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:
@@ -205,6 +205,49 @@ test("PATCH /apps/:id rejects slug changes for built-in apps with code=builtin_s
|
|||||||
assert.equal(after.rows[0].route, originalRoute, "route must not change");
|
assert.equal(after.rows[0].route, originalRoute, "route must not change");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("PATCH /apps/:id rejects route changes for seeded built-ins (calendar)", async () => {
|
||||||
|
// calendar/documents have no hardcoded SPA <Route> but are seeded
|
||||||
|
// canonical apps; admins must not be able to repurpose their slug or
|
||||||
|
// path or links/audits/deep-links break.
|
||||||
|
const slug = "calendar";
|
||||||
|
const original = await pool.query(`SELECT id, route FROM apps WHERE slug = $1`, [slug]);
|
||||||
|
if (original.rowCount === 0) return;
|
||||||
|
const appId = original.rows[0].id;
|
||||||
|
const originalRoute = original.rows[0].route;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||||
|
body: JSON.stringify({ route: `/calendar-renamed-${Date.now()}` }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.code, "builtin_route_locked");
|
||||||
|
const after = await pool.query(`SELECT route FROM apps WHERE id = $1`, [appId]);
|
||||||
|
assert.equal(after.rows[0].route, originalRoute);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /apps rejects externalUrl with non-http(s) scheme (XSS guard, create path)", async () => {
|
||||||
|
const res = await fetch(`${API_BASE}/api/apps`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: `xss-create-${Date.now()}`,
|
||||||
|
nameAr: "ت",
|
||||||
|
nameEn: "T",
|
||||||
|
iconName: "Grid2X2",
|
||||||
|
route: `/xss-${Date.now()}`,
|
||||||
|
color: "#000000",
|
||||||
|
sortOrder: 0,
|
||||||
|
externalUrl: "javascript:alert(1)",
|
||||||
|
openMode: "external_tab",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.code, "invalid_external_url");
|
||||||
|
});
|
||||||
|
|
||||||
test("PATCH /apps/:id rejects externalUrl with non-http(s) scheme (XSS guard)", async () => {
|
test("PATCH /apps/:id rejects externalUrl with non-http(s) scheme (XSS guard)", async () => {
|
||||||
// javascript: in an iframe src or window.open target = stored XSS for
|
// javascript: in an iframe src or window.open target = stored XSS for
|
||||||
// every user; data: / file: are similarly dangerous. Server must reject.
|
// every user; data: / file: are similarly dangerous. Server must reject.
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
// Slugs of apps whose `route` is hardcoded in the tx-os SPA router
|
// Slugs of canonical built-in apps whose route is owned by the system
|
||||||
// (see artifacts/tx-os/src/App.tsx). Editing the route of one of these
|
// and must not be changed by an admin. Two flavors qualify:
|
||||||
// apps in the admin panel breaks navigation silently — the launcher
|
// 1. Slugs whose `route` is hardcoded in the tx-os SPA router
|
||||||
// would set the new path but no <Route> matches it. Task #517: server
|
// (see artifacts/tx-os/src/App.tsx). Renaming the route breaks
|
||||||
// rejects route changes for these slugs and the admin form locks the
|
// navigation silently — the launcher would set the new path but
|
||||||
// route field with a helpful notice. Add a slug here whenever you wire
|
// no <Route> matches.
|
||||||
// a new hardcoded <Route> in App.tsx for a seeded app.
|
// 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
|
// IMPORTANT: This file has zero imports on purpose so it can be
|
||||||
// consumed safely from both the API server (Node, via @workspace/db)
|
// consumed safely from both the API server (Node, via @workspace/db)
|
||||||
@@ -18,6 +25,8 @@ export const BUILTIN_APP_SLUGS = [
|
|||||||
"my-orders",
|
"my-orders",
|
||||||
"orders-incoming",
|
"orders-incoming",
|
||||||
"executive-meetings",
|
"executive-meetings",
|
||||||
|
"calendar",
|
||||||
|
"documents",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
|
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ async function main() {
|
|||||||
"my-orders": "/my-orders",
|
"my-orders": "/my-orders",
|
||||||
"orders-incoming": "/orders/incoming",
|
"orders-incoming": "/orders/incoming",
|
||||||
"executive-meetings": "/executive-meetings",
|
"executive-meetings": "/executive-meetings",
|
||||||
|
calendar: "/calendar",
|
||||||
|
documents: "/documents",
|
||||||
};
|
};
|
||||||
for (const a of apps) {
|
for (const a of apps) {
|
||||||
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {
|
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user