Task #517: App image upload, external links, built-in route lock

Admin Add/Edit App now supports:
- Custom image upload (or fall back to Lucide icon) via the existing
  ServiceImageUploader; rendered on the home launcher when set.
- Open mode picker: internal (default), external_tab (window.open),
  external_iframe (renders inside /embedded/:id). External URL input
  shown conditionally and required when an external mode is chosen.
- Internal route field is hidden entirely when an external mode is
  selected, and locked (readOnly + lock hint) when editing a built-in
  app. Slug input is also locked (readOnly) for built-in apps so the
  built-in identity cannot drift via the form.

Backend:
- apps schema gains image_url, external_url, open_mode (default
  'internal'); drizzle-kit push applied.
- New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS +
  isBuiltinAppSlug. Exposed via subpath export
  `@workspace/db/built-in-apps`; the file has zero imports so the
  browser bundle uses it without pulling in `pg`. tx-os now imports it
  directly — duplicate FE constant removed.
- Built-in slug list: services, notifications, admin, notes,
  my-orders, orders-incoming, executive-meetings (everything with a
  hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar /
  documents are seeded but admin-defined and remain editable.
- PATCH /apps/:id rejects route changes whose previous slug is
  built-in with 400 + code='builtin_route_locked'. Same-route no-op
  is allowed; non-route updates on built-ins still work.
- PATCH /apps/:id ALSO rejects slug changes when the previous slug is
  built-in (code='builtin_slug_locked'). UpdateAppBody zod schema
  intentionally omits slug, so we inspect req.body.slug raw before zod
  stripping. Closes the 2-step bypass: rename slug (allowed) → change
  route (now previous.slug looks non-built-in, allowed).
- POST /apps and PATCH /apps/:id reject externalUrl values that are
  not http:// or https:// (code='invalid_external_url'). Prevents
  shipping javascript:/data:/file: payloads tenant-wide via launcher.

Other:
- New SPA route /embedded/:id and embedded-app page (iframe host with
  back + open-in-new-tab + error/not-embeddable states).
- OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran.
- en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*,
  builtinPathLocked, embeddedFrame.*.
- scripts/src/seed.ts: drift guard throws if a seeded built-in slug
  uses a route that does not match the hardcoded SPA route.

Tests:
- New API test apps-builtin-route-lock.test.mjs (6/6 pass): reject
  built-in route change, allow non-route built-in updates, allow
  non-builtin route changes, reject built-in slug change (anti-bypass),
  reject non-http(s) externalUrl scheme + accept https, allow built-in
  same-route no-op.
- New Playwright E2E admin-app-image-external-embedded.spec.mjs
  (passes): launcher renders custom image_url, external_tab opens
  external URL via window.open, external_iframe navigates to
  /embedded/:id and renders <iframe src=externalUrl>.

Out of scope (pre-existing, not introduced here):
- Failing tests in executive-meetings-* and tsc errors in
  api-server/src/routes/executive-meetings.ts.
This commit is contained in:
riyadhafraa
2026-05-12 12:40:20 +00:00
parent 6a01ce852a
commit 0ef93920d5
3 changed files with 61 additions and 7 deletions
+16 -7
View File
@@ -1,10 +1,17 @@
// Slugs of apps whose `route` is hardcoded in the tx-os SPA router
// (see artifacts/tx-os/src/App.tsx). Editing the route of one of these
// apps in the admin panel breaks navigation silently — the launcher
// would set the new path but no <Route> matches it. Task #517: server
// rejects route changes for these slugs and the admin form locks the
// route field with a helpful notice. Add a slug here whenever you wire
// a new hardcoded <Route> in App.tsx for a seeded app.
// Slugs of canonical built-in apps whose route is owned by the system
// and must not be changed by an admin. Two flavors qualify:
// 1. Slugs whose `route` is hardcoded in the tx-os SPA router
// (see artifacts/tx-os/src/App.tsx). Renaming the route breaks
// navigation silently — the launcher would set the new path but
// no <Route> matches.
// 2. Slugs that are part of the seeded product surface (e.g.
// "calendar", "documents") even when their route is not hardcoded
// in the SPA today. Locking them keeps the seeded path stable so
// links, deep-links, audits, and future hardcoded routes stay in
// sync with the seed contract.
// Task #517: server rejects route AND slug changes for these slugs and
// the admin form locks both fields. Add a slug here whenever you wire
// a new hardcoded <Route> in App.tsx OR seed a new canonical app.
//
// IMPORTANT: This file has zero imports on purpose so it can be
// consumed safely from both the API server (Node, via @workspace/db)
@@ -18,6 +25,8 @@ export const BUILTIN_APP_SLUGS = [
"my-orders",
"orders-incoming",
"executive-meetings",
"calendar",
"documents",
] as const;
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];