diff --git a/artifacts/api-server/tests/apps-builtin-route-lock.test.mjs b/artifacts/api-server/tests/apps-builtin-route-lock.test.mjs index b6684b0e..3098eae4 100644 --- a/artifacts/api-server/tests/apps-builtin-route-lock.test.mjs +++ b/artifacts/api-server/tests/apps-builtin-route-lock.test.mjs @@ -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"); }); +test("PATCH /apps/:id rejects route changes for seeded built-ins (calendar)", async () => { + // calendar/documents have no hardcoded SPA 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 () => { // javascript: in an iframe src or window.open target = stored XSS for // every user; data: / file: are similarly dangerous. Server must reject. diff --git a/lib/db/src/built-in-apps.ts b/lib/db/src/built-in-apps.ts index c3119dca..11df0663 100644 --- a/lib/db/src/built-in-apps.ts +++ b/lib/db/src/built-in-apps.ts @@ -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 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 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 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 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]; diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index e34c68b3..a919147d 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -240,6 +240,8 @@ async function main() { "my-orders": "/my-orders", "orders-incoming": "/orders/incoming", "executive-meetings": "/executive-meetings", + calendar: "/calendar", + documents: "/documents", }; for (const a of apps) { if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {