Task Breakdown: App Launcher & Apps registry API
Ordered tasks derived from
plan.md. Each is small enough to land in one PR and ships with tests per Constitution VI. The schema task ships its migration in the same PR per Constitution V.
Epic ID: APW-11-app-launcher
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-09-17
How to use
- Tasks are sequential by default.
(parallel)means it may run alongside its predecessor. - Every task names the exact files to create or modify. new marks a file that does not exist yet; every other
path was checked with
git ls-filesondevelop@ee45946e5. - Every task has a Test line (file + assertion, or the run command for a test task) and a Done when line that is checkable without reading the diff.
- Add new tasks at the bottom rather than renumbering.
- Phase boundaries are ship boundaries:
developis green and deployable at the end of each phase. - Repository commands run from the monorepo root; migrations are authored from
apps/api/. - No string added by any task may claim single sign-on (launch-parity backlog G-09); T21 enforces it.
- Program audit resolutions (CONTRACTS.md §0) applied here: R-1 (T1), R-2 (T7), R-16 (T4, T6,
T18), R-19 (T25), R-22 (T9, T20 — no suite under
apps/api/test/; this epic ownsapps/web/e2e/flow-app-launcher-apps.spec.ts), R-25 (T30), R-26 (every audit-round change below is a new task or a new id inside an existing one — FR-55…FR-66, S23…S26, ACC-11-41…ACC-11-54 — and no earlier task, id or default is withdrawn). - Audit round 2026-09-17. Seven tasks gained a second half and three are new (T31–T33), because the programme
audit found work that was missing rather than wrong: the operator switch was never wired into the deploy
manifests, the
app_launcherActivity type had no Live Feed bucket, the PR lane had no way to seed a live App Work, and the exposure toggle's save path rewrote a README. Each addition is named in the task it belongs to and carries its own Test line. - Run commands:
- contracts:
pnpm --filter @ever-works/contracts test - agent:
pnpm --filter @ever-works/agent test -- app-launcher - API:
cd apps/api && pnpm test -- app-launcher - component:
pnpm --filter @ever-works/app-launcher test - web unit:
pnpm --filter ever-works-web test -- app-launcher; web e2e:pnpm --filter ever-works-web test:e2e app-launcher
- contracts:
Phase P1 — The launcher inside Ever Works (Wave 1)
Delivers spec FR-1…FR-44, FR-53, FR-54 and ACC-11-01…ACC-11-33.
P1.1 — Contracts and data
-
T1. Shared launcher types. Create
packages/contracts/src/apps/app-launcher.ts(new, Resolution R-1) with every type and constant in plan §3.3 —APP_LAUNCHER_ENVIRONMENTS,AppLauncherItem,AppLauncherListResponse,AppLauncherPreferenceChange,AppLauncherRejectionReason,AppLauncherRejection,AppLauncherSavePreferencesResponse,AppLauncherPinLimitErrorBody,AppLauncherPlatformsResponse,AppLauncherEmptyAction,APP_LAUNCHER_PIN_LIMIT = 6,APP_LAUNCHER_MAX_ITEMS_RESPONSE = 200,APP_LAUNCHER_MAX_CHANGES_PER_SAVE = 200,APP_LAUNCHER_MAX_PREFERENCE_ROWS = 500,APP_LAUNCHER_PANEL_PLATFORMS_MAX = 12,APP_LAUNCHER_PANEL_WORKS_MAX = 24,APP_LAUNCHER_CATALOG_MAX_ENTRIES = 24,APP_LAUNCHER_ICON_MAX_BYTES = 16_384,APP_LAUNCHER_CLIENT_CACHE_MS = 300_000. Modify APW-03's barrelpackages/contracts/src/apps/index.ts—export * from './app-launcher.js';(create the barrel, andexport * from './apps/index.js';inpackages/contracts/src/index.ts, only if APW-03 has not landed). Modifypackages/contracts/src/__tests__/index.barrel.spec.ts— in either branch, whether APW-03's barrel exists or this task creates it: add the importimport * as apps from '../apps/index.js';, add['apps', apps]to theAREASarray (:56-90), and recountexpect(exportLines).toBe(33)(:132) from the array — do not guess the number. Without this the barrel's name-collision check never sees a launcher type and stays green while two areas export the same name (APW11-G24). The same edit belongs in APW-03's own T1; it is requested there and repeated here so this epic cannot land a blind check. Test:packages/contracts/src/apps/__tests__/app-launcher.spec.ts(new) pins every numeric constant and the environment union; runpnpm --filter @ever-works/contracts test. Done when:pnpm --filter @ever-works/contracts buildemits the declarations,import { AppLauncherItem } from '@ever-works/contracts'resolves inapps/apiandapps/web, andpackages/contracts/src/__tests__/index.barrel.spec.tspasses withappsinAREASand its literal recounted from the array — deleting a launcher type from the barrel makes the collision check run over it (ACC-11-54). -
T2. Entity and Work column. Create
packages/agent/src/entities/app-launcher-preference.entity.ts(new) per plan §3.2:userId(@ManyToOne(() => User, { onDelete: 'CASCADE' })),scopeKeyvarchar(40),itemKeyvarchar(64),visibledefaulttrue,pinneddefaultfalse,pinOrdersmallintnullable,sortOrderintegernullable,createdAt,updatedAt;@Unique('uq_app_launcher_prefs_user_scope_item', ['userId', 'scopeKey', 'itemKey']),@Index('idx_app_launcher_prefs_user_scope', ['userId', 'scopeKey']). NotenantId/organizationIdcolumns (plan §3.2 explains why). Modifypackages/agent/src/entities/work.entity.ts— append@Column({ type: 'boolean', nullable: true }) appLauncherExposed?: boolean | null;at the end of the column list. Modifypackages/agent/src/entities/index.ts,packages/agent/src/database/_entity-names.ts,packages/agent/src/database/_entities-inventory.ts— registerAppLauncherPreference. Test:packages/agent/src/entities/__tests__/app-launcher-preference.entity.spec.ts(new) — index and unique names, no scope-stamp columns, defaults. Done when: the entity drift specs inpackages/agent/src/database/pass without a magic-number edit andpnpm --filter @ever-works/agent buildis clean. -
T3. Migration. Create
apps/api/src/migrations/1792110000000-CreateAppLauncherPreferences.ts(new) —ALTER TABLE "works" ADD COLUMN "appLauncherExposed" boolean NULL;CREATE TABLE "app_launcher_preferences"with the unique constraint and index from T2.down()drops only whatup()created. Re-stamp above the newest migration ifdevelopmoved past1791240000000-AddSafetyRailsCore.ts(newest onee45946e5). Test:apps/api/src/migrations/__tests__/CreateAppLauncherPreferences.spec.ts(new) — noDROP COLUMN/ rename of a pre-existing column inup();down()drops exactly the column, table and indexup()created; on SQLite an existing Work readsappLauncherExposed = NULLafterup(). Done when: a fresh database and a database with existing Works both migrate, and every existing Work readsappLauncherExposed = NULL.
P1.2 — Agent domain
-
T4. Pure address and order modules. Create
packages/agent/src/app-launcher/launcher-address.ts(new) —resolveLauncherAddress()andtoSafeLauncherUrl()exactly as in plan §4.6. Createpackages/agent/src/app-launcher/launcher-order.ts(new) —orderLauncherItems()implementing spec FR-26 and the section caps of FR-4 (panel) vs 200 (response). Test:packages/agent/src/app-launcher/__tests__/launcher-address.spec.tsandlauncher-order.spec.ts(new) — every row of plan §10.1 for these two files: earliest verified domain wins, a second domain never moves the tile, removing the first falls back to the second then the subdomain (ACC-11-10), a managed subdomain alone yields an address (ACC-11-09); 140 exposed live Works yield 24 panel tiles andworksTotal: 140(ACC-11-14); catalog order for Ever apps (ACC-11-05); an App Work whosemanagedSubdomainlabel was allocated on a dedicated apps apex resolves to<label>.<apps-apex>and never to a host underEVER_WORKS_DOMAIN, with the default resolver (no binding) and with a bound published-host port both covered (ACC-11-41); a reorder writesorderfor every item of its section, and the mergedglobal ∪ scopeKeypinned view is what the 6-pin rule counts (ACC-11-47). Done when: both specs pass with 100% branch coverage of the two modules, including the apps-apex-never-platform-domain case. -
T5 (parallel with T4). Repository methods. Create
packages/agent/src/database/repositories/app-launcher-preference.repository.ts(new) —findForUser(userId, scopeKeys),upsertMany(userId, rows, manager)usingON CONFLICT ("userId","scopeKey","itemKey") DO UPDATE,countPinned(userId, scopeKeys, manager),pruneIneligible(userId, eligibleKeys, keep = 500, manager). Modifypackages/agent/src/database/repositories/work.repository.ts— addfindLauncherCandidates({ userId, memberWorkIds, organizationId, limit }): creator-or-member,status <> 'archived',organizationId = :orgorIS NULLfor personal scope,updatedAt DESC,limit ≤ 500. Modifypackages/agent/src/database/repositories/work-deployment.repository.ts— addfindLatestReadyForWorks(workIds, environment)(state = 'READY', newest per Work), next tofindLatestForWorks(line 48). Modifypackages/agent/src/database/repositories/work-custom-domain.repository.ts— addfindVerifiedProductionForWorks(workIds)(verified = true,environment = 'production',createdAt ASC). Modifypackages/agent/src/database/index.ts— export the new repository. Read APW-06's runtime state (audit round — ACC-11-42). Inject APW-06'sWorkAppRuntimeStateRepository(APW-06 T17,work_app_runtime_states, columnspaused/pausedAt,removedAt, APW-06 plan §7.2:1000-1001)@Optional()inAppLauncherServiceand call a newfindStateForWorks(workIds)for the candidate set; no row, or the repository being unbound before APW-06 lands, means "not paused". APW-10's quarantine is read through the same optional injection of itsAppsTierPolicyport (APW-06 T3 / APW-10), never by a direct table read. Nothing here writes those tables. Test:packages/agent/src/database/repositories/__tests__/app-launcher-preference.repository.spec.ts(new) — two writers changing different items both persist and the same item takes the last write (ACC-11-20); re-sending identical values changes nothing; extendingpackages/agent/src/database/repositories/__tests__/work.repository.spec.ts,work-deployment.repository.spec.ts,work-custom-domain.repository.spec.tsfor the three new methods (preview and non-READYrows excluded; archived Works excluded; Organization filter); andwork-deployment.repository.spec.tsalso pins that the latest row is chosen by(createdAt DESC, id DESC)and that aSUPERSEDEDrow (APW-06) is skipped (ACC-11-44). Done when: all repository specs pass in the SQLite unit lane, and a Work with a paused runtime-state row never appears in a listing built by the service (ACC-11-42). -
T6.
AppLauncherService. Createpackages/agent/src/app-launcher/app-launcher.service.ts(new) —listForUser(user, scope, platforms, { includeHidden, limit })andsavePreferences(userId, scope, changes, platforms)per plan §4.1–4.2, including the eligible-key set,unknownItem/cannotHideCurrentrejections, whole-save refusal on pin limit (throwsAppLauncherPinLimitError), and the 500-row prune. Createpackages/agent/src/app-launcher/app-launcher.errors.ts(new) —AppLauncherPinLimitErrorcarrying the{ code: 'pinLimit', limit }body the controller maps to422(plan §3.3, §4.5). InjectManagedHostRootResolver(new token inpackages/agent/src/app-launcher/managed-host-root.resolver.ts) with a default binding that resolves the apps apex for kindapp—EVER_WORKS_APPS_DOMAINwhen set, elseEVER_WORKS_DOMAIN— the platform root for every other kind, andnullwhen neither is configured (so no host is invented).packages/agent/src/config/index.tshas no accessor for either variable, so it is read fromprocess.envaspackages/agent/src/ever-works-providers/subdomain-allocator.service.ts:192does (process.env.EVER_WORKS_DOMAIN?.trim() || 'ever.works'). InjectAPP_PUBLISHED_HOSTS(AppPublishedHostsPort.primary(workId), bound by APW-06'sAppHostsService) andMANAGED_HOST_ROOT_RESOLVER@Optional()and appended last, so APW-11 P1 is correct before APW-06 T48 bindsAppManagedHostRootResolverand the bound value wins when it is there (plan §4.6, spec FR-55). Never synthesise<label>.<EVER_WORKS_DOMAIN>for a Work whose label was allocated elsewhere. Read the item name fromWorkAppSpecState.displayNamewhen the Work has one (APW-03 plan §3.1,varchar(80)), elsework.name, capped at 100 (spec FR-57). Setmeta.appWorksAvailablefrom the App Works gate (spec FR-64) and markmanageState: 'notLive'for a paused, removed or quarantined App Work (spec FR-56). Createpackages/agent/src/app-launcher/app-launcher.module.tsandindex.ts(new) —TypeOrmModule.forFeature([AppLauncherPreference]), providers, exports. Test:packages/agent/src/app-launcher/__tests__/app-launcher.service.spec.ts(new) — an App Work with aREADYproduction deployment appears with no setting (ACC-11-09); a directory Work appears only when exposed and only for members who can view it (ACC-11-11); a failed latest deployment after an earlier success carrieslastDeployFailed(ACC-11-12); a preview deployment alone never lists (ACC-11-13); Work pins differ between two Organizations while Ever app pins are shared (ACC-11-21); 200 cap withtruncated; aCANCELEDorSUPERSEDEDlatest row chips nothing and aROLLED_BACKone carrieslastDeployFailed(ACC-11-44); an item whosedisplayName(with a community-build suffix) is set is named from it (ACC-11-43); a paused / removed / quarantined App Work isnotLivewith its preference row kept (ACC-11-42); the default resolver alone never yields a host underEVER_WORKS_DOMAINfor a kind-appWork, and a bound fake published-host port wins (ACC-11-41);app-launcher.save.spec.ts(new) — the current platform has no hide (ACC-11-06); a seventh pin refuses the whole save (ACC-11-19); a key for another Organization's Work is rejected with a reason byte-identical to a nonexistent Work's (ACC-11-22); a save in Organization B leaves Organization A's pins and orders untouched (ACC-11-47). Done when: no test can make a preview-only or never-READYWork appear, an inaccessible key's rejection is byte-identical to a nonexistent key's, and no test can make an App Work resolve under the platform's own domain while its label belongs to the apps apex. -
T7. Exposure on Work update. Modify
packages/agent/src/dto/update-work.dto.ts— optionalappLauncherExposed?: boolean | nullwith@IsOptional()and a boolean-or-null validator;@ApiPropertyOptionaldescription "Show this Work in members' App Launcher (null = kind default)". Modifypackages/agent/src/services/work-lifecycle.service.tsupdateWork(line 851) — afterensureCanEdit, persist the field when present and changed, then log Activity withactionType: ActivityActionType.APP_LAUNCHERandactionapp.launcher.exposedorapp.launcher.hidden(Resolution R-2),status: ActivityStatus.COMPLETED, an Englishsummarythat names neither the Work nor the address, andmetadata: { explicit, previousEffective }(spec FR-61, plan §4.4). "Changed" compares(storedValue, storedExplicit)with(newValue, newExplicit), sonull → trueon anappWork is a real change andnull → nullis a no-op that writes nothing. Modifypackages/agent/src/entities/activity-log.types.ts— append one memberAPP_LAUNCHER = 'app_launcher'. Modifypackages/agent/src/activity-log/feed-kind.ts— add[ActivityActionType.APP_LAUNCHER]: 'work'toFEED_KIND_RULES(the table starts at:53). The member must have an entry:feed-kind.spec.ts:14-18fails on any enum member without one, so this task cannot land without the classification, and'work'is the correct bucket — an exposure change is Work metadata changing, not a delivery and not a held decision (APW11-G05). If the decision is ever revisited it is a different value in the same table, never an omission. Modifypackages/agent/src/services/work-query.service.ts— addappLauncher: { exposed, effectiveExposed, live }to the Work detail payload (one extra batched read of latestREADYdeployment + verified domains, reusing T5 methods). Test:packages/agent/src/services/__tests__/work-lifecycle.app-launcher-exposure.spec.ts(new) — one Activity row per real change withactionType: 'app_launcher', the dottedaction, thesummary,statusandmetadataof plan §4.4, none on a no-op, no address or Work name in any field, viewer refused (ACC-11-16, ACC-11-46); extendpackages/agent/src/entities/__tests__/activity-log.types.spec.tsfor the new member and run the existingpackages/agent/src/activity-log/feed-kind.spec.ts, which must pass with the new entry and fail without it (APW11-G05); extendpackages/agent/src/services/__tests__/work-query.service.spec.tsfor theappLauncherpayload. Done when:PUT /api/works/:id { "appLauncherExposed": true }by an editor writes one Activity entry, and by a viewer answers the existing edit-rights error, andpnpm --filter @ever-works/agent test -- feed-kind activity-log.types work-lifecycle.app-launcher-exposureis green.
P1.3 — API
-
T8. Platform catalog service. Create
apps/api/src/app-launcher/platform-catalog.schema.tsandapps/api/src/app-launcher/platform-catalog.service.ts(new) per plan §5.2: envEVER_WORKS_PLATFORM_CATALOG_REPO(regex^ever-works\/[a-z0-9-]+$),_REF,_ENV,_SELF_ID; 8,000 ms fetch timeout; icons inlined as data URIs ≤ 16,384 bytes; SVG deny patterns; ≤ 24 entries; success TTL 3,600,000 ms, failure TTL 30,000 ms,:last-goodentry without TTL. Add the non-production source overrideEVER_WORKS_PLATFORM_CATALOG_BASE_URL(plan §5.2): honoured only whenNODE_ENV !== 'production'and the programme'sEVER_WORKS_E2E_FAKESswitch is set, read through the same kind of getter that guards the existing lane switches (config.subscriptions.bypassSeatLimitsInE2E(),packages/agent/src/config/index.ts:824-829) — APW-13 owns theEVER_WORKS_E2E_FAKESaccessor, so until it lands read both variables in one place in this service with the production branch first. It replaces the raw host forplatforms.jsonand the icons, and every other rule — scheme, size, SVG deny patterns, entry cap — still applies while it is active. A browser route handler cannot substitute for it: the fetch happens in the API process (APW11-G06). Log the environment when it is unset outside production (app_launcher.catalog.environment_unset, error level, counted) — spec FR-10 and plan §5.2; T31 sets it per manifest. Test:apps/api/src/app-launcher/platform-catalog.service.spec.ts(new) — 25th entry dropped,javascript:andhttp:dropped (ACC-11-08), oversize icon → entry kept without icon, SVG with<scriptrejected, blocked source with no prior read →catalogAvailable: falseand last-good served after a prior read (ACC-11-07), entries forstagediffer fromproductionand an entry without adevelopURL is absent fordevelop(ACC-11-05),evil/platformsrepo refused at boot; the base-URL override is ignored withNODE_ENV=productionand applied when the fakes switch is on (ACC-11-51); the service readsfixtures/platforms.fixture.json, the fixture the catalogue drafts ship, and the same fixture'sjavascript:/http:/25th-entry cases are the ones asserted above; an unset_ENVoutside production logs the error. Done when: with the fixture catalog the service returns entries forstagethat differ fromproduction, an entry lacking adevelopURL is absent fordevelop, and a production boot with the override set still readsever-works/platforms. -
T9. Controllers, guard, module. Create
apps/api/src/app-launcher/guards/app-launcher-enabled.guard.ts(new) — 404 unlessEVER_WORKS_APP_LAUNCHER_ENABLED === 'true', mirroringapps/api/src/fleet/guards/fleet-enabled.guard.ts. Createapps/api/src/app-launcher/dto/app-launcher.dto.ts(new) —ListAppLauncherQueryDto(includeHidden,limit1..200) andSaveAppLauncherPreferencesDto(@ArrayMinSize(1) @ArrayMaxSize(200), key regex,order0..9999). Createapps/api/src/app-launcher/app-launcher.controller.ts(new) —@Controller('api/me/apps'):GET /(@Throttle({ long: { limit: 60, ttl: 60_000 } })) andPUT preferences(@Throttle({ long: { limit: 30, ttl: 60_000 } })), scope fromScopeContextService.getScope(),AppLauncherPinLimitError→422 { code: 'pinLimit', limit: 6 }. Createapps/api/src/app-launcher/app-launcher-platforms.controller.ts(new) —@Public()GET api/app-launcher/platforms(@Throttle({ long: { limit: 120, ttl: 60_000 } }),Cache-Control: public, max-age=3600, stale-while-revalidate=600,Access-Control-Allow-Origin: *, no credentials). Createapps/api/src/app-launcher/app-launcher.module.ts(new); modifyapps/api/src/api.module.tsto import it next toWorkAgentModule. Modifypackages/agent/src/config/index.ts— add one accessor,config.appLauncher.isEnabled(), accepting the platform's existingtruthy()set ('true' | '1' | 'yes'— the valuesapi.controller.ts:92accepts today, so no installation that works today stops working), and make the guard andapi.controller.tsboth call it, so the web UI and the API can never disagree (APW11-G12, plan §7). An unset or unparseable value is off. Modifyapps/api/src/api.controller.ts—features.appLauncherEnabled: config.appLauncher.isEnabled(). Modifyapps/web/e2e/flow-config-public-contract.spec.ts— addappLauncherEnabledto the sorted feature key list at:120-125, andapps/api/src/api.controller.spec.ts— itsit.eachrows for the new key, including'1'and'yes'. Test:apps/api/src/app-launcher/app-launcher.controller.spec.ts(new) — every P1 row of plan §10.2: throttle metadata 60 reads / 30 writes (ACC-11-26), 404 on every route with the env unset (ACC-11-28); thefeatures.appLauncherEnabledrows above (ACC-11-28 half); the422 pinLimitbody declared byapp-launcher.errors.ts;app-launcher-platforms.controller.spec.ts(new) —@Public()and the 1-hour cache header (ACC-11-27); and, replacing the formerapps/api/test/suite (Resolution R-22),apps/api/src/app-launcher/app-launcher.registry.integration.spec.ts(new, plan §10.2) — in-memory better-sqlite3 withENTITIES, 200 live Works for one person, 50 calls: p95 < 300 ms and no response holds more than 200 items (ACC-11-25); runcd apps/api && pnpm test -- app-launcher. Done when:cd apps/api && pnpm testis green; with the env unset every route answers 404; andpnpm --filter ever-works-web test:e2e flow-config-public-contractis green with the new key.
P1.4 — The web component (host-fed mode)
-
T10. Package scaffold. Create
packages/app-launcher/package.json(new,@ever-works/app-launcher,"private": true,"type": "module", dependencylit),tsup.config.ts,tsconfig.json,vitest.config.ts(happy-dom),src/index.ts,src/types.ts,src/strings.ts,scripts/check-size.mjs(gzip ofdist/index.js≤ 30,720 bytes, run bypnpm test). SetnoExternal: ['lit']intsup.config.ts(plan §6.1): tsup externalisesdependenciesby default, which would leave Lit out ofdist/index.js— so the size check would measure the wrong artifact and T27's static fixture pages could not resolve the bare import. The precedent ispackages/plugins/k8s/tsup.config.ts:5. One self-contained ESM file, no import map (APW11-G15). Add the licence note forlit(BSD-3-Clause) andhappy-dom(MIT) beside the repository's dependency notes, and regeneratepnpm-lock.yamlin this PR — neither package appears in it today. Modifyapps/web/package.json—"@ever-works/app-launcher": "workspace:*"independencies(next to@ever-works/contracts/@ever-works/pluginat:37-38). This belongs in P1, not in T28: the web imports the package from T14, and the Docker web build works from aturbo pruned tree (.deploy/docker/web/Dockerfile:61,95,102,turbo build --filter=ever-works-web...), so an undeclared workspace package is neither linked nor built and the web image fails to resolve the import (APW11-G14).pnpm-workspace.yamlalready globspackages/*, so no workspace edit is needed. Test:scripts/check-size.mjsfailspnpm --filter @ever-works/app-launcher testwhen the gzip ofdist/index.jsexceeds 30,720 bytes (ACC-11-35);packages/app-launcher/src/__tests__/index.spec.ts(new) imports the entry twice without acustomElements.defineerror;scripts/check-size.mjsalso asserts the built file contains no barefrom 'lit'import. Done when:pnpm --filter @ever-works/app-launcher build testpasses on an empty element, andpnpm turbo build --filter=ever-works-web...builds@ever-works/app-launcherfirst (APW11-G14). -
T11. Keyboard model and URL guard. Create
packages/app-launcher/src/grid-navigation.tsandsrc/safe-url.ts(new). Test:packages/app-launcher/src/__tests__/grid-navigation.spec.ts(new) — all keys of spec §6.6 at 2 and 3 columns,Home/End, typeahead wrap-around (ACC-11-29);safe-url.spec.ts(new) —javascript:andhttp:refused,http://localhostaccepted only when allowed, the returned URL equals the input exactly with nothing added (ACC-11-08, ACC-11-23). Done when: both specs pass with 100% branch coverage of the two modules. -
T12.
<ever-app-launcher>element. Createpackages/app-launcher/src/ever-app-launcher.tsandsrc/styles.ts(new) per plan §6.2–6.3: trigger button witharia-haspopup="menu"/aria-expanded/aria-controls;role="menu"panel with labelled groups androle="menuitem"anchor tiles (target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer"); roving tabindex; focus trap;Escrestores focus;ResizeObservercolumns (3 ≥ 360 px, else 2); bottom sheet under 640 px; 88 px fixed tile height; skeletons; chips as text;show()/hide(); events:open,:close, cancellable:item-activate,:manage,:retry,:empty-actionwith{ action: 'createAppWork' | 'goToWorks' }emitted by the S8 button (the element never navigates itself in host-fed mode, and it usesappWorksAvailable— the property, else the registry'smeta.appWorksAvailable— to choose which button to render);--ever-app-launcher-*custom properties; no global style insertion; guardedcustomElements.define. Test:packages/app-launcher/src/__tests__/ever-app-launcher.spec.ts(new) — sections Pinned / Ever apps / Your apps / Manage apps in order and 3 vs 2 columns (ACC-11-02); 6 skeleton tiles at 88 px whileloading(ACC-11-03); You're here not a link (ACC-11-06); cancelled activation opens nothing (ACC-11-40); both empty states render the right button and dispatch:empty-actionwith the rightdetail, and no navigation happens inside the element (ACC-11-48); focus trap; no<style>added todocument.head. Done when: the size check passes and the spec is green.
P1.5 — Web integration
-
T13. Flag helper and public config plumbing. Create
apps/web/src/lib/feature-flags/app-launcher.ts(new,server-only) —isAppLauncherEnabled(distinctId):features.appLauncherEnabled === trueand (PostHog not configured orisFeatureEnabled('app-launcher', distinctId) === truewithin 1,500 ms). Any error or timeout →false. Name the reader (APW11-G12). No web code reads/api/configtoday (git grepinapps/web/srcfinds no caller), so the helper must own the read: a server-sidefetch(${API_URL}/api/config, { cache: 'no-store', signal: AbortSignal.timeout(1_500) }), the boolean read fromfeatures.appLauncherEnabled, andfalseon any non-200, parse error or timeout — the same fail-closed posture as the PostHog half. It must not readEVER_WORKS_APP_LAUNCHER_ENABLEDfrom its own environment: the web and the API are separate deployments, and a web-side copy of the environment would drift from the API's answer. Modifyapps/web/src/app/[locale]/(dashboard)/layout.tsxandlayout-client.tsx— compute once, passappLauncherEnabledtoDashboardHeader, the palette context and the settings layout. Test:apps/web/src/lib/feature-flags/app-launcher.unit.spec.ts(new) — the fail-closed matrix (config off; config unreachable; config times out; PostHog unset; flag true/false/undefined/timeout) (ACC-11-28 flag half). Done when: the spec passes, a PostHog timeout yieldsfalse, and a/api/configtimeout yieldsfalse. -
T14. BFF route and React wrapper. Create
apps/web/src/app/api/me/apps/route.ts(new) —GETproxy that forwards onlyincludeHiddenas a query parameter and must carry the workspace scope. Do not copyapps/web/src/app/api/usage/costs/[section]/route.tsas the plan's earlier draft suggested: that route sets onlyAuthorization(route.ts:35-40), and a request without the scope header is resolved by the API as personal scope (apps/api/src/scope/scope-resolver.middleware.ts:41-45,apps/api/src/scope/session-scope.guard.ts:124-128), so no Organization-scoped Work would ever be listed. Proxy throughserverFetch(apps/web/src/lib/api/server-api.ts:102-131, which readsx-ever-workspaceand sets the API scope header at:108-120) or reproduce those four lines, and assert the header in the spec below (APW11-G04). Createapps/web/src/components/app-launcher/AppLauncherButton.tsxandAppLauncherProvider.tsx(new) per plan §6.4: lazy element import inuseEffect, a 5-minute module cache keyed by the serialized workspace scope — an entry from another scope is never rendered, and the element closes when the scope changes (spec FR-66, ACC-11-49) —browserApiFetch('/api/me/apps'), strings fromuseTranslations('dashboard.appLauncher'),:manage→ROUTES.DASHBOARD_SETTINGS_APP_LAUNCHER,:empty-action→ROUTES.DASHBOARD_WORKS_NEW(with theappkind pre-chosen) orROUTES.DASHBOARD_WORKS(apps/web/src/lib/constants.ts:144-145), the element'sappWorksAvailablefrommeta.appWorksAvailable, chunk-load failure renders a disabled control withdashboard.appLauncher.controlUnavailable. Modifyapps/web/messages/en.jsonand the 20 sibling locale files — thedashboard.appLauncher.*keys this task renders from plan §8, includingcontrolUnavailableandemptyActionFailed, in this same PR (README §7 rule 11, APW11-G23). Createapps/web/src/lib/app-launcher/app-launcher-telemetry.ts(new) — closed union of the four events in plan §9.1, copyingapps/web/src/lib/help/help-telemetry.ts. Modifyapps/web/src/components/dashboard/DashboardHeader.tsx— optionalappLauncher?: booleanprop; render<AppLauncherButton />after the Help button (line 149). Nothing else in the header moves. Test:apps/web/src/components/app-launcher/AppLauncherButton.unit.spec.tsx(new) — a second open within 5 minutes renders from cache with no fetch (ACC-11-03), refetch after, a switch to another Organization closes the element and never renders the previous scope's items (ACC-11-49),:empty-actionroutes to the right constant for each detail and a failed push renders the failure copy (ACC-11-48), the telemetry payload of three tile opens has no host, URL or Work name (ACC-11-32);apps/web/src/app/api/me/apps/route.unit.spec.ts(new) — forwards onlyincludeHidden, forwards the workspace scope header to the API, never puts the token in the upstream URL (ACC-11-24 unit half, APW11-G04);apps/web/src/components/dashboard/DashboardHeader.app-launcher.unit.spec.tsx(new) — without the prop the right cluster is unchanged, with it the control is last (ACC-11-01). Done when: the three specs pass, the message spec of T19 sees everydashboard.appLauncher.*key in all 21 files, andapps/web/e2e/command-palette.spec.tspasses unchanged with the flag off. -
T15. Command palette entry. Modify
apps/web/src/components/command-palette/registry/types.ts— optionalopenAppLauncher?: () => voidonPaletteCommandContext. Modifyapps/web/src/components/command-palette/registry/commands.ts— commandopenAppLauncher(lucideLayoutGrid),available: (ctx) => ctx.openAppLauncher !== undefined. Modifyapps/web/src/components/command-palette/CommandPalette.tsx— pass the provider's opener. Test: extendapps/web/src/components/command-palette/registry/registry.unit.spec.ts— the command exists only whenopenAppLauncheris provided and matcheslauncher,appsandswitch app(ACC-11-04 unit half). Done when:apps/web/e2e/command-palette.spec.tspasses unchanged. -
T16. Manage apps settings page. Modify
apps/web/src/lib/constants.ts—DASHBOARD_SETTINGS_APP_LAUNCHER: '/settings/app-launcher'. Modifyapps/web/src/app/[locale]/(dashboard)/settings/settings-layout-client.tsx— tab afternotifications, rendered only when enabled. Modifyapps/web/src/app/[locale]/(dashboard)/settings/layout.tsx— the nested server layout renders the nav and already computes a flag of its own (isFleetEnabled()at:21); a parent App Router layout cannot pass props into it, so this file callsisAppLauncherEnabledand passesappLauncherEnabledtoSettingsLayoutClientwith a default offalse(APW11-G13). Createapps/web/src/app/[locale]/(dashboard)/settings/app-launcher/page.tsx(new, server;notFound()when disabled),apps/web/src/components/settings/AppLauncherSettings.tsx(new),apps/web/src/lib/api/app-launcher.ts(new, server-onlyserverFetch), andapps/web/src/app/actions/settings/app-launcher.ts(new,saveAppLauncherPreferencesAction). Rows: Show toggle, Pin toggle (7th disabled with tooltip), Move up/down,Alt+↑/↓, native drag handle; 500 ms debounced batch save;Saving…/Saved/Couldn't save. Try again.; a reorder that writesorderfor every item of the section it moves within (spec FR-62); past 200 eligible items the header Showing 200 of {count} with theFilter appscontrol (spec FR-63). Modifyapps/web/messages/en.jsonand the 20 sibling locale files — thedashboard.settings.appLauncher.*anddashboard.settings.tabs.appLauncherkeys this task renders from plan §8, including the section headings, the Hidden by the Work row action, the filter and the count, and the accessible names of the Show, Pin and drag controls, in this same PR (APW11-G23). Test:apps/web/src/components/settings/AppLauncherSettings.unit.spec.tsx(new) — debounce batches changes into one save, pin counter, seventh pin disabled with "Six pins is the limit. Unpin one first." (ACC-11-19, ACC-11-18 unit half); a move sendsorderfor every row of its section (ACC-11-47); past 200 items the count line and filter render and every eligible item stays reachable (ACC-11-47); extendapps/web/src/app/[locale]/(dashboard)/settings/settings-layout-client.unit.spec.tsx— the tab renders only when enabled (ACC-11-28) — and addapps/web/src/app/[locale]/(dashboard)/settings/layout.unit.spec.tsx(new) for the prop being passed and defaulting tofalse. Done when: reloading the page after each control shows the saved state, and the message spec of T19 finds everydashboard.settings.appLauncher.*key in all 21 locales. -
T17. Work setting toggle, on both surfaces. Create
apps/web/src/components/works/detail/settings/AppLauncherExposureSetting.tsx(new) — readswork.appLauncher, disabled with the not-live copy, read-only for viewers. Do not save throughuseSettings().handleUpdate(APW11-G02).handleUpdate(apps/web/src/components/works/detail/settings/SettingsContext.tsx:76-92) submits the whole General form toupdateWork, whose zod object (apps/web/src/app/actions/dashboard/works.ts:596-614) lists onlyname/description/owner/organization/websiteTemplateId/readmeConfigand therefore stripsappLauncherExposed, after which the action callsworkAPI.update(:639) andworkAPI.updateReadme(:641) — the choice would never persist and every toggle would rewrite the Work's README. CreatesetWorkAppLauncherExposureAction(workId, value: boolean | null)inapps/web/src/app/actions/dashboard/works.tsinstead: it callsPUT /api/works/:idwith exactly{ appLauncherExposed: value }, saves on the toggle, rendersSaved/Couldn't save. Try again.in place, and never callsupdateReadme. Createapps/web/src/components/works/detail/overview/AppLauncherExposureCard.tsx(new) and mount it inapps/web/src/app/[locale]/(dashboard)/works/[id]/page.tsx(APW11-G03): the settings page answersnotFound()unlesscanAccessSettings(work.userRole)(works/[id]/settings/page.tsx:31-33, MANAGER perapps/web/src/lib/permissions.ts:70-71) while the API grants the change to EDITOR (packages/agent/src/services/work-ownership.service.ts:142-143), so editors and viewers need the Overview card. Both surfaces render the same state and use the same action; the settings card is not moved or changed. Modifyapps/web/src/components/works/detail/settings/GeneralSettings.tsx— mount the setting at the end of the card, only when the launcher is enabled. Modifyapps/web/src/components/works/detail/settings/SettingsForm.tsx(or theSettingsProvidercontext) andapps/web/src/app/[locale]/(dashboard)/works/[id]/settings/page.tsx— carry theappLauncherEnabledprop from T13 toGeneralSettings, defaulting tofalse(APW11-G13). Modify the web Work type inapps/web/src/lib/api/work.ts— add the optionalappLauncherobject andappLauncherExposed?: boolean | nulltoUpdateWorkDto(:111) so the action's body is typed. Modifyapps/web/messages/en.jsonand the 20 sibling locale files — thedashboard.workDetail.settings.appLauncher.*keys from plan §8, includingresetToDefault, the toggle's accessible name and the save states, in this same PR (APW11-G23). Test:apps/web/src/components/works/detail/settings/AppLauncherExposureSetting.unit.spec.tsx(new) — a viewer sees it read-only with "Only editors can change this." (ACC-11-15); a not-live Work shows it disabled with the stored choice kept (ACC-11-17); the toggle calls the dedicated action with exactly one field and neverupdateWork/updateReadme(ACC-11-46);Reset to defaultsendsnulland offers itself only when an explicit choice is stored (ACC-11-46);apps/web/src/components/works/detail/overview/AppLauncherExposureCard.unit.spec.tsx(new) — viewer read-only copy, editor toggle, same state as the settings card (ACC-11-45). Done when: the two specs pass,SettingsForm.unit.spec.tsxpasses unchanged with the flag off, and no path in the launcher callsupdateReadme.
P1.6 — Catalog repository, i18n, e2e, docs
-
T18. Land the
ever-works/platformscatalog content. (owner action — outside this monorepo) The repository exists (created 2026-09-17;BUILD-READINESS.md§6 item 4), so this task is no longer "create it": it reviews the drafts incatalog-draft/, supplies the per-environment addresses, and lands them as the repository's first commit content, with the layout, schema and CI exactly as plan §5.1:platforms.json,schema/platforms.schema.json,.github/workflows/validate.yml,fixtures/platforms.fixture.json,icons/ever-works.svg,icons/ever-gauzy.svg,icons/ever-teams.svg,README.md,CONTRIBUTING.md,LICENSE; first entries: Ever Works, Ever Gauzy, Ever Teams with owner-supplied addresses per environment; tagv1.0.0. Decide read access (plan §5.1): either the repository is made public, as the sibling listingever-works/templatesis, or the reader carries a token. The reader fetches over the raw host, so a private repository with neither makes every read fail and leaves Ever apps showing S9 forever. Record whichever answer is chosen here. Name an owner per entry beyond the first three: README §1 names Ever Rec as a launcher destination and it is not in the seeded set, so it needs an entry and a named owner (spec §9 question 3), and so does every platform added later. Test: that repository's.github/workflows/validate.yml(schema, icon size, https-only, unique ids, ≤ 24 entries) passes on the tag and fails on a fixture pull request adding a 25th entry;apps/api'splatform-catalog.service.spec.tsreadsfixtures/platforms.fixture.jsonfrom the same draft set (T8). Done when: itsvalidate.ymlpasses, the chosen answer on read access is recorded, andEVER_WORKS_PLATFORM_CATALOG_REF=v1.0.0on stage renders the catalog's tiles. -
T19. i18n keys — cross-locale completeness. Modify
apps/web/messages/en.json— every key in plan §8 (P1 keys only; the three P2 keys land in T27), then the same keys in the 20 sibling locale files inapps/web/messages/. Leaf names camelCase with no literal.. The keys themselves land with their surfaces (APW11-G23). T14 (panel and palette context keys), T16 (Manage apps and settings-tab keys) and T17 (Work-setting keys) each add the group they first render in their own PR, because README §7 rule 11 and this file's task rules both require each task to keepdevelopdeployable; this task is the completeness pass — it verifies all 21 files carry every key, fills any locale a component task added only toen.json, and owns nothing that a component already renders. Test:apps/web/src/lib/app-launcher/__tests__/app-launcher-messages.unit.spec.ts(new) — every P1 key of plan §8 resolves to a non-empty string in all 21 locale files, no leaf name contains a dot, and everyt('…')key used byAppLauncherButton.tsx,AppLauncherSettings.tsx,AppLauncherExposureSetting.tsxandAppLauncherExposureCard.tsxexists inen.json(ACC-11-31). Done when: the spec passes andpnpm --filter ever-works-web buildreports no missing messages. -
T20. P1 e2e. Create
apps/web/e2e/flow-app-launcher-apps.spec.ts(the file ACCEPTANCE.md E2E-12 names; owned by this epic per Resolution R-22 — APW-13 references it and does not create it),apps/web/e2e/app-launcher-manage.spec.ts,apps/web/e2e/app-launcher-exposure.spec.ts,apps/web/e2e/app-launcher-keyboard-a11y.spec.ts,apps/web/e2e/app-launcher-flag-off.spec.ts(new) per plan §10.5 andapps/web/e2e/helpers/app-launcher-seed.ts(new). PrefergetByTestIdfor tiles (app-launcher-tile-<key>). Modify.github/workflows/e2e.yml—EVER_WORKS_APP_LAUNCHER_ENABLED: 'true'andE2E_APP_LAUNCHER_SEED: 'true'in the main shard's env block (theenv:at:261, besideSUBSCRIPTIONS_ENABLED: 'true'at:351), and a new, separately gated job that boots the same local stack withEVER_WORKS_APP_LAUNCHER_ENABLEDunset and runs onlyapp-launcher-flag-off.spec.ts. The file sets the API environment once for the whole prebuilt stack and has no per-spec toggle, so this is the only way both lanes can run in the PR without weakening ACC-11-28 (APW11-G08).EVER_WORKS_PLATFORM_CATALOG_BASE_URLpoints the API at the fixture catalog in both jobs, withEVER_WORKS_E2E_FAKES: '1'(T8, APW11-G06). Seed through the route, never the database (APW11-G07). The lane's API runs on in-memory sqlite (DATABASE_IN_MEMORY: 'true',e2e.yml:271-272) and no e2e helper touches a database — the file the earlier draft named,apps/web/e2e/flow-org-upgrade-from-account.spec.ts, is API-only. The helperapps/web/e2e/helpers/app-launcher-seed.tstherefore calls T33'sPOST /api/e2e/app-launcher/seed(404 unlessNODE_ENV !== 'production'andE2E_APP_LAUNCHER_SEED === 'true') and names the rows each case needs: one Work with aREADYproduction deployment + managed subdomain, one with an earlierREADYand a laterERRORdeployment, one with a verified production custom domain, one never deployed, and one withkind: 'app'written as the raw varchar until APW-01 lands. Test: runpnpm --filter ever-works-web test:e2e app-launcher. Assertions: control last in the header (ACC-11-01); sections and columns (ACC-11-02); palette →launcher→Enterfocuses the first tile (ACC-11-04); tiles match the fixture for the environment (ACC-11-05); live App Work present, failed-after-success chip (ACC-11-09, ACC-11-12); exposure by an editor visible to a second member and not to a non-member (ACC-11-11); viewer read-only (ACC-11-15); one Activity entry per exposure change (ACC-11-16); pin, hide and move reflected in a second browser context, seventh pin refused (ACC-11-18, ACC-11-19); popup hasopener === null, no referrer and the exact stored URL (ACC-11-23); every request URL recorded withpage.on('request')during open, save and tile activation contains notoken,access_token,sessionToken,ew_live_or session-cookie value, with a control request that plants one and must be caught (ACC-11-24); flag off hides control, palette command, settings page and Work setting and the API answers 404 (ACC-11-28); §6.6 keyboard table and focus return (ACC-11-29); axe over control, panel and Manage apps with no new violation (ACC-11-30); the exposure toggle from the Work Overview by an editor, with the settings page agreeing (ACC-11-45); the empty state's two actions reaching their routes (ACC-11-48); the seeded states themselves (ACC-11-52). Done when: the five specs pass in both lanes,command-palette.spec.ts,flow-org-settings-persistence.spec.tsand the Work settings specs pass unchanged, and the flag-off job's log shows the variable absent from the API environment. -
T21. G-09 copy guard. Create
apps/web/src/lib/app-launcher/__tests__/no-sso-claims.unit.spec.ts(new) — loadsdashboard.appLauncher,dashboard.settings.appLauncheranddashboard.workDetail.settings.appLauncherfrom every locale file and fails on/single sign-on|\bsso\b|one login|already signed in/i(English) plusno-sso-terms-draft.md— the per-locale term list this epic ships beside the spec (APW11-G21), whose rows carry a review state so an unconfirmed locale is visible rather than silently passing. Remove the prohibition only when APW-12 ships (TRACKER note). Test: that spec (ACC-11-33); runpnpm --filter ever-works-web test -- no-sso-claims. Done when: adding"Single sign-on"to any launcher key makes the spec fail, and adding a listed translated phrase to a reviewed locale makes it fail too. -
T22. Docs. Create
docs/features/app-launcher.md(new) — what appears, why a Work appears, pins and hides, exposure, what the launcher does not do (no sign-on). Modifyapps/docs/sidebarsPlatform.tsto list it. Modifydocs/features/managed-hosting.md"Related" list with one link. Modifydocs/specs/features/app-works/TRACKER.md— APW-11 impl status. Modifydocs/specs/features/app-works/user-docs/app-works.md— the The App Launcher section (:135-138) currently says "Turn on Show in App Launcher in an App Work's settings to add its live URL", while spec FR-19 defaults an App Work to on and ACC-11-09 asserts it appears "with no setting changed". Reword it additively — App Works appear once live, and any other Work appears when Show in App Launcher is turned on — keeping the existing sentence about pinning, hiding and reordering (APW11-G25). Test: runpnpm --filter ever-works-docs build; T21's pattern applied todocs/features/app-launcher.mdfinds nothing; the reworded user-docs section matches FR-19's default. Done when:pnpm --filter ever-works-docs buildhas no broken links and the user docs no longer tells an App Work's owner to switch on something that is already on. -
T23. P1 ship gate. Root
pnpm format && pnpm lint && pnpm type-check && pnpm test && pnpm build; enableEVER_WORKS_APP_LAUNCHER_ENABLED=trueon stage (withEVER_WORKS_PLATFORM_CATALOG_{REPO,REF,ENV,SELF_ID}from T31); walk ACC-11-01…ACC-11-54. Modifydocs/specs/features/app-works/ACCEPTANCE.md— the APW-11 rows point at the files named in T1–T21 and at the 14 ids ACC-11-41…ACC-11-54 (sent to the program owner of that file) — anddocs/specs/features/app-works/TRACKER.md— APW-11 P1 row. Test: the PR lane runs T20's five specs in both jobs; ACC-E2E-12's nightlyapps/web/e2e/flow-app-works-live-launcher.spec.ts(APW-13) passes against stage. Done when: all 54 are ticked on stage, and flippingEVER_WORKS_APP_LAUNCHER_ENABLEDoff and on again on stage leaves every stored preference and exposure value unchanged (ACC-11-50).
Phase P2 — The launcher inside other Ever platforms (Wave 3)
Delivers spec FR-45…FR-52 and ACC-11-34…ACC-11-40. Blocked by APW-12 (token issuance, identity-provider
facade). The ManagedHostRootResolver binding is APW-06 T48 in P1 (R-16) and only replaces the default T6
ships, so it is not a P2 blocker (APW11-G01).
-
T24. Self-fetch mode and stale cache. Create
packages/app-launcher/src/data-source.tsandsrc/stale-cache.ts(new) — readscatalog-url(default<api>/api/app-launcher/platforms?environment=…) without credentials, callsgetAccessToken(), readsapps-urlwithAuthorization: Beareronly;localStoragelast-good list with a 7-day (604,800,000 ms) maximum age, every access in try/catch. Modifypackages/app-launcher/src/ever-app-launcher.ts— attributesenvironment,catalog-url,apps-url,sign-in-available;:sign-inevent; signed-out rendering. Test:packages/app-launcher/src/__tests__/stale-cache.spec.ts(new) — 6 days served, 8 days not (ACC-11-39);data-source.spec.ts(new) — never sendscredentials: 'include', never puts a token in a URL, and with no token renders Ever apps plus the sign-in prompt (ACC-11-36). Done when: both specs pass and the size check (T10) still passes. -
T25. Delegated read on the API. (requires APW-12's
DelegatedReaddecorator and guard branch — APW-12 plan §5.3; this task does not create or modify them) Modifyapps/api/src/app-launcher/app-launcher.controller.ts—@DelegatedRead('apps:read')on theGETlist handler only; resolve scope keysglobal+personalwhenauth.authMethod === 'ever-id-delegated'(plan §4.7). Per Resolution R-19 this task adds noauthMethodfield or value — the field ships with AW-24 and APW-12 appends'ever-id-delegated'— and AW-24'sHumanActorGuardrefuses the delegated principal on human-only routes without change. Test: extendapps/api/src/app-launcher/app-launcher.controller.spec.ts— a delegated principal lists the same items as that person's session in personal scope (ACC-11-37); a delegated token onPUTis refused401so arrangement cannot change (ACC-11-37); a token in the query string is refused400 tokenInQuery(APW-12's guard). Done when: the spec passes and no file inapps/api/src/app-launcher/declares anauthMethodtype. -
T26. Launcher CORS middleware. Create
apps/api/src/app-launcher/launcher-delegated-cors.middleware.ts(new) per plan §4.7; parseEVER_WORKS_APP_LAUNCHER_ORIGINS(≤ 50 exacthttps://origins; boot fails in production on an invalid entry, likeapps/api/src/cors-validation.ts). Modifyapps/api/src/app-launcher/app-launcher.module.ts—configure(consumer)for the two launcher routes only. Test:apps/api/src/app-launcher/launcher-delegated-cors.middleware.spec.ts(new) — ACAO only for allow-listed origins and none for others (ACC-11-38), neverAccess-Control-Allow-Credentials,204preflight, other routes untouched; the existing CORS e2e contract passes unchanged. Done when: the spec passes and a 51st origin fails boot withNODE_ENV=production. -
T27. Cross-framework fixtures, P2 i18n, e2e. Create
packages/app-launcher/fixtures/angular/index.html,fixtures/react/index.html,fixtures/solid/index.html(new, static pages loadingdist/index.js) andapps/web/e2e/app-launcher-cross-framework.spec.ts(new). Modifyapps/web/messages/en.jsonand the 20 sibling locale files — the three P2 keys from plan §8; extend T19's messages spec to cover them. Test:apps/web/e2e/app-launcher-cross-framework.spec.ts— the element renders in the three fixture pages with no console error and no style leak into the host (ACC-11-34); signed out, no request carries the Ever Works session cookie (ACC-11-36); runpnpm --filter ever-works-web test:e2e app-launcher-cross-framework. Done when: ACC-11-34…ACC-11-40 pass. -
T28. Extraction and publication. (owner decision — README §8 open question 7; registered as APW-11 question 2) Extract
packages/app-launcherwith history (git subtree split) into the owner-chosen public repository, set"private": false, publish under the owner-chosen npm scope, and switchapps/webto the published version. Document the element API from plan §6.2 in that repository's README. Still needs the owner (APW11-G21/EXT-30): the target repository, the npm scope, and a names-only publish credential provisioned before P2 starts (no secret value in this public repository, per README rule 10 and Constitution VII). P1 is unaffected — the package is"private": truein the monorepo, is declared byapps/web'spackage.jsonfrom T10, and mounts in the header from T14, so nothing here blocks Wave 1 and nothing shipped in P1 is withdrawn by the extraction. Modifyapps/web/package.json— the dependency points at the published version. Test: the extracted repository's CI runs the movedsrc/__tests__/*.spec.tsandscripts/check-size.mjs; in this monorepoapps/web/e2e/app-launcher-cross-framework.spec.tsand T20's specs pass against the published version. Done when: Ever Works and one other Ever platform load the same published version, and the names-only credential's name is recorded in the private operations repository. -
T29. P2 ship gate. Root
format / lint / type-check / test / buildgreen; walk ACC-11-34…ACC-11-40 on stage with Ever ID. T21's copy guard is relaxed only for strings APW-12 approves. Modifydocs/specs/features/app-works/TRACKER.md(APW-11 P2 row), this file'sStatus, and — only for strings APW-12 approves — the allowed-term list besideapps/web/src/lib/app-launcher/__tests__/no-sso-claims.unit.spec.ts. Test:apps/web/e2e/app-launcher-cross-framework.spec.tsand the T24–T26 specs green in the PR lane; ACC-E2E-13 (b) on stage reads the person's apps from Ever Teams through a delegated token. Done when: ACC-11-34…ACC-11-40 are ticked on stage.
Cross-phase closing tasks
-
T30 (P1, lands with T2–T3). Classify new tables for workspace backup (R-25). Modify
packages/agent/src/account-transfer/backup/collectors/domain-specs.ts— append to theaccountdomain:{ file: 'app-launcher-preferences.jsonl', entity: 'AppLauncherPreference', scope: { by: 'user' } }— the table hasuserIdand noorganizationId(plan §3.2), and its rows are the person's own layout.packages/agent/src/account-transfer/backup/redaction.tsis not modified: rows hold item keys, visibility and order only — no names, URLs or secret-shaped column. T2'sWork.appLauncherExposedridesworks/works.jsonl. Test: extendpackages/agent/src/account-transfer/backup/collectors/collectors.spec.ts—AppLauncherPreferenceis referenced exactly once, inaccount, scopedby: 'user', not dropped; its planned query isequals: { userId }with noorganizationIdpredicate;Workis still referenced once, byworks/works.jsonl. Done when:pnpm --filter @ever-works/agent test -- collectors redactionis green and a backup of a workspace whose owner pinned one item listsdata/account/app-launcher-preferences.jsonlwith that row. -
T31 (P1, lands with T9; blocks T23). Operator switches in the deploy manifests. Modify
.deploy/k8s/k8s-manifest.dev.yaml,.deploy/k8s/k8s-manifest.stage.yamland.deploy/k8s/k8s-manifest.prod.yaml— add, beside the existingEVER_WORKS_DOMAINblock (k8s-manifest.stage.yaml:293-294), the API variables this epic introduces, in the file's own-"name": X/value: "$X"style:EVER_WORKS_APP_LAUNCHER_ENABLED,EVER_WORKS_PLATFORM_CATALOG_REPO,EVER_WORKS_PLATFORM_CATALOG_REF,EVER_WORKS_PLATFORM_CATALOG_ENV(developin the dev file,stagein the stage file,productionin the production file — the value is per file, and the defaultproductionis what would otherwise show production addresses on stage and dev, contradicting spec FR-10) andEVER_WORKS_PLATFORM_CATALOG_SELF_ID. Modifyapps/api/.env.examplewith the same names and their defaults. Do not add anything by removal: no existing variable is renamed, moved or deleted, and the switch is wired through the one accessor T9 adds (config.appLauncher.isEnabled()), so flipping it in a manifest is the whole operation — no code change, no redeploy of a new image (XC-10's APW-11 slice, spec FR-65). Test:git grep -n "EVER_WORKS_APP_LAUNCHER_ENABLED\|EVER_WORKS_PLATFORM_CATALOG"shows all five variables in all three manifests and in.env.example; a render check (helm/kustomizeor the repository's manifest lint) keeps the YAML valid; extend T9's controller spec with the case that flipping the variable off leaves every stored row untouched and answers 404 on all three routes, then on again restores the same list (ACC-11-50). Done when: the three manifests and.env.examplecarry the variables with per-environment_ENV, and the on-stage off/on walk of T23 shows the same tiles, order, pins, hides and exposure values before and after. -
T32 (P1, lands with T14; independent).
app_launcherActivity badge and filter translations (XC-24). Modifyapps/web/src/components/activity-log/ActivityTypeBadge.tsx— addapp_launcher: 'appLauncher'toTYPE_TO_I18N(:31-50) and an entry to the colour map, so the row stops falling through to the rawactionType.replace(/_/g, ' ')label at:58. Modifyapps/web/messages/en.jsonand the 20 sibling locale files —dashboard.activity.filters.types.appLauncher(and the badge label if the badge reads a second key), in every locale, in this PR. Createapps/web/src/components/activity-log/ActivityTypeBadge.unit.spec.tsx(new) — theapp_launcherrow renders the translated label, notapp launcher, in a non-English locale. Test: that spec (ACC-11-53); runpnpm --filter ever-works-web test -- ActivityTypeBadge. Done when: the spec passes and removing theTYPE_TO_I18Nentry makes it fail. -
T33 (P1, lands with T9; blocks T20). Non-production seed route for the PR lane (APW11-G07). Create
apps/api/src/app-launcher/e2e-seed.controller.tsanddto/e2e-seed.dto.ts(new) —POST /api/e2e/app-launcher/seed, reaching the API only whenNODE_ENV !== 'production'andE2E_APP_LAUNCHER_SEED === 'true'(the gate is read through a getter added besideconfig.subscriptions.bypassSeatLimitsInE2E()atpackages/agent/src/config/index.ts:824-829, with the same production-first shape); otherwise404, before the handler. Session-authenticated: it creates rows for the signed-in person's own scope. The DTO takes a closed set of fixture shapes —{ kind: 'app' | 'website', name, managedSubdomain?, deployments: [{ state, environment, website? }], customDomain?: { domain, verified, environment } }— and creates theworks,work_deploymentsandwork_custom_domainsrows the specs need (kind: 'app'as the raw varchar until APW-01 lands; no cluster, no build, no network). Modifyapps/api/src/app-launcher/app-launcher.module.ts— register it only when the gate is open. Test:apps/api/src/app-launcher/e2e-seed.controller.spec.ts(new) —404withNODE_ENV=productioneven when the variable is set;404with the variable unset; a request without a session refused; and a seeded Work with aREADYproduction deployment plus a managed subdomain then appears inGET /api/me/appswith the expectedurl(ACC-11-52). Done when: the spec passes and T20's helper renders every state it needs without touching a database.
Definition of Done
- Every checkbox above is ticked.
pnpm format:check,pnpm lint,pnpm type-check,pnpm testandpnpm buildare green.- Every acceptance criterion in spec §8 has been walked on stage — ACC-11-01…ACC-11-54.
- The existing header, command-palette, Organization-settings and Work-settings e2e specs pass unchanged.
- CONTRACTS.md rows named in the plan header exist and match the merged code — including the audit round's
additions: the two ports of plan §4.6,
EVER_WORKS_PLATFORM_CATALOG_BASE_URL,E2E_APP_LAUNCHER_SEED, the catalog repository row forever-works/platforms, and the R-2 sentence that every newactionTypealso gets aFEED_KIND_RULESentry (T7). - No string anywhere in the launcher claims single sign-on before APW-12 is live.
- No suite for this epic lives under
apps/api/test/(Resolution R-22). - Nothing this epic added earlier has been withdrawn: every FR, scenario, ACC id, task and default from the original draft is still present, and the audit round's changes are additions (Resolution R-26).