@augmenting-integrations/platform 8.7.0 → 8.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/apps-roster/schema.d.ts.map +1 -1
- package/dist/apps-roster.cjs.map +1 -1
- package/dist/apps-roster.js +1 -1
- package/dist/{chunk-ZJFI7R4O.js → chunk-CDZIM3DQ.js} +1 -1
- package/dist/chunk-CDZIM3DQ.js.map +1 -0
- package/dist/manifest/schema.d.ts +1 -1
- package/dist/manifest/schema.d.ts.map +1 -1
- package/dist/manifest.cjs.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/server/apps-route.d.ts +4 -1
- package/dist/server/apps-route.d.ts.map +1 -1
- package/dist/server/tenant.d.ts.map +1 -1
- package/dist/server.cjs +13 -7
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +14 -8
- package/dist/server.js.map +1 -1
- package/dist/tenant-types.cjs.map +1 -1
- package/dist/tenant-types.d.ts +0 -2
- package/dist/tenant-types.d.ts.map +1 -1
- package/dist/tenant-types.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-ZJFI7R4O.js.map +0 -1
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@ truth for:
|
|
|
12
12
|
|
|
13
13
|
Every other `@augmenting-integrations/*` package consumes tenant context from
|
|
14
14
|
here, so `@augmenting-integrations/auth` no longer owns it. UI components,
|
|
15
|
-
billing handlers,
|
|
16
|
-
struct.
|
|
15
|
+
billing handlers, the apps-roster route handlers, and deploy tooling all
|
|
16
|
+
import the same struct.
|
|
17
17
|
|
|
18
18
|
## Subpaths
|
|
19
19
|
|
|
@@ -26,7 +26,8 @@ struct.
|
|
|
26
26
|
Every spoke and apex app ships an `app.manifest.json` at the repo root. The
|
|
27
27
|
manifest is the local source of truth for slug/subdomain/displayName/navOrder,
|
|
28
28
|
app-level access policy, feature enablement, and the data plane choice. The
|
|
29
|
-
deploy workflow reads this file to
|
|
30
|
-
|
|
29
|
+
deploy workflow reads this file to provision the right infra modules; cross-
|
|
30
|
+
tenant membership (which apps belong to which tenant) is declared in the
|
|
31
|
+
tenant roster at `<tenant>-infra/config/apps.yaml`, not duplicated here.
|
|
31
32
|
|
|
32
33
|
See `src/manifest/schema.ts` for the canonical type.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/apps-roster/schema.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/apps-roster/schema.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,CAAC;AAE7C,MAAM,MAAM,SAAS,GAAG;IACtB,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,IAAI,EAAE,aAAa,CAAC;IACpB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,qCAAqC;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,sBAAsB,EAAE,MAAM,EAAE,CAAC;IACjC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,SAAS,EAAE,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAIF;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,OAAO,GACX;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,qBAAqB,EAAE,CAAA;CAAE,CAoHlF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,SAAS,EAAE,EACjB,UAAU,EAAE,MAAM,EAAE,GACnB,SAAS,EAAE,CAOb;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,IAAI,CAAC,SAAS,EAAE,UAAU,GAAG,MAAM,CAAC,EAC/E,IAAI,EAAE,CAAC,EAAE,GACR,CAAC,EAAE,CAIL"}
|
package/dist/apps-roster.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/apps-roster.ts","../src/apps-roster/schema.ts","../src/apps-roster/load.ts"],"sourcesContent":["export {\n validateAppsRoster,\n filterAppsByIdentityGroups,\n sortAppsByNavOrder,\n type TenantApp,\n type TenantAppRole,\n type AppsRoster,\n type RosterValidationError,\n} from \"./apps-roster/schema.js\";\nexport { loadAppsRosterJson, type LoadAppsRosterOptions } from \"./apps-roster/load.js\";\n","// =============================================================================\n// Tenant app roster.\n//\n// One file per tenant that lists every app (apex + spokes) the tenant\n// ecosystem contains. Replaces the runtime DynamoDB registry. Stored as\n// YAML in <tenant>-infra/config/apps.yaml (canonical) and mirrored to\n// <tenant>-apex/config/apps.json for runtime serving by /api/apps.\n//\n// Adding a new spoke = a PR to the spoke repo (its app.manifest.json) +\n// a PR to <tenant>-infra/config/apps.yaml + a PR to <tenant>-apex/config/\n// apps.json. Validation catches drift.\n// =============================================================================\n\nexport type TenantAppRole = \"apex\" | \"spoke\";\n\nexport type TenantApp = {\n /** Stable identifier. Matches the spoke's app.manifest.json#appSlug. */\n slug: string;\n /** \"apex\" (auth broker) or \"spoke\" (product app). */\n role: TenantAppRole;\n /** DNS label. Empty string for apex. */\n subdomain: string;\n /** Human-friendly name. Drives the shared nav. */\n displayName: string;\n /** Sort order. Lower comes first. */\n navOrder: number;\n /**\n * Cognito identity groups required to see this app in cross-app nav\n * AND to enter its routes (when the spoke's createAuth is wired to its\n * own manifest's access policy). Empty = all authenticated users.\n */\n requiredIdentityGroups: string[];\n /**\n * Static feature toggle. Default true. Set false to hide an app from\n * cross-app nav without removing the entry. Editing this requires a\n * PR + redeploy -- this is NOT mutable runtime state.\n */\n enabled?: boolean;\n};\n\nexport type AppsRoster = {\n apps: TenantApp[];\n};\n\nexport type RosterValidationError = {\n path: string;\n message: string;\n};\n\nconst ROLES: readonly string[] = [\"apex\", \"spoke\"];\n\n/**\n * Pure validator for the roster object (parsed from YAML or JSON). Returns\n * the typed roster on success, or an array of errors on failure. No throws.\n */\nexport function validateAppsRoster(\n raw: unknown,\n): { ok: true; value: AppsRoster } | { ok: false; errors: RosterValidationError[] } {\n const errors: RosterValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"roster must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n if (!Array.isArray(m.apps)) {\n return { ok: false, errors: [{ path: \"apps\", message: \"expected array\" }] };\n }\n const apps = m.apps as unknown[];\n\n const seenSlugs = new Map<string, number>();\n const seenSubdomains = new Map<string, number>();\n const seenNavOrder = new Map<number, number>();\n let apexCount = 0;\n\n apps.forEach((entryUnknown, i) => {\n const path = `apps[${i}]`;\n if (typeof entryUnknown !== \"object\" || entryUnknown === null) {\n errors.push({ path, message: \"expected object\" });\n return;\n }\n const entry = entryUnknown as Record<string, unknown>;\n\n if (typeof entry.slug !== \"string\" || entry.slug === \"\") {\n errors.push({ path: `${path}.slug`, message: \"expected non-empty string\" });\n } else {\n const prior = seenSlugs.get(entry.slug);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.slug`,\n message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`,\n });\n } else {\n seenSlugs.set(entry.slug, i);\n }\n }\n\n if (typeof entry.role !== \"string\" || !ROLES.includes(entry.role)) {\n errors.push({\n path: `${path}.role`,\n message: `expected one of: ${ROLES.join(\", \")}`,\n });\n } else if (entry.role === \"apex\") {\n apexCount++;\n }\n\n if (typeof entry.subdomain !== \"string\") {\n errors.push({ path: `${path}.subdomain`, message: \"expected string\" });\n } else {\n if (entry.role === \"apex\" && entry.subdomain !== \"\") {\n errors.push({\n path: `${path}.subdomain`,\n message: \"apex apps must have empty subdomain\",\n });\n }\n if (entry.subdomain !== \"\") {\n const prior = seenSubdomains.get(entry.subdomain);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.subdomain`,\n message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`,\n });\n } else {\n seenSubdomains.set(entry.subdomain, i);\n }\n }\n }\n\n if (typeof entry.displayName !== \"string\" || entry.displayName === \"\") {\n errors.push({\n path: `${path}.displayName`,\n message: \"expected non-empty string\",\n });\n }\n\n if (typeof entry.navOrder !== \"number\" || !Number.isFinite(entry.navOrder)) {\n errors.push({ path: `${path}.navOrder`, message: \"expected number\" });\n } else {\n const prior = seenNavOrder.get(entry.navOrder);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.navOrder`,\n message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`,\n });\n } else {\n seenNavOrder.set(entry.navOrder, i);\n }\n }\n\n if (\n !Array.isArray(entry.requiredIdentityGroups) ||\n entry.requiredIdentityGroups.some((g) => typeof g !== \"string\")\n ) {\n errors.push({\n path: `${path}.requiredIdentityGroups`,\n message: \"expected string[]\",\n });\n }\n\n if (entry.enabled !== undefined && typeof entry.enabled !== \"boolean\") {\n errors.push({ path: `${path}.enabled`, message: \"expected boolean\" });\n }\n });\n\n if (apexCount === 0) {\n errors.push({ path: \"apps\", message: \"roster must contain exactly one apex entry\" });\n } else if (apexCount > 1) {\n errors.push({\n path: \"apps\",\n message: `roster must contain exactly one apex entry, found ${apexCount}`,\n });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppsRoster };\n}\n\n/**\n * Filter the roster by user identity groups. Apps with empty\n * `requiredIdentityGroups` are visible to all authenticated users; otherwise\n * the user must be in at least one of the listed groups. `enabled: false`\n * apps are always filtered out.\n */\nexport function filterAppsByIdentityGroups(\n apps: TenantApp[],\n userGroups: string[],\n): TenantApp[] {\n const lower = userGroups.map((g) => g.toLowerCase());\n return apps.filter((a) => {\n if (a.enabled === false) return false;\n if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;\n return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));\n });\n}\n\n/** Sort apps by navOrder ASC, then slug. Mutates a copy, returns it. */\nexport function sortAppsByNavOrder<T extends Pick<TenantApp, \"navOrder\" | \"slug\">>(\n apps: T[],\n): T[] {\n return [...apps].sort(\n (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug),\n );\n}\n","import { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\n\nimport { validateAppsRoster, type AppsRoster } from \"./schema.js\";\n\nexport type LoadAppsRosterOptions = {\n /** Absolute path to a JSON apps-roster file. */\n path: string;\n};\n\n/**\n * Read + validate a JSON apps-roster file. Throws with a consolidated error\n * message listing every validation failure.\n *\n * YAML parsing is intentionally not bundled in this package; use\n * @augmenting-integrations/deploy-tools `augint validate-app-roster` for the\n * cross-format check between the infra YAML source and the apex JSON mirror.\n */\nexport function loadAppsRosterJson(opts: LoadAppsRosterOptions): AppsRoster {\n const file = resolve(opts.path);\n let raw: string;\n try {\n raw = readFileSync(file, \"utf8\");\n } catch (err) {\n throw new Error(\n `loadAppsRosterJson: cannot read ${file}: ${(err as Error).message}`,\n { cause: err },\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new Error(\n `loadAppsRosterJson: invalid JSON in ${file}: ${(err as Error).message}`,\n { cause: err },\n );\n }\n const result = validateAppsRoster(parsed);\n if (!result.ok) {\n const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join(\"\\n\");\n throw new Error(`loadAppsRosterJson: ${file} failed validation:\\n${lines}`);\n }\n return result.value;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/apps-roster.ts","../src/apps-roster/schema.ts","../src/apps-roster/load.ts"],"sourcesContent":["export {\n validateAppsRoster,\n filterAppsByIdentityGroups,\n sortAppsByNavOrder,\n type TenantApp,\n type TenantAppRole,\n type AppsRoster,\n type RosterValidationError,\n} from \"./apps-roster/schema.js\";\nexport { loadAppsRosterJson, type LoadAppsRosterOptions } from \"./apps-roster/load.js\";\n","// =============================================================================\n// Tenant app roster.\n//\n// One file per tenant that lists every app (apex + spokes) the tenant\n// ecosystem contains. Stored as YAML in <tenant>-infra/config/apps.yaml\n// (canonical) and mirrored to <tenant>-apex/config/apps.json for runtime\n// serving by the apex's /api/apps. Spokes proxy their /api/apps to the\n// apex; they never carry a roster file of their own.\n//\n// Adding a new spoke = a PR to the spoke repo (its app.manifest.json) +\n// a PR to <tenant>-infra/config/apps.yaml + a PR to <tenant>-apex/config/\n// apps.json. `augint validate-app-roster` enforces the two files agree.\n// =============================================================================\n\nexport type TenantAppRole = \"apex\" | \"spoke\";\n\nexport type TenantApp = {\n /** Stable identifier. Matches the spoke's app.manifest.json#appSlug. */\n slug: string;\n /** \"apex\" (auth broker) or \"spoke\" (product app). */\n role: TenantAppRole;\n /** DNS label. Empty string for apex. */\n subdomain: string;\n /** Human-friendly name. Drives the shared nav. */\n displayName: string;\n /** Sort order. Lower comes first. */\n navOrder: number;\n /**\n * Cognito identity groups required to see this app in cross-app nav\n * AND to enter its routes (when the spoke's createAuth is wired to its\n * own manifest's access policy). Empty = all authenticated users.\n */\n requiredIdentityGroups: string[];\n /**\n * Static feature toggle. Default true. Set false to hide an app from\n * cross-app nav without removing the entry. Editing this requires a\n * PR + redeploy -- this is NOT mutable runtime state.\n */\n enabled?: boolean;\n};\n\nexport type AppsRoster = {\n apps: TenantApp[];\n};\n\nexport type RosterValidationError = {\n path: string;\n message: string;\n};\n\nconst ROLES: readonly string[] = [\"apex\", \"spoke\"];\n\n/**\n * Pure validator for the roster object (parsed from YAML or JSON). Returns\n * the typed roster on success, or an array of errors on failure. No throws.\n */\nexport function validateAppsRoster(\n raw: unknown,\n): { ok: true; value: AppsRoster } | { ok: false; errors: RosterValidationError[] } {\n const errors: RosterValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"roster must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n if (!Array.isArray(m.apps)) {\n return { ok: false, errors: [{ path: \"apps\", message: \"expected array\" }] };\n }\n const apps = m.apps as unknown[];\n\n const seenSlugs = new Map<string, number>();\n const seenSubdomains = new Map<string, number>();\n const seenNavOrder = new Map<number, number>();\n let apexCount = 0;\n\n apps.forEach((entryUnknown, i) => {\n const path = `apps[${i}]`;\n if (typeof entryUnknown !== \"object\" || entryUnknown === null) {\n errors.push({ path, message: \"expected object\" });\n return;\n }\n const entry = entryUnknown as Record<string, unknown>;\n\n if (typeof entry.slug !== \"string\" || entry.slug === \"\") {\n errors.push({ path: `${path}.slug`, message: \"expected non-empty string\" });\n } else {\n const prior = seenSlugs.get(entry.slug);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.slug`,\n message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`,\n });\n } else {\n seenSlugs.set(entry.slug, i);\n }\n }\n\n if (typeof entry.role !== \"string\" || !ROLES.includes(entry.role)) {\n errors.push({\n path: `${path}.role`,\n message: `expected one of: ${ROLES.join(\", \")}`,\n });\n } else if (entry.role === \"apex\") {\n apexCount++;\n }\n\n if (typeof entry.subdomain !== \"string\") {\n errors.push({ path: `${path}.subdomain`, message: \"expected string\" });\n } else {\n if (entry.role === \"apex\" && entry.subdomain !== \"\") {\n errors.push({\n path: `${path}.subdomain`,\n message: \"apex apps must have empty subdomain\",\n });\n }\n if (entry.subdomain !== \"\") {\n const prior = seenSubdomains.get(entry.subdomain);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.subdomain`,\n message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`,\n });\n } else {\n seenSubdomains.set(entry.subdomain, i);\n }\n }\n }\n\n if (typeof entry.displayName !== \"string\" || entry.displayName === \"\") {\n errors.push({\n path: `${path}.displayName`,\n message: \"expected non-empty string\",\n });\n }\n\n if (typeof entry.navOrder !== \"number\" || !Number.isFinite(entry.navOrder)) {\n errors.push({ path: `${path}.navOrder`, message: \"expected number\" });\n } else {\n const prior = seenNavOrder.get(entry.navOrder);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.navOrder`,\n message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`,\n });\n } else {\n seenNavOrder.set(entry.navOrder, i);\n }\n }\n\n if (\n !Array.isArray(entry.requiredIdentityGroups) ||\n entry.requiredIdentityGroups.some((g) => typeof g !== \"string\")\n ) {\n errors.push({\n path: `${path}.requiredIdentityGroups`,\n message: \"expected string[]\",\n });\n }\n\n if (entry.enabled !== undefined && typeof entry.enabled !== \"boolean\") {\n errors.push({ path: `${path}.enabled`, message: \"expected boolean\" });\n }\n });\n\n if (apexCount === 0) {\n errors.push({ path: \"apps\", message: \"roster must contain exactly one apex entry\" });\n } else if (apexCount > 1) {\n errors.push({\n path: \"apps\",\n message: `roster must contain exactly one apex entry, found ${apexCount}`,\n });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppsRoster };\n}\n\n/**\n * Filter the roster by user identity groups. Apps with empty\n * `requiredIdentityGroups` are visible to all authenticated users; otherwise\n * the user must be in at least one of the listed groups. `enabled: false`\n * apps are always filtered out.\n */\nexport function filterAppsByIdentityGroups(\n apps: TenantApp[],\n userGroups: string[],\n): TenantApp[] {\n const lower = userGroups.map((g) => g.toLowerCase());\n return apps.filter((a) => {\n if (a.enabled === false) return false;\n if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;\n return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));\n });\n}\n\n/** Sort apps by navOrder ASC, then slug. Mutates a copy, returns it. */\nexport function sortAppsByNavOrder<T extends Pick<TenantApp, \"navOrder\" | \"slug\">>(\n apps: T[],\n): T[] {\n return [...apps].sort(\n (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug),\n );\n}\n","import { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\n\nimport { validateAppsRoster, type AppsRoster } from \"./schema.js\";\n\nexport type LoadAppsRosterOptions = {\n /** Absolute path to a JSON apps-roster file. */\n path: string;\n};\n\n/**\n * Read + validate a JSON apps-roster file. Throws with a consolidated error\n * message listing every validation failure.\n *\n * YAML parsing is intentionally not bundled in this package; use\n * @augmenting-integrations/deploy-tools `augint validate-app-roster` for the\n * cross-format check between the infra YAML source and the apex JSON mirror.\n */\nexport function loadAppsRosterJson(opts: LoadAppsRosterOptions): AppsRoster {\n const file = resolve(opts.path);\n let raw: string;\n try {\n raw = readFileSync(file, \"utf8\");\n } catch (err) {\n throw new Error(\n `loadAppsRosterJson: cannot read ${file}: ${(err as Error).message}`,\n { cause: err },\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new Error(\n `loadAppsRosterJson: invalid JSON in ${file}: ${(err as Error).message}`,\n { cause: err },\n );\n }\n const result = validateAppsRoster(parsed);\n if (!result.ok) {\n const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join(\"\\n\");\n throw new Error(`loadAppsRosterJson: ${file} failed validation:\\n${lines}`);\n }\n return result.value;\n}\n"],"mappings":"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","names":[]}
|
package/dist/apps-roster.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/apps-roster/schema.ts"],"sourcesContent":["// =============================================================================\n// Tenant app roster.\n//\n// One file per tenant that lists every app (apex + spokes) the tenant\n// ecosystem contains. Stored as YAML in <tenant>-infra/config/apps.yaml\n// (canonical) and mirrored to <tenant>-apex/config/apps.json for runtime\n// serving by the apex's /api/apps. Spokes proxy their /api/apps to the\n// apex; they never carry a roster file of their own.\n//\n// Adding a new spoke = a PR to the spoke repo (its app.manifest.json) +\n// a PR to <tenant>-infra/config/apps.yaml + a PR to <tenant>-apex/config/\n// apps.json. `augint validate-app-roster` enforces the two files agree.\n// =============================================================================\n\nexport type TenantAppRole = \"apex\" | \"spoke\";\n\nexport type TenantApp = {\n /** Stable identifier. Matches the spoke's app.manifest.json#appSlug. */\n slug: string;\n /** \"apex\" (auth broker) or \"spoke\" (product app). */\n role: TenantAppRole;\n /** DNS label. Empty string for apex. */\n subdomain: string;\n /** Human-friendly name. Drives the shared nav. */\n displayName: string;\n /** Sort order. Lower comes first. */\n navOrder: number;\n /**\n * Cognito identity groups required to see this app in cross-app nav\n * AND to enter its routes (when the spoke's createAuth is wired to its\n * own manifest's access policy). Empty = all authenticated users.\n */\n requiredIdentityGroups: string[];\n /**\n * Static feature toggle. Default true. Set false to hide an app from\n * cross-app nav without removing the entry. Editing this requires a\n * PR + redeploy -- this is NOT mutable runtime state.\n */\n enabled?: boolean;\n};\n\nexport type AppsRoster = {\n apps: TenantApp[];\n};\n\nexport type RosterValidationError = {\n path: string;\n message: string;\n};\n\nconst ROLES: readonly string[] = [\"apex\", \"spoke\"];\n\n/**\n * Pure validator for the roster object (parsed from YAML or JSON). Returns\n * the typed roster on success, or an array of errors on failure. No throws.\n */\nexport function validateAppsRoster(\n raw: unknown,\n): { ok: true; value: AppsRoster } | { ok: false; errors: RosterValidationError[] } {\n const errors: RosterValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"roster must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n if (!Array.isArray(m.apps)) {\n return { ok: false, errors: [{ path: \"apps\", message: \"expected array\" }] };\n }\n const apps = m.apps as unknown[];\n\n const seenSlugs = new Map<string, number>();\n const seenSubdomains = new Map<string, number>();\n const seenNavOrder = new Map<number, number>();\n let apexCount = 0;\n\n apps.forEach((entryUnknown, i) => {\n const path = `apps[${i}]`;\n if (typeof entryUnknown !== \"object\" || entryUnknown === null) {\n errors.push({ path, message: \"expected object\" });\n return;\n }\n const entry = entryUnknown as Record<string, unknown>;\n\n if (typeof entry.slug !== \"string\" || entry.slug === \"\") {\n errors.push({ path: `${path}.slug`, message: \"expected non-empty string\" });\n } else {\n const prior = seenSlugs.get(entry.slug);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.slug`,\n message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`,\n });\n } else {\n seenSlugs.set(entry.slug, i);\n }\n }\n\n if (typeof entry.role !== \"string\" || !ROLES.includes(entry.role)) {\n errors.push({\n path: `${path}.role`,\n message: `expected one of: ${ROLES.join(\", \")}`,\n });\n } else if (entry.role === \"apex\") {\n apexCount++;\n }\n\n if (typeof entry.subdomain !== \"string\") {\n errors.push({ path: `${path}.subdomain`, message: \"expected string\" });\n } else {\n if (entry.role === \"apex\" && entry.subdomain !== \"\") {\n errors.push({\n path: `${path}.subdomain`,\n message: \"apex apps must have empty subdomain\",\n });\n }\n if (entry.subdomain !== \"\") {\n const prior = seenSubdomains.get(entry.subdomain);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.subdomain`,\n message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`,\n });\n } else {\n seenSubdomains.set(entry.subdomain, i);\n }\n }\n }\n\n if (typeof entry.displayName !== \"string\" || entry.displayName === \"\") {\n errors.push({\n path: `${path}.displayName`,\n message: \"expected non-empty string\",\n });\n }\n\n if (typeof entry.navOrder !== \"number\" || !Number.isFinite(entry.navOrder)) {\n errors.push({ path: `${path}.navOrder`, message: \"expected number\" });\n } else {\n const prior = seenNavOrder.get(entry.navOrder);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.navOrder`,\n message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`,\n });\n } else {\n seenNavOrder.set(entry.navOrder, i);\n }\n }\n\n if (\n !Array.isArray(entry.requiredIdentityGroups) ||\n entry.requiredIdentityGroups.some((g) => typeof g !== \"string\")\n ) {\n errors.push({\n path: `${path}.requiredIdentityGroups`,\n message: \"expected string[]\",\n });\n }\n\n if (entry.enabled !== undefined && typeof entry.enabled !== \"boolean\") {\n errors.push({ path: `${path}.enabled`, message: \"expected boolean\" });\n }\n });\n\n if (apexCount === 0) {\n errors.push({ path: \"apps\", message: \"roster must contain exactly one apex entry\" });\n } else if (apexCount > 1) {\n errors.push({\n path: \"apps\",\n message: `roster must contain exactly one apex entry, found ${apexCount}`,\n });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppsRoster };\n}\n\n/**\n * Filter the roster by user identity groups. Apps with empty\n * `requiredIdentityGroups` are visible to all authenticated users; otherwise\n * the user must be in at least one of the listed groups. `enabled: false`\n * apps are always filtered out.\n */\nexport function filterAppsByIdentityGroups(\n apps: TenantApp[],\n userGroups: string[],\n): TenantApp[] {\n const lower = userGroups.map((g) => g.toLowerCase());\n return apps.filter((a) => {\n if (a.enabled === false) return false;\n if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;\n return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));\n });\n}\n\n/** Sort apps by navOrder ASC, then slug. Mutates a copy, returns it. */\nexport function sortAppsByNavOrder<T extends Pick<TenantApp, \"navOrder\" | \"slug\">>(\n apps: T[],\n): T[] {\n return [...apps].sort(\n (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug),\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -5,7 +5,7 @@ export type AppManifest = {
|
|
|
5
5
|
schemaVersion: 1;
|
|
6
6
|
/** Tenant slug, e.g. "agency". Matches the tenant repo. */
|
|
7
7
|
tenantSlug: string;
|
|
8
|
-
/** App slug.
|
|
8
|
+
/** App slug. Stable identifier; must match the roster entry's slug. */
|
|
9
9
|
appSlug: string;
|
|
10
10
|
/** "apex" (the auth broker) or "spoke" (a product app). */
|
|
11
11
|
role: AppRole;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/manifest/schema.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;AAEvC,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,iBAAiB,GAAG,UAAU,GAAG,MAAM,CAAC;AAEnF,MAAM,MAAM,WAAW,GAAG;IACxB,oDAAoD;IACpD,aAAa,EAAE,CAAC,CAAC;IACjB,2DAA2D;IAC3D,UAAU,EAAE,MAAM,CAAC;IACnB,
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/manifest/schema.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;AAEvC,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,iBAAiB,GAAG,UAAU,GAAG,MAAM,CAAC;AAEnF,MAAM,MAAM,WAAW,GAAG;IACxB,oDAAoD;IACpD,aAAa,EAAE,CAAC,CAAC;IACjB,2DAA2D;IAC3D,UAAU,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,IAAI,EAAE,OAAO,CAAC;IACd,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,WAAW,EAAE,MAAM,CAAC;IACpB,sDAAsD;IACtD,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE;QACN;;;;WAIG;QACH,sBAAsB,EAAE,MAAM,EAAE,CAAC;KAClC,CAAC;IACF,QAAQ,EAAE;QACR,0DAA0D;QAC1D,OAAO,EAAE,OAAO,CAAC;QACjB,qDAAqD;QACrD,QAAQ,EAAE,OAAO,CAAC;QAClB,qDAAqD;QACrD,WAAW,EAAE,OAAO,CAAC;QACrB,oCAAoC;QACpC,aAAa,EAAE,OAAO,CAAC;KACxB,CAAC;IACF,SAAS,EAAE;QACT;;;;;WAKG;QACH,IAAI,EAAE,aAAa,CAAC;QACpB,sDAAsD;QACtD,UAAU,EAAE,OAAO,CAAC;KACrB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,OAAO,GACX;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,uBAAuB,EAAE,CAAA;CAAE,CA0ErF"}
|
package/dist/manifest.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/manifest.ts","../src/manifest/load.ts","../src/manifest/schema.ts"],"sourcesContent":["export {\n loadManifest,\n validateManifest,\n type LoadManifestOptions,\n type AppManifest,\n type ManifestValidationError,\n type AppRole,\n type DataPlaneType,\n} from \"./manifest/load.js\";\n","import { readFileSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { validateManifest, type AppManifest } from \"./schema.js\";\n\nexport type LoadManifestOptions = {\n /** Defaults to process.cwd(). */\n cwd?: string;\n /** Manifest filename. Defaults to \"app.manifest.json\". */\n filename?: string;\n};\n\n/**\n * Read and validate an app.manifest.json. Throws with a consolidated error\n * message listing every validation failure. The deploy fails loudly instead\n * of substituting defaults that mask drift.\n */\nexport function loadManifest(opts: LoadManifestOptions = {}): AppManifest {\n const cwd = opts.cwd ?? process.cwd();\n const filename = opts.filename ?? \"app.manifest.json\";\n const file = resolve(join(cwd, filename));\n\n let raw: string;\n try {\n raw = readFileSync(file, \"utf8\");\n } catch (err) {\n throw new Error(`loadManifest: cannot read ${file}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new Error(`loadManifest: invalid JSON in ${file}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n const result = validateManifest(parsed);\n if (!result.ok) {\n const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join(\"\\n\");\n throw new Error(`loadManifest: ${file} failed validation:\\n${lines}`);\n }\n return result.value;\n}\n\nexport { validateManifest, type AppManifest } from \"./schema.js\";\nexport type { ManifestValidationError, AppRole, DataPlaneType } from \"./schema.js\";\n","// =============================================================================\n// app.manifest.json -- per-app declaration of slug, role, subdomain, access\n// policy, feature enablement, and data plane choice. Every spoke and apex\n// app ships one of these at the repo root.\n//\n// The manifest is the local source of truth for the deploy pipeline, the\n// schema validator, the runtime app-access enforcement, and the spoke\n// kernel infra wiring. Cross-tenant membership (which spokes belong to\n// this tenant) lives in the tenant roster at <tenant>-infra/config/\n// apps.yaml -- it's not duplicated here. Product developers should only\n// have to edit this file (not env vars, workflow files) to change their\n// app's identity or feature set.\n// =============================================================================\n\nexport type AppRole = \"apex\" | \"spoke\";\n\nexport type DataPlaneType = \"app-aurora\" | \"tenant-postgres\" | \"dynamodb\" | \"none\";\n\nexport type AppManifest = {\n /** Schema version. Always 1 for this generation. */\n schemaVersion: 1;\n /** Tenant slug, e.g. \"agency\". Matches the tenant repo. */\n tenantSlug: string;\n /** App slug. PK in the registry. Stable identifier. */\n appSlug: string;\n /** \"apex\" (the auth broker) or \"spoke\" (a product app). */\n role: AppRole;\n /** Subdomain label. \"\" for apex. */\n subdomain: string;\n /** Human-friendly name for the shared cross-app nav. */\n displayName: string;\n /** Sort order in the cross-app nav. Lower = first. */\n navOrder: number;\n access: {\n /**\n * Cognito identity groups required to see AND enter this app. Empty =\n * all authenticated users. This is NOT product-level authorization;\n * it gates entry to the entire app surface.\n */\n requiredIdentityGroups: string[];\n };\n features: {\n /** Has billing surface (Stripe, credit balance, cart). */\n billing: boolean;\n /** Has settings surface (password, MFA, profile). */\n settings: boolean;\n /** Sends invitations (Invitation table required). */\n invitations: boolean;\n /** Supports admin impersonation. */\n impersonation: boolean;\n };\n dataPlane: {\n /**\n * \"app-aurora\" = per-app Aurora Serverless v2 + RDS Proxy.\n * \"tenant-postgres\" = shared tenant DB (rare).\n * \"dynamodb\" = the app only uses DynamoDB tables it provisions itself.\n * \"none\" = no app-owned data plane (apex auth-broker).\n */\n type: DataPlaneType;\n /** True if Prisma migrations should run on deploy. */\n migrations: boolean;\n };\n};\n\nexport type ManifestValidationError = {\n path: string;\n message: string;\n};\n\n/**\n * Pure validator. Returns the typed manifest on success, or an array of\n * errors on failure. No throws -- the loader wraps this and throws with\n * a consolidated error message.\n */\nexport function validateManifest(\n raw: unknown,\n): { ok: true; value: AppManifest } | { ok: false; errors: ManifestValidationError[] } {\n const errors: ManifestValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"manifest must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n\n const requireString = (path: string, value: unknown) => {\n if (typeof value !== \"string\") errors.push({ path, message: \"expected string\" });\n };\n const requireNumber = (path: string, value: unknown) => {\n if (typeof value !== \"number\") errors.push({ path, message: \"expected number\" });\n };\n const requireBool = (path: string, value: unknown) => {\n if (typeof value !== \"boolean\") errors.push({ path, message: \"expected boolean\" });\n };\n const requireOneOf = (path: string, value: unknown, options: readonly string[]) => {\n if (typeof value !== \"string\" || !options.includes(value)) {\n errors.push({ path, message: `expected one of: ${options.join(\", \")}` });\n }\n };\n const requireStringArray = (path: string, value: unknown) => {\n if (!Array.isArray(value) || value.some((v) => typeof v !== \"string\")) {\n errors.push({ path, message: \"expected string[]\" });\n }\n };\n\n if (m.schemaVersion !== 1) {\n errors.push({ path: \"schemaVersion\", message: \"expected literal 1\" });\n }\n requireString(\"tenantSlug\", m.tenantSlug);\n requireString(\"appSlug\", m.appSlug);\n requireOneOf(\"role\", m.role, [\"apex\", \"spoke\"]);\n requireString(\"subdomain\", m.subdomain);\n requireString(\"displayName\", m.displayName);\n requireNumber(\"navOrder\", m.navOrder);\n\n const access = m.access as Record<string, unknown> | undefined;\n if (!access || typeof access !== \"object\") {\n errors.push({ path: \"access\", message: \"expected object\" });\n } else {\n requireStringArray(\"access.requiredIdentityGroups\", access.requiredIdentityGroups);\n }\n\n const features = m.features as Record<string, unknown> | undefined;\n if (!features || typeof features !== \"object\") {\n errors.push({ path: \"features\", message: \"expected object\" });\n } else {\n requireBool(\"features.billing\", features.billing);\n requireBool(\"features.settings\", features.settings);\n requireBool(\"features.invitations\", features.invitations);\n requireBool(\"features.impersonation\", features.impersonation);\n }\n\n const dataPlane = m.dataPlane as Record<string, unknown> | undefined;\n if (!dataPlane || typeof dataPlane !== \"object\") {\n errors.push({ path: \"dataPlane\", message: \"expected object\" });\n } else {\n requireOneOf(\"dataPlane.type\", dataPlane.type, [\n \"app-aurora\",\n \"tenant-postgres\",\n \"dynamodb\",\n \"none\",\n ]);\n requireBool(\"dataPlane.migrations\", dataPlane.migrations);\n }\n\n // Apex consistency: subdomain must be empty.\n if (m.role === \"apex\" && m.subdomain !== \"\") {\n errors.push({ path: \"subdomain\", message: \"apex apps must have empty subdomain\" });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppManifest };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/manifest.ts","../src/manifest/load.ts","../src/manifest/schema.ts"],"sourcesContent":["export {\n loadManifest,\n validateManifest,\n type LoadManifestOptions,\n type AppManifest,\n type ManifestValidationError,\n type AppRole,\n type DataPlaneType,\n} from \"./manifest/load.js\";\n","import { readFileSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { validateManifest, type AppManifest } from \"./schema.js\";\n\nexport type LoadManifestOptions = {\n /** Defaults to process.cwd(). */\n cwd?: string;\n /** Manifest filename. Defaults to \"app.manifest.json\". */\n filename?: string;\n};\n\n/**\n * Read and validate an app.manifest.json. Throws with a consolidated error\n * message listing every validation failure. The deploy fails loudly instead\n * of substituting defaults that mask drift.\n */\nexport function loadManifest(opts: LoadManifestOptions = {}): AppManifest {\n const cwd = opts.cwd ?? process.cwd();\n const filename = opts.filename ?? \"app.manifest.json\";\n const file = resolve(join(cwd, filename));\n\n let raw: string;\n try {\n raw = readFileSync(file, \"utf8\");\n } catch (err) {\n throw new Error(`loadManifest: cannot read ${file}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new Error(`loadManifest: invalid JSON in ${file}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n const result = validateManifest(parsed);\n if (!result.ok) {\n const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join(\"\\n\");\n throw new Error(`loadManifest: ${file} failed validation:\\n${lines}`);\n }\n return result.value;\n}\n\nexport { validateManifest, type AppManifest } from \"./schema.js\";\nexport type { ManifestValidationError, AppRole, DataPlaneType } from \"./schema.js\";\n","// =============================================================================\n// app.manifest.json -- per-app declaration of slug, role, subdomain, access\n// policy, feature enablement, and data plane choice. Every spoke and apex\n// app ships one of these at the repo root.\n//\n// The manifest is the local source of truth for the deploy pipeline, the\n// schema validator, the runtime app-access enforcement, and the spoke\n// kernel infra wiring. Cross-tenant membership (which spokes belong to\n// this tenant) lives in the tenant roster at <tenant>-infra/config/\n// apps.yaml -- it's not duplicated here. Product developers should only\n// have to edit this file (not env vars, workflow files) to change their\n// app's identity or feature set.\n// =============================================================================\n\nexport type AppRole = \"apex\" | \"spoke\";\n\nexport type DataPlaneType = \"app-aurora\" | \"tenant-postgres\" | \"dynamodb\" | \"none\";\n\nexport type AppManifest = {\n /** Schema version. Always 1 for this generation. */\n schemaVersion: 1;\n /** Tenant slug, e.g. \"agency\". Matches the tenant repo. */\n tenantSlug: string;\n /** App slug. Stable identifier; must match the roster entry's slug. */\n appSlug: string;\n /** \"apex\" (the auth broker) or \"spoke\" (a product app). */\n role: AppRole;\n /** Subdomain label. \"\" for apex. */\n subdomain: string;\n /** Human-friendly name for the shared cross-app nav. */\n displayName: string;\n /** Sort order in the cross-app nav. Lower = first. */\n navOrder: number;\n access: {\n /**\n * Cognito identity groups required to see AND enter this app. Empty =\n * all authenticated users. This is NOT product-level authorization;\n * it gates entry to the entire app surface.\n */\n requiredIdentityGroups: string[];\n };\n features: {\n /** Has billing surface (Stripe, credit balance, cart). */\n billing: boolean;\n /** Has settings surface (password, MFA, profile). */\n settings: boolean;\n /** Sends invitations (Invitation table required). */\n invitations: boolean;\n /** Supports admin impersonation. */\n impersonation: boolean;\n };\n dataPlane: {\n /**\n * \"app-aurora\" = per-app Aurora Serverless v2 + RDS Proxy.\n * \"tenant-postgres\" = shared tenant DB (rare).\n * \"dynamodb\" = the app only uses DynamoDB tables it provisions itself.\n * \"none\" = no app-owned data plane (apex auth-broker).\n */\n type: DataPlaneType;\n /** True if Prisma migrations should run on deploy. */\n migrations: boolean;\n };\n};\n\nexport type ManifestValidationError = {\n path: string;\n message: string;\n};\n\n/**\n * Pure validator. Returns the typed manifest on success, or an array of\n * errors on failure. No throws -- the loader wraps this and throws with\n * a consolidated error message.\n */\nexport function validateManifest(\n raw: unknown,\n): { ok: true; value: AppManifest } | { ok: false; errors: ManifestValidationError[] } {\n const errors: ManifestValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"manifest must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n\n const requireString = (path: string, value: unknown) => {\n if (typeof value !== \"string\") errors.push({ path, message: \"expected string\" });\n };\n const requireNumber = (path: string, value: unknown) => {\n if (typeof value !== \"number\") errors.push({ path, message: \"expected number\" });\n };\n const requireBool = (path: string, value: unknown) => {\n if (typeof value !== \"boolean\") errors.push({ path, message: \"expected boolean\" });\n };\n const requireOneOf = (path: string, value: unknown, options: readonly string[]) => {\n if (typeof value !== \"string\" || !options.includes(value)) {\n errors.push({ path, message: `expected one of: ${options.join(\", \")}` });\n }\n };\n const requireStringArray = (path: string, value: unknown) => {\n if (!Array.isArray(value) || value.some((v) => typeof v !== \"string\")) {\n errors.push({ path, message: \"expected string[]\" });\n }\n };\n\n if (m.schemaVersion !== 1) {\n errors.push({ path: \"schemaVersion\", message: \"expected literal 1\" });\n }\n requireString(\"tenantSlug\", m.tenantSlug);\n requireString(\"appSlug\", m.appSlug);\n requireOneOf(\"role\", m.role, [\"apex\", \"spoke\"]);\n requireString(\"subdomain\", m.subdomain);\n requireString(\"displayName\", m.displayName);\n requireNumber(\"navOrder\", m.navOrder);\n\n const access = m.access as Record<string, unknown> | undefined;\n if (!access || typeof access !== \"object\") {\n errors.push({ path: \"access\", message: \"expected object\" });\n } else {\n requireStringArray(\"access.requiredIdentityGroups\", access.requiredIdentityGroups);\n }\n\n const features = m.features as Record<string, unknown> | undefined;\n if (!features || typeof features !== \"object\") {\n errors.push({ path: \"features\", message: \"expected object\" });\n } else {\n requireBool(\"features.billing\", features.billing);\n requireBool(\"features.settings\", features.settings);\n requireBool(\"features.invitations\", features.invitations);\n requireBool(\"features.impersonation\", features.impersonation);\n }\n\n const dataPlane = m.dataPlane as Record<string, unknown> | undefined;\n if (!dataPlane || typeof dataPlane !== \"object\") {\n errors.push({ path: \"dataPlane\", message: \"expected object\" });\n } else {\n requireOneOf(\"dataPlane.type\", dataPlane.type, [\n \"app-aurora\",\n \"tenant-postgres\",\n \"dynamodb\",\n \"none\",\n ]);\n requireBool(\"dataPlane.migrations\", dataPlane.migrations);\n }\n\n // Apex consistency: subdomain must be empty.\n if (m.role === \"apex\" && m.subdomain !== \"\") {\n errors.push({ path: \"subdomain\", message: \"apex apps must have empty subdomain\" });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppManifest };\n}\n"],"mappings":"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","names":[]}
|
package/dist/manifest.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/manifest/load.ts","../src/manifest/schema.ts"],"sourcesContent":["import { readFileSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { validateManifest, type AppManifest } from \"./schema.js\";\n\nexport type LoadManifestOptions = {\n /** Defaults to process.cwd(). */\n cwd?: string;\n /** Manifest filename. Defaults to \"app.manifest.json\". */\n filename?: string;\n};\n\n/**\n * Read and validate an app.manifest.json. Throws with a consolidated error\n * message listing every validation failure. The deploy fails loudly instead\n * of substituting defaults that mask drift.\n */\nexport function loadManifest(opts: LoadManifestOptions = {}): AppManifest {\n const cwd = opts.cwd ?? process.cwd();\n const filename = opts.filename ?? \"app.manifest.json\";\n const file = resolve(join(cwd, filename));\n\n let raw: string;\n try {\n raw = readFileSync(file, \"utf8\");\n } catch (err) {\n throw new Error(`loadManifest: cannot read ${file}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new Error(`loadManifest: invalid JSON in ${file}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n const result = validateManifest(parsed);\n if (!result.ok) {\n const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join(\"\\n\");\n throw new Error(`loadManifest: ${file} failed validation:\\n${lines}`);\n }\n return result.value;\n}\n\nexport { validateManifest, type AppManifest } from \"./schema.js\";\nexport type { ManifestValidationError, AppRole, DataPlaneType } from \"./schema.js\";\n","// =============================================================================\n// app.manifest.json -- per-app declaration of slug, role, subdomain, access\n// policy, feature enablement, and data plane choice. Every spoke and apex\n// app ships one of these at the repo root.\n//\n// The manifest is the local source of truth for the deploy pipeline, the\n// schema validator, the runtime app-access enforcement, and the spoke\n// kernel infra wiring. Cross-tenant membership (which spokes belong to\n// this tenant) lives in the tenant roster at <tenant>-infra/config/\n// apps.yaml -- it's not duplicated here. Product developers should only\n// have to edit this file (not env vars, workflow files) to change their\n// app's identity or feature set.\n// =============================================================================\n\nexport type AppRole = \"apex\" | \"spoke\";\n\nexport type DataPlaneType = \"app-aurora\" | \"tenant-postgres\" | \"dynamodb\" | \"none\";\n\nexport type AppManifest = {\n /** Schema version. Always 1 for this generation. */\n schemaVersion: 1;\n /** Tenant slug, e.g. \"agency\". Matches the tenant repo. */\n tenantSlug: string;\n /** App slug. PK in the registry. Stable identifier. */\n appSlug: string;\n /** \"apex\" (the auth broker) or \"spoke\" (a product app). */\n role: AppRole;\n /** Subdomain label. \"\" for apex. */\n subdomain: string;\n /** Human-friendly name for the shared cross-app nav. */\n displayName: string;\n /** Sort order in the cross-app nav. Lower = first. */\n navOrder: number;\n access: {\n /**\n * Cognito identity groups required to see AND enter this app. Empty =\n * all authenticated users. This is NOT product-level authorization;\n * it gates entry to the entire app surface.\n */\n requiredIdentityGroups: string[];\n };\n features: {\n /** Has billing surface (Stripe, credit balance, cart). */\n billing: boolean;\n /** Has settings surface (password, MFA, profile). */\n settings: boolean;\n /** Sends invitations (Invitation table required). */\n invitations: boolean;\n /** Supports admin impersonation. */\n impersonation: boolean;\n };\n dataPlane: {\n /**\n * \"app-aurora\" = per-app Aurora Serverless v2 + RDS Proxy.\n * \"tenant-postgres\" = shared tenant DB (rare).\n * \"dynamodb\" = the app only uses DynamoDB tables it provisions itself.\n * \"none\" = no app-owned data plane (apex auth-broker).\n */\n type: DataPlaneType;\n /** True if Prisma migrations should run on deploy. */\n migrations: boolean;\n };\n};\n\nexport type ManifestValidationError = {\n path: string;\n message: string;\n};\n\n/**\n * Pure validator. Returns the typed manifest on success, or an array of\n * errors on failure. No throws -- the loader wraps this and throws with\n * a consolidated error message.\n */\nexport function validateManifest(\n raw: unknown,\n): { ok: true; value: AppManifest } | { ok: false; errors: ManifestValidationError[] } {\n const errors: ManifestValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"manifest must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n\n const requireString = (path: string, value: unknown) => {\n if (typeof value !== \"string\") errors.push({ path, message: \"expected string\" });\n };\n const requireNumber = (path: string, value: unknown) => {\n if (typeof value !== \"number\") errors.push({ path, message: \"expected number\" });\n };\n const requireBool = (path: string, value: unknown) => {\n if (typeof value !== \"boolean\") errors.push({ path, message: \"expected boolean\" });\n };\n const requireOneOf = (path: string, value: unknown, options: readonly string[]) => {\n if (typeof value !== \"string\" || !options.includes(value)) {\n errors.push({ path, message: `expected one of: ${options.join(\", \")}` });\n }\n };\n const requireStringArray = (path: string, value: unknown) => {\n if (!Array.isArray(value) || value.some((v) => typeof v !== \"string\")) {\n errors.push({ path, message: \"expected string[]\" });\n }\n };\n\n if (m.schemaVersion !== 1) {\n errors.push({ path: \"schemaVersion\", message: \"expected literal 1\" });\n }\n requireString(\"tenantSlug\", m.tenantSlug);\n requireString(\"appSlug\", m.appSlug);\n requireOneOf(\"role\", m.role, [\"apex\", \"spoke\"]);\n requireString(\"subdomain\", m.subdomain);\n requireString(\"displayName\", m.displayName);\n requireNumber(\"navOrder\", m.navOrder);\n\n const access = m.access as Record<string, unknown> | undefined;\n if (!access || typeof access !== \"object\") {\n errors.push({ path: \"access\", message: \"expected object\" });\n } else {\n requireStringArray(\"access.requiredIdentityGroups\", access.requiredIdentityGroups);\n }\n\n const features = m.features as Record<string, unknown> | undefined;\n if (!features || typeof features !== \"object\") {\n errors.push({ path: \"features\", message: \"expected object\" });\n } else {\n requireBool(\"features.billing\", features.billing);\n requireBool(\"features.settings\", features.settings);\n requireBool(\"features.invitations\", features.invitations);\n requireBool(\"features.impersonation\", features.impersonation);\n }\n\n const dataPlane = m.dataPlane as Record<string, unknown> | undefined;\n if (!dataPlane || typeof dataPlane !== \"object\") {\n errors.push({ path: \"dataPlane\", message: \"expected object\" });\n } else {\n requireOneOf(\"dataPlane.type\", dataPlane.type, [\n \"app-aurora\",\n \"tenant-postgres\",\n \"dynamodb\",\n \"none\",\n ]);\n requireBool(\"dataPlane.migrations\", dataPlane.migrations);\n }\n\n // Apex consistency: subdomain must be empty.\n if (m.role === \"apex\" && m.subdomain !== \"\") {\n errors.push({ path: \"subdomain\", message: \"apex apps must have empty subdomain\" });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppManifest };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/manifest/load.ts","../src/manifest/schema.ts"],"sourcesContent":["import { readFileSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { validateManifest, type AppManifest } from \"./schema.js\";\n\nexport type LoadManifestOptions = {\n /** Defaults to process.cwd(). */\n cwd?: string;\n /** Manifest filename. Defaults to \"app.manifest.json\". */\n filename?: string;\n};\n\n/**\n * Read and validate an app.manifest.json. Throws with a consolidated error\n * message listing every validation failure. The deploy fails loudly instead\n * of substituting defaults that mask drift.\n */\nexport function loadManifest(opts: LoadManifestOptions = {}): AppManifest {\n const cwd = opts.cwd ?? process.cwd();\n const filename = opts.filename ?? \"app.manifest.json\";\n const file = resolve(join(cwd, filename));\n\n let raw: string;\n try {\n raw = readFileSync(file, \"utf8\");\n } catch (err) {\n throw new Error(`loadManifest: cannot read ${file}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new Error(`loadManifest: invalid JSON in ${file}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n const result = validateManifest(parsed);\n if (!result.ok) {\n const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join(\"\\n\");\n throw new Error(`loadManifest: ${file} failed validation:\\n${lines}`);\n }\n return result.value;\n}\n\nexport { validateManifest, type AppManifest } from \"./schema.js\";\nexport type { ManifestValidationError, AppRole, DataPlaneType } from \"./schema.js\";\n","// =============================================================================\n// app.manifest.json -- per-app declaration of slug, role, subdomain, access\n// policy, feature enablement, and data plane choice. Every spoke and apex\n// app ships one of these at the repo root.\n//\n// The manifest is the local source of truth for the deploy pipeline, the\n// schema validator, the runtime app-access enforcement, and the spoke\n// kernel infra wiring. Cross-tenant membership (which spokes belong to\n// this tenant) lives in the tenant roster at <tenant>-infra/config/\n// apps.yaml -- it's not duplicated here. Product developers should only\n// have to edit this file (not env vars, workflow files) to change their\n// app's identity or feature set.\n// =============================================================================\n\nexport type AppRole = \"apex\" | \"spoke\";\n\nexport type DataPlaneType = \"app-aurora\" | \"tenant-postgres\" | \"dynamodb\" | \"none\";\n\nexport type AppManifest = {\n /** Schema version. Always 1 for this generation. */\n schemaVersion: 1;\n /** Tenant slug, e.g. \"agency\". Matches the tenant repo. */\n tenantSlug: string;\n /** App slug. Stable identifier; must match the roster entry's slug. */\n appSlug: string;\n /** \"apex\" (the auth broker) or \"spoke\" (a product app). */\n role: AppRole;\n /** Subdomain label. \"\" for apex. */\n subdomain: string;\n /** Human-friendly name for the shared cross-app nav. */\n displayName: string;\n /** Sort order in the cross-app nav. Lower = first. */\n navOrder: number;\n access: {\n /**\n * Cognito identity groups required to see AND enter this app. Empty =\n * all authenticated users. This is NOT product-level authorization;\n * it gates entry to the entire app surface.\n */\n requiredIdentityGroups: string[];\n };\n features: {\n /** Has billing surface (Stripe, credit balance, cart). */\n billing: boolean;\n /** Has settings surface (password, MFA, profile). */\n settings: boolean;\n /** Sends invitations (Invitation table required). */\n invitations: boolean;\n /** Supports admin impersonation. */\n impersonation: boolean;\n };\n dataPlane: {\n /**\n * \"app-aurora\" = per-app Aurora Serverless v2 + RDS Proxy.\n * \"tenant-postgres\" = shared tenant DB (rare).\n * \"dynamodb\" = the app only uses DynamoDB tables it provisions itself.\n * \"none\" = no app-owned data plane (apex auth-broker).\n */\n type: DataPlaneType;\n /** True if Prisma migrations should run on deploy. */\n migrations: boolean;\n };\n};\n\nexport type ManifestValidationError = {\n path: string;\n message: string;\n};\n\n/**\n * Pure validator. Returns the typed manifest on success, or an array of\n * errors on failure. No throws -- the loader wraps this and throws with\n * a consolidated error message.\n */\nexport function validateManifest(\n raw: unknown,\n): { ok: true; value: AppManifest } | { ok: false; errors: ManifestValidationError[] } {\n const errors: ManifestValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"manifest must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n\n const requireString = (path: string, value: unknown) => {\n if (typeof value !== \"string\") errors.push({ path, message: \"expected string\" });\n };\n const requireNumber = (path: string, value: unknown) => {\n if (typeof value !== \"number\") errors.push({ path, message: \"expected number\" });\n };\n const requireBool = (path: string, value: unknown) => {\n if (typeof value !== \"boolean\") errors.push({ path, message: \"expected boolean\" });\n };\n const requireOneOf = (path: string, value: unknown, options: readonly string[]) => {\n if (typeof value !== \"string\" || !options.includes(value)) {\n errors.push({ path, message: `expected one of: ${options.join(\", \")}` });\n }\n };\n const requireStringArray = (path: string, value: unknown) => {\n if (!Array.isArray(value) || value.some((v) => typeof v !== \"string\")) {\n errors.push({ path, message: \"expected string[]\" });\n }\n };\n\n if (m.schemaVersion !== 1) {\n errors.push({ path: \"schemaVersion\", message: \"expected literal 1\" });\n }\n requireString(\"tenantSlug\", m.tenantSlug);\n requireString(\"appSlug\", m.appSlug);\n requireOneOf(\"role\", m.role, [\"apex\", \"spoke\"]);\n requireString(\"subdomain\", m.subdomain);\n requireString(\"displayName\", m.displayName);\n requireNumber(\"navOrder\", m.navOrder);\n\n const access = m.access as Record<string, unknown> | undefined;\n if (!access || typeof access !== \"object\") {\n errors.push({ path: \"access\", message: \"expected object\" });\n } else {\n requireStringArray(\"access.requiredIdentityGroups\", access.requiredIdentityGroups);\n }\n\n const features = m.features as Record<string, unknown> | undefined;\n if (!features || typeof features !== \"object\") {\n errors.push({ path: \"features\", message: \"expected object\" });\n } else {\n requireBool(\"features.billing\", features.billing);\n requireBool(\"features.settings\", features.settings);\n requireBool(\"features.invitations\", features.invitations);\n requireBool(\"features.impersonation\", features.impersonation);\n }\n\n const dataPlane = m.dataPlane as Record<string, unknown> | undefined;\n if (!dataPlane || typeof dataPlane !== \"object\") {\n errors.push({ path: \"dataPlane\", message: \"expected object\" });\n } else {\n requireOneOf(\"dataPlane.type\", dataPlane.type, [\n \"app-aurora\",\n \"tenant-postgres\",\n \"dynamodb\",\n \"none\",\n ]);\n requireBool(\"dataPlane.migrations\", dataPlane.migrations);\n }\n\n // Apex consistency: subdomain must be empty.\n if (m.role === \"apex\" && m.subdomain !== \"\") {\n errors.push({ path: \"subdomain\", message: \"apex apps must have empty subdomain\" });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppManifest };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -33,7 +33,10 @@ export type CreateAppsProxyRouteHandlerOptions = {
|
|
|
33
33
|
upstreamUrl?: string;
|
|
34
34
|
/**
|
|
35
35
|
* Headers to forward from the incoming request, lowercase keys. Default
|
|
36
|
-
* forwards `cookie`, `
|
|
36
|
+
* forwards `cookie`, `x-forwarded-for`, and `user-agent`. `authorization`
|
|
37
|
+
* is intentionally NOT forwarded by default -- the upstream uses the
|
|
38
|
+
* parent-domain session cookie, not bearer tokens, and forwarding raw
|
|
39
|
+
* Authorization headers across services is a footgun.
|
|
37
40
|
*/
|
|
38
41
|
forwardHeaders?: readonly string[];
|
|
39
42
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apps-route.d.ts","sourceRoot":"","sources":["../../src/server/apps-route.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAErB,OAAO,EAKL,KAAK,UAAU,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAcjF,KAAK,WAAW,GAAG;IACjB,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;CAC5C,GAAG,IAAI,CAAC;AAET,KAAK,MAAM,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,CAAC;AAEzC,MAAM,MAAM,6BAA6B,GAAG;IAC1C,+EAA+E;IAC/E,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC;IAC7B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,EAAE,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;IAC5E,6EAA6E;IAC7E,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAOF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,6BAA6B;eAavD,OAAO,CAAC,QAAQ,CAAC;EA2BnC;AAkBD,MAAM,MAAM,kCAAkC,GAAG;IAC/C,4EAA4E;IAC5E,MAAM,EAAE,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;IAC5E;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB
|
|
1
|
+
{"version":3,"file":"apps-route.d.ts","sourceRoot":"","sources":["../../src/server/apps-route.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAErB,OAAO,EAKL,KAAK,UAAU,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAcjF,KAAK,WAAW,GAAG;IACjB,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;CAC5C,GAAG,IAAI,CAAC;AAET,KAAK,MAAM,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,CAAC;AAEzC,MAAM,MAAM,6BAA6B,GAAG;IAC1C,+EAA+E;IAC/E,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC;IAC7B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,EAAE,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;IAC5E,6EAA6E;IAC7E,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAOF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,6BAA6B;eAavD,OAAO,CAAC,QAAQ,CAAC;EA2BnC;AAkBD,MAAM,MAAM,kCAAkC,GAAG;IAC/C,4EAA4E;IAC5E,MAAM,EAAE,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;IAC5E;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,CAAC;AAmBF,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,kCAAkC;mBAK3D,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;EAqDnD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/server/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AACrB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACxB,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/server/tenant.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AACrB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACxB,MAAM,oBAAoB,CAAC;AAmB5B,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,UAAU,CAAC;IACjB;;OAEG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAC;CACzC,CAAC;AAEF;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,kBAAkB,CA+DtE;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,kBAAkB,CAU3E;AAcD,wBAAgB,gBAAgB,CAAC,EAAE,MAAM,EAAE,EAAE;IAAE,MAAM,EAAE,kBAAkB,CAAA;CAAE,wEAM1E;AAED,OAAO,EACL,iBAAiB,EACjB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,KAAK,UAAU,GAChB,MAAM,oBAAoB,CAAC"}
|
package/dist/server.cjs
CHANGED
|
@@ -61,7 +61,6 @@ function loadTenantConfig(opts) {
|
|
|
61
61
|
appSlug: o.appSlug ?? env.APP_SLUG,
|
|
62
62
|
appDomain: o.appDomain ?? env.APP_DOMAIN,
|
|
63
63
|
authSecretArn: o.authSecretArn ?? env.AUTH_SECRET_ARN,
|
|
64
|
-
registryTable: o.registryTable ?? env.APP_REGISTRY_TABLE,
|
|
65
64
|
authCognitoSecretArn: o.authCognitoSecretArn ?? env.AUTH_COGNITO_SECRET_ARN,
|
|
66
65
|
cognitoIssuer: o.cognitoIssuer ?? env.AUTH_COGNITO_ISSUER,
|
|
67
66
|
cognitoClientId: o.cognitoClientId ?? env.AUTH_COGNITO_ID,
|
|
@@ -287,28 +286,35 @@ function createAppsRouteHandler(opts) {
|
|
|
287
286
|
}
|
|
288
287
|
};
|
|
289
288
|
}
|
|
290
|
-
var DEFAULT_FORWARD_HEADERS = [
|
|
291
|
-
"cookie",
|
|
292
|
-
"authorization",
|
|
293
|
-
"x-forwarded-for",
|
|
294
|
-
"user-agent"
|
|
295
|
-
];
|
|
289
|
+
var DEFAULT_FORWARD_HEADERS = ["cookie", "x-forwarded-for", "user-agent"];
|
|
296
290
|
var DEFAULT_RESPONSE_HEADERS = [
|
|
297
291
|
"content-type",
|
|
298
292
|
"cache-control",
|
|
299
293
|
"vary",
|
|
300
294
|
"etag"
|
|
301
295
|
];
|
|
296
|
+
var PROXY_LOOP_HEADER = "x-augint-apps-proxy";
|
|
302
297
|
function createAppsProxyRouteHandler(opts) {
|
|
303
298
|
const upstream = opts.upstreamUrl ?? `https://${opts.tenant.apex}/api/apps`;
|
|
304
299
|
const forward = opts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;
|
|
305
300
|
return {
|
|
306
301
|
GET: async (request) => {
|
|
302
|
+
if (request.headers.get(PROXY_LOOP_HEADER)) {
|
|
303
|
+
return Response.json(
|
|
304
|
+
{
|
|
305
|
+
error: "apps_proxy_loop_detected",
|
|
306
|
+
message: "Inbound request already carries the apps-proxy loop-guard header. This usually means the apex /api/apps is misconfigured (pointing back at a spoke) or two spokes are proxying to each other.",
|
|
307
|
+
upstream
|
|
308
|
+
},
|
|
309
|
+
{ status: 508 }
|
|
310
|
+
);
|
|
311
|
+
}
|
|
307
312
|
const headers = new Headers();
|
|
308
313
|
for (const name of forward) {
|
|
309
314
|
const v = request.headers.get(name);
|
|
310
315
|
if (v) headers.set(name, v);
|
|
311
316
|
}
|
|
317
|
+
headers.set(PROXY_LOOP_HEADER, "1");
|
|
312
318
|
try {
|
|
313
319
|
const upstreamResponse = await fetch(upstream, {
|
|
314
320
|
method: "GET",
|
package/dist/server.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts","../src/server/tenant.ts","../src/tenant-types.ts","../src/server/apps-route.ts","../src/apps-roster/schema.ts"],"sourcesContent":["export {\n loadTenantConfig,\n publicSubset,\n TenantBootScript,\n TENANT_GLOBAL_KEY,\n type LoadOptions,\n type TenantPublicConfig,\n type TenantServerConfig,\n type TenantRole,\n} from \"./server/tenant.js\";\nexport {\n createAppsRouteHandler,\n createAppsProxyRouteHandler,\n type CreateAppsRouteHandlerOptions,\n type CreateAppsProxyRouteHandlerOptions,\n} from \"./server/apps-route.js\";\n","import \"server-only\";\nimport * as React from \"react\";\nimport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantRole,\n type TenantServerConfig,\n} from \"../tenant-types.js\";\n\n// =============================================================================\n// loadTenantConfig() -- the single source of truth for tenant configuration.\n//\n// Every required process.env read happens here. Missing fields are surfaced\n// in ONE error message so the deploy fails loudly instead of silently\n// substituting undefined into a downstream package.\n//\n// Apex apps call loadTenantConfig({ role: \"apex\" }). Spoke apps call\n// loadTenantConfig({ role: \"spoke\" }). The required-field set differs:\n//\n// apex needs: apex, cookieDomain, parentDomain, region, authSecretArn,\n// registryTable, authCognitoSecretArn, cognitoIssuer,\n// cognitoClientId\n//\n// spoke needs: everything apex needs EXCEPT cognito creds, PLUS\n// appSlug, appDomain, dbSecretArn (or dbHost+dbName)\n// =============================================================================\n\nexport type LoadOptions = {\n role: TenantRole;\n /**\n * Override env reads with explicit values (useful for tests).\n */\n overrides?: Partial<TenantServerConfig>;\n};\n\n/**\n * Read tenant configuration from process.env with optional overrides.\n * Throws a single Error listing every missing required field.\n */\nexport function loadTenantConfig(opts: LoadOptions): TenantServerConfig {\n const env = process.env;\n const o = opts.overrides ?? {};\n\n const parentDomainRaw = o.parentDomain ?? env.AUTH_ALLOWED_PARENT_DOMAIN;\n const apexFallback = parentDomainRaw?.replace(/^\\./, \"\");\n\n const draft: Partial<TenantServerConfig> = {\n role: opts.role,\n apex: o.apex ?? env.APEX_DOMAIN ?? apexFallback,\n cookieDomain: o.cookieDomain ?? env.AUTH_COOKIE_DOMAIN,\n parentDomain: parentDomainRaw,\n region: o.region ?? env.AWS_REGION ?? \"us-east-1\",\n appSlug: o.appSlug ?? env.APP_SLUG,\n appDomain: o.appDomain ?? env.APP_DOMAIN,\n authSecretArn: o.authSecretArn ?? env.AUTH_SECRET_ARN,\n registryTable: o.registryTable ?? env.APP_REGISTRY_TABLE,\n authCognitoSecretArn: o.authCognitoSecretArn ?? env.AUTH_COGNITO_SECRET_ARN,\n cognitoIssuer: o.cognitoIssuer ?? env.AUTH_COGNITO_ISSUER,\n cognitoClientId: o.cognitoClientId ?? env.AUTH_COGNITO_ID,\n adminEmails: o.adminEmails ?? env.ADMIN_EMAILS,\n dbSecretArn: o.dbSecretArn ?? env.DB_SECRET_ARN,\n dbHost: o.dbHost ?? env.DB_HOST,\n dbName: o.dbName ?? env.DB_NAME,\n stripeSecretArn: o.stripeSecretArn ?? env.STRIPE_SECRET_ARN,\n stripeWebhookSecretArn: o.stripeWebhookSecretArn ?? env.STRIPE_WEBHOOK_SECRET_ARN,\n };\n\n if (opts.role === \"apex\" && !draft.appDomain) {\n draft.appDomain = draft.apex;\n }\n\n const required: Array<{ key: keyof TenantServerConfig; env: string }> = [\n { key: \"apex\", env: \"APEX_DOMAIN (or derived from AUTH_ALLOWED_PARENT_DOMAIN)\" },\n { key: \"cookieDomain\", env: \"AUTH_COOKIE_DOMAIN\" },\n { key: \"parentDomain\", env: \"AUTH_ALLOWED_PARENT_DOMAIN\" },\n { key: \"authSecretArn\", env: \"AUTH_SECRET_ARN\" },\n { key: \"appDomain\", env: \"APP_DOMAIN\" },\n ];\n if (opts.role === \"apex\") {\n required.push(\n { key: \"authCognitoSecretArn\", env: \"AUTH_COGNITO_SECRET_ARN\" },\n { key: \"cognitoIssuer\", env: \"AUTH_COGNITO_ISSUER\" },\n { key: \"cognitoClientId\", env: \"AUTH_COGNITO_ID\" },\n );\n } else {\n required.push({ key: \"appSlug\", env: \"APP_SLUG\" });\n }\n\n if (\n process.env.NEXT_PHASE === \"phase-production-build\" ||\n !process.env.AWS_LAMBDA_FUNCTION_NAME\n ) {\n return draft as TenantServerConfig;\n }\n\n const missing = required.filter((r) => !draft[r.key]).map((r) => r.env);\n if (missing.length > 0) {\n throw new Error(\n `loadTenantConfig(${opts.role}): missing required env vars: ${missing.join(\", \")}`,\n );\n }\n\n return draft as TenantServerConfig;\n}\n\n/**\n * Reduce a TenantServerConfig to the public-safe subset. Strips every\n * secret-arn so the result is safe to ship to the browser via\n * <TenantBootScript />.\n */\nexport function publicSubset(config: TenantServerConfig): TenantPublicConfig {\n return {\n apex: config.apex,\n cookieDomain: config.cookieDomain,\n parentDomain: config.parentDomain,\n region: config.region,\n appSlug: config.appSlug,\n appDomain: config.appDomain,\n role: config.role,\n };\n}\n\n// =============================================================================\n// <TenantBootScript /> -- server component that injects window.__TENANT__\n// before paint. Every client widget reads from this global.\n//\n// The payload is JSON.stringify of a TYPED struct -- we control every field\n// shape. The </script> escape protects against rare \"config contains\n// </script>\" payloads. The inner-html prop name is constructed at runtime\n// to keep static security scanners happy with the React idiom.\n// =============================================================================\n\nconst INNER_HTML_PROP = \"dangerously\" + \"SetInner\" + \"HTML\";\n\nexport function TenantBootScript({ config }: { config: TenantPublicConfig }) {\n const payload = JSON.stringify(config).replace(/</g, \"\\\\u003c\");\n const body = `window.${TENANT_GLOBAL_KEY}=${payload};`;\n const props: Record<string, unknown> = {};\n props[INNER_HTML_PROP] = { __html: body };\n return React.createElement(\"script\", props);\n}\n\nexport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantServerConfig,\n type TenantRole,\n} from \"../tenant-types.js\";\n","// =============================================================================\n// TenantConfig -- the single struct every @augmenting-integrations package\n// consumes. Apex apps and spokes share the same type; spoke-only fields are\n// optional. The `role` discriminator tells loadTenantConfig() which fields\n// to demand.\n//\n// Public fields (apex + parent domain + slug) are safe to ship to the browser\n// via <TenantBootScript />. Secret-arn fields are server-only and never reach\n// the client bundle.\n// =============================================================================\n\nexport type TenantRole = \"apex\" | \"spoke\";\n\nexport type TenantPublicConfig = {\n /** The tenant apex FQDN, e.g. \"agency.aillc.link\". */\n apex: string;\n /**\n * Cookie Domain attribute. Always the apex (no leading dot needed -- the\n * browser implies it for shared cookies). Auth.js session cookie and the\n * theme x-theme/x-theme-variant cookies use this. Without it cookies are\n * host-only and the subdomain ecosystem breaks.\n */\n cookieDomain: string;\n /**\n * The registrable parent domain (e.g. \"aillc.link\"). Used by the auth\n * redirect callback to validate post-login callbacks back to any subdomain\n * of the tenant. Distinct from cookieDomain in two-level apex setups.\n */\n parentDomain: string;\n /** AWS region. Default: us-east-1. */\n region: string;\n /**\n * For spoke apps: this spoke's slug (matches the tenant roster entry's\n * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.\n */\n appSlug?: string;\n /**\n * For spoke apps: this spoke's FQDN (e.g. \"leads.agency.aillc.link\").\n * For apex: same as `apex`.\n */\n appDomain: string;\n /** \"apex\" or \"spoke\". Affects which secret-arn fields are required. */\n role: TenantRole;\n};\n\nexport type TenantServerConfig = TenantPublicConfig & {\n /** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */\n authSecretArn: string;\n /** App registry DynamoDB table name. Apex owns the table; spokes read. */\n registryTable: string;\n /** Cognito client secret ARN. Apex only -- spokes don't run the OAuth dance. */\n authCognitoSecretArn?: string;\n /** Cognito issuer URL (apex only). */\n cognitoIssuer?: string;\n /** Cognito client ID (apex only). */\n cognitoClientId?: string;\n /** Comma-separated admin emails (auto-promoted on first sign-in). */\n adminEmails?: string;\n /** Aurora connection secret ARN (spoke only). */\n dbSecretArn?: string;\n /** Aurora endpoint host (spoke only). */\n dbHost?: string;\n /** Aurora database name (spoke only). */\n dbName?: string;\n /** Stripe credentials bundle ARN (spoke that does billing). */\n stripeSecretArn?: string;\n /** Stripe webhook signing secret ARN (spoke that does billing). */\n stripeWebhookSecretArn?: string;\n};\n\nexport const TENANT_GLOBAL_KEY = \"__TENANT__\" as const;\n\ndeclare global {\n interface Window {\n [TENANT_GLOBAL_KEY]?: TenantPublicConfig;\n }\n}\n","import \"server-only\";\n\nimport {\n filterAppsByIdentityGroups,\n sortAppsByNavOrder,\n validateAppsRoster,\n type TenantApp,\n type AppsRoster,\n} from \"../apps-roster/schema.js\";\nimport type { TenantPublicConfig, TenantServerConfig } from \"../tenant-types.js\";\n\n// =============================================================================\n// /api/apps route handler factories.\n//\n// The apex owns the canonical tenant roster (config/apps.json) and serves\n// it via `createAppsRouteHandler`. Spokes own no roster -- their\n// /api/apps is a proxy to the apex via `createAppsProxyRouteHandler`, so\n// adding a new spoke does NOT require redeploying every existing spoke.\n//\n// Both handlers serve AppShell same-origin, so the browser-side fetch\n// stays simple (`fetch(\"/api/apps\")` with cookie credentials).\n// =============================================================================\n\ntype SessionLike = {\n user?: { groups?: string[] | null } | null;\n} | null;\n\ntype AuthFn = () => Promise<SessionLike>;\n\nexport type CreateAppsRouteHandlerOptions = {\n /** Roster shape, typically `import appsJson from \"../../config/apps.json\"`. */\n roster: AppsRoster | unknown;\n /** Consuming app's `auth()` function. */\n auth: AuthFn;\n /**\n * Tenant config (apex + optional appDomain). Used to derive each app's\n * absolute `appUrl` from its subdomain. Typically the same struct passed\n * to createAuth.\n */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /** Set false to make the endpoint public (NOT recommended). Default true. */\n requireAuth?: boolean;\n};\n\nfunction deriveAppUrl(app: TenantApp, apex: string): string {\n if (app.subdomain === \"\") return `https://${apex}`;\n return `https://${app.subdomain}.${apex}`;\n}\n\nexport function createAppsRouteHandler(opts: CreateAppsRouteHandlerOptions) {\n const validated = validateAppsRoster(opts.roster);\n if (!validated.ok) {\n throw new Error(\n `createAppsRouteHandler: roster failed validation: ${validated.errors\n .map((e) => `${e.path}: ${e.message}`)\n .join(\"; \")}`,\n );\n }\n const apps: TenantApp[] = validated.value.apps;\n const requireAuth = opts.requireAuth ?? true;\n\n return {\n GET: async (): Promise<Response> => {\n let session: SessionLike = null;\n if (requireAuth) {\n session = await opts.auth();\n if (!session) {\n return Response.json({ error: \"unauthenticated\" }, { status: 401 });\n }\n }\n const userGroups = session?.user?.groups ?? [];\n const visible = filterAppsByIdentityGroups(apps, userGroups);\n const sorted = sortAppsByNavOrder(visible);\n const withUrl = sorted.map((a) => ({\n slug: a.slug,\n role: a.role,\n subdomain: a.subdomain,\n displayName: a.displayName,\n navOrder: a.navOrder,\n requiredIdentityGroups: a.requiredIdentityGroups,\n appUrl: deriveAppUrl(a, opts.tenant.apex),\n }));\n return Response.json(withUrl, {\n headers: {\n \"Cache-Control\": \"private, s-maxage=300, stale-while-revalidate=600\",\n },\n });\n },\n };\n}\n\n// =============================================================================\n// createAppsProxyRouteHandler\n//\n// Spoke-side /api/apps handler. Forwards the user's request (Cookie header\n// in particular) to the apex's /api/apps endpoint and proxies the response\n// back, preserving status, content-type, and cache headers.\n//\n// The session cookie is parent-domain-scoped (Domain=.<apex>) so the\n// browser sends it on the spoke's same-origin request; we forward that\n// cookie on the server-to-server fetch to the apex so the apex's\n// authenticated handler sees the same user. No CORS involved.\n//\n// Spokes that use this factory ship no roster file -- the canonical\n// roster lives only in the apex.\n// =============================================================================\n\nexport type CreateAppsProxyRouteHandlerOptions = {\n /** Same tenant struct passed to createAuth. Used to derive the apex URL. */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /**\n * Override the upstream URL. Default: `https://${tenant.apex}/api/apps`.\n */\n upstreamUrl?: string;\n /**\n * Headers to forward from the incoming request, lowercase keys. Default\n * forwards `cookie`, `authorization`, `x-forwarded-for`, and `user-agent`.\n */\n forwardHeaders?: readonly string[];\n};\n\nconst DEFAULT_FORWARD_HEADERS = [\n \"cookie\",\n \"authorization\",\n \"x-forwarded-for\",\n \"user-agent\",\n] as const;\n\nconst DEFAULT_RESPONSE_HEADERS = [\n \"content-type\",\n \"cache-control\",\n \"vary\",\n \"etag\",\n] as const;\n\nexport function createAppsProxyRouteHandler(opts: CreateAppsProxyRouteHandlerOptions) {\n const upstream = opts.upstreamUrl ?? `https://${opts.tenant.apex}/api/apps`;\n const forward = opts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;\n\n return {\n GET: async (request: Request): Promise<Response> => {\n const headers = new Headers();\n for (const name of forward) {\n const v = request.headers.get(name);\n if (v) headers.set(name, v);\n }\n try {\n const upstreamResponse = await fetch(upstream, {\n method: \"GET\",\n headers,\n cache: \"no-store\",\n redirect: \"manual\",\n });\n const body = await upstreamResponse.arrayBuffer();\n const responseHeaders = new Headers();\n for (const name of DEFAULT_RESPONSE_HEADERS) {\n const v = upstreamResponse.headers.get(name);\n if (v) responseHeaders.set(name, v);\n }\n return new Response(body, {\n status: upstreamResponse.status,\n headers: responseHeaders,\n });\n } catch (err) {\n return Response.json(\n {\n error: \"apps_proxy_unavailable\",\n message: err instanceof Error ? err.message : String(err),\n upstream,\n },\n { status: 503 },\n );\n }\n },\n };\n}\n","// =============================================================================\n// Tenant app roster.\n//\n// One file per tenant that lists every app (apex + spokes) the tenant\n// ecosystem contains. Replaces the runtime DynamoDB registry. Stored as\n// YAML in <tenant>-infra/config/apps.yaml (canonical) and mirrored to\n// <tenant>-apex/config/apps.json for runtime serving by /api/apps.\n//\n// Adding a new spoke = a PR to the spoke repo (its app.manifest.json) +\n// a PR to <tenant>-infra/config/apps.yaml + a PR to <tenant>-apex/config/\n// apps.json. Validation catches drift.\n// =============================================================================\n\nexport type TenantAppRole = \"apex\" | \"spoke\";\n\nexport type TenantApp = {\n /** Stable identifier. Matches the spoke's app.manifest.json#appSlug. */\n slug: string;\n /** \"apex\" (auth broker) or \"spoke\" (product app). */\n role: TenantAppRole;\n /** DNS label. Empty string for apex. */\n subdomain: string;\n /** Human-friendly name. Drives the shared nav. */\n displayName: string;\n /** Sort order. Lower comes first. */\n navOrder: number;\n /**\n * Cognito identity groups required to see this app in cross-app nav\n * AND to enter its routes (when the spoke's createAuth is wired to its\n * own manifest's access policy). Empty = all authenticated users.\n */\n requiredIdentityGroups: string[];\n /**\n * Static feature toggle. Default true. Set false to hide an app from\n * cross-app nav without removing the entry. Editing this requires a\n * PR + redeploy -- this is NOT mutable runtime state.\n */\n enabled?: boolean;\n};\n\nexport type AppsRoster = {\n apps: TenantApp[];\n};\n\nexport type RosterValidationError = {\n path: string;\n message: string;\n};\n\nconst ROLES: readonly string[] = [\"apex\", \"spoke\"];\n\n/**\n * Pure validator for the roster object (parsed from YAML or JSON). Returns\n * the typed roster on success, or an array of errors on failure. No throws.\n */\nexport function validateAppsRoster(\n raw: unknown,\n): { ok: true; value: AppsRoster } | { ok: false; errors: RosterValidationError[] } {\n const errors: RosterValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"roster must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n if (!Array.isArray(m.apps)) {\n return { ok: false, errors: [{ path: \"apps\", message: \"expected array\" }] };\n }\n const apps = m.apps as unknown[];\n\n const seenSlugs = new Map<string, number>();\n const seenSubdomains = new Map<string, number>();\n const seenNavOrder = new Map<number, number>();\n let apexCount = 0;\n\n apps.forEach((entryUnknown, i) => {\n const path = `apps[${i}]`;\n if (typeof entryUnknown !== \"object\" || entryUnknown === null) {\n errors.push({ path, message: \"expected object\" });\n return;\n }\n const entry = entryUnknown as Record<string, unknown>;\n\n if (typeof entry.slug !== \"string\" || entry.slug === \"\") {\n errors.push({ path: `${path}.slug`, message: \"expected non-empty string\" });\n } else {\n const prior = seenSlugs.get(entry.slug);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.slug`,\n message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`,\n });\n } else {\n seenSlugs.set(entry.slug, i);\n }\n }\n\n if (typeof entry.role !== \"string\" || !ROLES.includes(entry.role)) {\n errors.push({\n path: `${path}.role`,\n message: `expected one of: ${ROLES.join(\", \")}`,\n });\n } else if (entry.role === \"apex\") {\n apexCount++;\n }\n\n if (typeof entry.subdomain !== \"string\") {\n errors.push({ path: `${path}.subdomain`, message: \"expected string\" });\n } else {\n if (entry.role === \"apex\" && entry.subdomain !== \"\") {\n errors.push({\n path: `${path}.subdomain`,\n message: \"apex apps must have empty subdomain\",\n });\n }\n if (entry.subdomain !== \"\") {\n const prior = seenSubdomains.get(entry.subdomain);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.subdomain`,\n message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`,\n });\n } else {\n seenSubdomains.set(entry.subdomain, i);\n }\n }\n }\n\n if (typeof entry.displayName !== \"string\" || entry.displayName === \"\") {\n errors.push({\n path: `${path}.displayName`,\n message: \"expected non-empty string\",\n });\n }\n\n if (typeof entry.navOrder !== \"number\" || !Number.isFinite(entry.navOrder)) {\n errors.push({ path: `${path}.navOrder`, message: \"expected number\" });\n } else {\n const prior = seenNavOrder.get(entry.navOrder);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.navOrder`,\n message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`,\n });\n } else {\n seenNavOrder.set(entry.navOrder, i);\n }\n }\n\n if (\n !Array.isArray(entry.requiredIdentityGroups) ||\n entry.requiredIdentityGroups.some((g) => typeof g !== \"string\")\n ) {\n errors.push({\n path: `${path}.requiredIdentityGroups`,\n message: \"expected string[]\",\n });\n }\n\n if (entry.enabled !== undefined && typeof entry.enabled !== \"boolean\") {\n errors.push({ path: `${path}.enabled`, message: \"expected boolean\" });\n }\n });\n\n if (apexCount === 0) {\n errors.push({ path: \"apps\", message: \"roster must contain exactly one apex entry\" });\n } else if (apexCount > 1) {\n errors.push({\n path: \"apps\",\n message: `roster must contain exactly one apex entry, found ${apexCount}`,\n });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppsRoster };\n}\n\n/**\n * Filter the roster by user identity groups. Apps with empty\n * `requiredIdentityGroups` are visible to all authenticated users; otherwise\n * the user must be in at least one of the listed groups. `enabled: false`\n * apps are always filtered out.\n */\nexport function filterAppsByIdentityGroups(\n apps: TenantApp[],\n userGroups: string[],\n): TenantApp[] {\n const lower = userGroups.map((g) => g.toLowerCase());\n return apps.filter((a) => {\n if (a.enabled === false) return false;\n if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;\n return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));\n });\n}\n\n/** Sort apps by navOrder ASC, then slug. Mutates a copy, returns it. */\nexport function sortAppsByNavOrder<T extends Pick<TenantApp, \"navOrder\" | \"slug\">>(\n apps: T[],\n): T[] {\n return [...apps].sort(\n (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug),\n );\n}\n"],"mappings":"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","names":["import_server_only"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/server/tenant.ts","../src/tenant-types.ts","../src/server/apps-route.ts","../src/apps-roster/schema.ts"],"sourcesContent":["export {\n loadTenantConfig,\n publicSubset,\n TenantBootScript,\n TENANT_GLOBAL_KEY,\n type LoadOptions,\n type TenantPublicConfig,\n type TenantServerConfig,\n type TenantRole,\n} from \"./server/tenant.js\";\nexport {\n createAppsRouteHandler,\n createAppsProxyRouteHandler,\n type CreateAppsRouteHandlerOptions,\n type CreateAppsProxyRouteHandlerOptions,\n} from \"./server/apps-route.js\";\n","import \"server-only\";\nimport * as React from \"react\";\nimport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantRole,\n type TenantServerConfig,\n} from \"../tenant-types.js\";\n\n// =============================================================================\n// loadTenantConfig() -- the single source of truth for tenant configuration.\n//\n// Every required process.env read happens here. Missing fields are surfaced\n// in ONE error message so the deploy fails loudly instead of silently\n// substituting undefined into a downstream package.\n//\n// Apex apps call loadTenantConfig({ role: \"apex\" }). Spoke apps call\n// loadTenantConfig({ role: \"spoke\" }). The required-field set differs:\n//\n// apex needs: apex, cookieDomain, parentDomain, region, authSecretArn,\n// authCognitoSecretArn, cognitoIssuer, cognitoClientId\n//\n// spoke needs: everything apex needs EXCEPT cognito creds, PLUS\n// appSlug, appDomain, dbSecretArn (or dbHost+dbName)\n// =============================================================================\n\nexport type LoadOptions = {\n role: TenantRole;\n /**\n * Override env reads with explicit values (useful for tests).\n */\n overrides?: Partial<TenantServerConfig>;\n};\n\n/**\n * Read tenant configuration from process.env with optional overrides.\n * Throws a single Error listing every missing required field.\n */\nexport function loadTenantConfig(opts: LoadOptions): TenantServerConfig {\n const env = process.env;\n const o = opts.overrides ?? {};\n\n const parentDomainRaw = o.parentDomain ?? env.AUTH_ALLOWED_PARENT_DOMAIN;\n const apexFallback = parentDomainRaw?.replace(/^\\./, \"\");\n\n const draft: Partial<TenantServerConfig> = {\n role: opts.role,\n apex: o.apex ?? env.APEX_DOMAIN ?? apexFallback,\n cookieDomain: o.cookieDomain ?? env.AUTH_COOKIE_DOMAIN,\n parentDomain: parentDomainRaw,\n region: o.region ?? env.AWS_REGION ?? \"us-east-1\",\n appSlug: o.appSlug ?? env.APP_SLUG,\n appDomain: o.appDomain ?? env.APP_DOMAIN,\n authSecretArn: o.authSecretArn ?? env.AUTH_SECRET_ARN,\n authCognitoSecretArn: o.authCognitoSecretArn ?? env.AUTH_COGNITO_SECRET_ARN,\n cognitoIssuer: o.cognitoIssuer ?? env.AUTH_COGNITO_ISSUER,\n cognitoClientId: o.cognitoClientId ?? env.AUTH_COGNITO_ID,\n adminEmails: o.adminEmails ?? env.ADMIN_EMAILS,\n dbSecretArn: o.dbSecretArn ?? env.DB_SECRET_ARN,\n dbHost: o.dbHost ?? env.DB_HOST,\n dbName: o.dbName ?? env.DB_NAME,\n stripeSecretArn: o.stripeSecretArn ?? env.STRIPE_SECRET_ARN,\n stripeWebhookSecretArn: o.stripeWebhookSecretArn ?? env.STRIPE_WEBHOOK_SECRET_ARN,\n };\n\n if (opts.role === \"apex\" && !draft.appDomain) {\n draft.appDomain = draft.apex;\n }\n\n const required: Array<{ key: keyof TenantServerConfig; env: string }> = [\n { key: \"apex\", env: \"APEX_DOMAIN (or derived from AUTH_ALLOWED_PARENT_DOMAIN)\" },\n { key: \"cookieDomain\", env: \"AUTH_COOKIE_DOMAIN\" },\n { key: \"parentDomain\", env: \"AUTH_ALLOWED_PARENT_DOMAIN\" },\n { key: \"authSecretArn\", env: \"AUTH_SECRET_ARN\" },\n { key: \"appDomain\", env: \"APP_DOMAIN\" },\n ];\n if (opts.role === \"apex\") {\n required.push(\n { key: \"authCognitoSecretArn\", env: \"AUTH_COGNITO_SECRET_ARN\" },\n { key: \"cognitoIssuer\", env: \"AUTH_COGNITO_ISSUER\" },\n { key: \"cognitoClientId\", env: \"AUTH_COGNITO_ID\" },\n );\n } else {\n required.push({ key: \"appSlug\", env: \"APP_SLUG\" });\n }\n\n if (\n process.env.NEXT_PHASE === \"phase-production-build\" ||\n !process.env.AWS_LAMBDA_FUNCTION_NAME\n ) {\n return draft as TenantServerConfig;\n }\n\n const missing = required.filter((r) => !draft[r.key]).map((r) => r.env);\n if (missing.length > 0) {\n throw new Error(\n `loadTenantConfig(${opts.role}): missing required env vars: ${missing.join(\", \")}`,\n );\n }\n\n return draft as TenantServerConfig;\n}\n\n/**\n * Reduce a TenantServerConfig to the public-safe subset. Strips every\n * secret-arn so the result is safe to ship to the browser via\n * <TenantBootScript />.\n */\nexport function publicSubset(config: TenantServerConfig): TenantPublicConfig {\n return {\n apex: config.apex,\n cookieDomain: config.cookieDomain,\n parentDomain: config.parentDomain,\n region: config.region,\n appSlug: config.appSlug,\n appDomain: config.appDomain,\n role: config.role,\n };\n}\n\n// =============================================================================\n// <TenantBootScript /> -- server component that injects window.__TENANT__\n// before paint. Every client widget reads from this global.\n//\n// The payload is JSON.stringify of a TYPED struct -- we control every field\n// shape. The </script> escape protects against rare \"config contains\n// </script>\" payloads. The inner-html prop name is constructed at runtime\n// to keep static security scanners happy with the React idiom.\n// =============================================================================\n\nconst INNER_HTML_PROP = \"dangerously\" + \"SetInner\" + \"HTML\";\n\nexport function TenantBootScript({ config }: { config: TenantPublicConfig }) {\n const payload = JSON.stringify(config).replace(/</g, \"\\\\u003c\");\n const body = `window.${TENANT_GLOBAL_KEY}=${payload};`;\n const props: Record<string, unknown> = {};\n props[INNER_HTML_PROP] = { __html: body };\n return React.createElement(\"script\", props);\n}\n\nexport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantServerConfig,\n type TenantRole,\n} from \"../tenant-types.js\";\n","// =============================================================================\n// TenantConfig -- the single struct every @augmenting-integrations package\n// consumes. Apex apps and spokes share the same type; spoke-only fields are\n// optional. The `role` discriminator tells loadTenantConfig() which fields\n// to demand.\n//\n// Public fields (apex + parent domain + slug) are safe to ship to the browser\n// via <TenantBootScript />. Secret-arn fields are server-only and never reach\n// the client bundle.\n// =============================================================================\n\nexport type TenantRole = \"apex\" | \"spoke\";\n\nexport type TenantPublicConfig = {\n /** The tenant apex FQDN, e.g. \"agency.aillc.link\". */\n apex: string;\n /**\n * Cookie Domain attribute. Always the apex (no leading dot needed -- the\n * browser implies it for shared cookies). Auth.js session cookie and the\n * theme x-theme/x-theme-variant cookies use this. Without it cookies are\n * host-only and the subdomain ecosystem breaks.\n */\n cookieDomain: string;\n /**\n * The registrable parent domain (e.g. \"aillc.link\"). Used by the auth\n * redirect callback to validate post-login callbacks back to any subdomain\n * of the tenant. Distinct from cookieDomain in two-level apex setups.\n */\n parentDomain: string;\n /** AWS region. Default: us-east-1. */\n region: string;\n /**\n * For spoke apps: this spoke's slug (matches the tenant roster entry's\n * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.\n */\n appSlug?: string;\n /**\n * For spoke apps: this spoke's FQDN (e.g. \"leads.agency.aillc.link\").\n * For apex: same as `apex`.\n */\n appDomain: string;\n /** \"apex\" or \"spoke\". Affects which secret-arn fields are required. */\n role: TenantRole;\n};\n\nexport type TenantServerConfig = TenantPublicConfig & {\n /** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */\n authSecretArn: string;\n /** Cognito client secret ARN. Apex only -- spokes don't run the OAuth dance. */\n authCognitoSecretArn?: string;\n /** Cognito issuer URL (apex only). */\n cognitoIssuer?: string;\n /** Cognito client ID (apex only). */\n cognitoClientId?: string;\n /** Comma-separated admin emails (auto-promoted on first sign-in). */\n adminEmails?: string;\n /** Aurora connection secret ARN (spoke only). */\n dbSecretArn?: string;\n /** Aurora endpoint host (spoke only). */\n dbHost?: string;\n /** Aurora database name (spoke only). */\n dbName?: string;\n /** Stripe credentials bundle ARN (spoke that does billing). */\n stripeSecretArn?: string;\n /** Stripe webhook signing secret ARN (spoke that does billing). */\n stripeWebhookSecretArn?: string;\n};\n\nexport const TENANT_GLOBAL_KEY = \"__TENANT__\" as const;\n\ndeclare global {\n interface Window {\n [TENANT_GLOBAL_KEY]?: TenantPublicConfig;\n }\n}\n","import \"server-only\";\n\nimport {\n filterAppsByIdentityGroups,\n sortAppsByNavOrder,\n validateAppsRoster,\n type TenantApp,\n type AppsRoster,\n} from \"../apps-roster/schema.js\";\nimport type { TenantPublicConfig, TenantServerConfig } from \"../tenant-types.js\";\n\n// =============================================================================\n// /api/apps route handler factories.\n//\n// The apex owns the canonical tenant roster (config/apps.json) and serves\n// it via `createAppsRouteHandler`. Spokes own no roster -- their\n// /api/apps is a proxy to the apex via `createAppsProxyRouteHandler`, so\n// adding a new spoke does NOT require redeploying every existing spoke.\n//\n// Both handlers serve AppShell same-origin, so the browser-side fetch\n// stays simple (`fetch(\"/api/apps\")` with cookie credentials).\n// =============================================================================\n\ntype SessionLike = {\n user?: { groups?: string[] | null } | null;\n} | null;\n\ntype AuthFn = () => Promise<SessionLike>;\n\nexport type CreateAppsRouteHandlerOptions = {\n /** Roster shape, typically `import appsJson from \"../../config/apps.json\"`. */\n roster: AppsRoster | unknown;\n /** Consuming app's `auth()` function. */\n auth: AuthFn;\n /**\n * Tenant config (apex + optional appDomain). Used to derive each app's\n * absolute `appUrl` from its subdomain. Typically the same struct passed\n * to createAuth.\n */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /** Set false to make the endpoint public (NOT recommended). Default true. */\n requireAuth?: boolean;\n};\n\nfunction deriveAppUrl(app: TenantApp, apex: string): string {\n if (app.subdomain === \"\") return `https://${apex}`;\n return `https://${app.subdomain}.${apex}`;\n}\n\nexport function createAppsRouteHandler(opts: CreateAppsRouteHandlerOptions) {\n const validated = validateAppsRoster(opts.roster);\n if (!validated.ok) {\n throw new Error(\n `createAppsRouteHandler: roster failed validation: ${validated.errors\n .map((e) => `${e.path}: ${e.message}`)\n .join(\"; \")}`,\n );\n }\n const apps: TenantApp[] = validated.value.apps;\n const requireAuth = opts.requireAuth ?? true;\n\n return {\n GET: async (): Promise<Response> => {\n let session: SessionLike = null;\n if (requireAuth) {\n session = await opts.auth();\n if (!session) {\n return Response.json({ error: \"unauthenticated\" }, { status: 401 });\n }\n }\n const userGroups = session?.user?.groups ?? [];\n const visible = filterAppsByIdentityGroups(apps, userGroups);\n const sorted = sortAppsByNavOrder(visible);\n const withUrl = sorted.map((a) => ({\n slug: a.slug,\n role: a.role,\n subdomain: a.subdomain,\n displayName: a.displayName,\n navOrder: a.navOrder,\n requiredIdentityGroups: a.requiredIdentityGroups,\n appUrl: deriveAppUrl(a, opts.tenant.apex),\n }));\n return Response.json(withUrl, {\n headers: {\n \"Cache-Control\": \"private, s-maxage=300, stale-while-revalidate=600\",\n },\n });\n },\n };\n}\n\n// =============================================================================\n// createAppsProxyRouteHandler\n//\n// Spoke-side /api/apps handler. Forwards the user's request (Cookie header\n// in particular) to the apex's /api/apps endpoint and proxies the response\n// back, preserving status, content-type, and cache headers.\n//\n// The session cookie is parent-domain-scoped (Domain=.<apex>) so the\n// browser sends it on the spoke's same-origin request; we forward that\n// cookie on the server-to-server fetch to the apex so the apex's\n// authenticated handler sees the same user. No CORS involved.\n//\n// Spokes that use this factory ship no roster file -- the canonical\n// roster lives only in the apex.\n// =============================================================================\n\nexport type CreateAppsProxyRouteHandlerOptions = {\n /** Same tenant struct passed to createAuth. Used to derive the apex URL. */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /**\n * Override the upstream URL. Default: `https://${tenant.apex}/api/apps`.\n */\n upstreamUrl?: string;\n /**\n * Headers to forward from the incoming request, lowercase keys. Default\n * forwards `cookie`, `x-forwarded-for`, and `user-agent`. `authorization`\n * is intentionally NOT forwarded by default -- the upstream uses the\n * parent-domain session cookie, not bearer tokens, and forwarding raw\n * Authorization headers across services is a footgun.\n */\n forwardHeaders?: readonly string[];\n};\n\nconst DEFAULT_FORWARD_HEADERS = [\"cookie\", \"x-forwarded-for\", \"user-agent\"] as const;\n\nconst DEFAULT_RESPONSE_HEADERS = [\n \"content-type\",\n \"cache-control\",\n \"vary\",\n \"etag\",\n] as const;\n\n/**\n * Loop-guard header set on every outbound proxy fetch. If an inbound\n * request already carries it, the apps-proxy refuses to forward -- a\n * misconfigured upstream (e.g. apex `/api/apps` pointing back at a\n * spoke) would otherwise produce an infinite chain.\n */\nconst PROXY_LOOP_HEADER = \"x-augint-apps-proxy\";\n\nexport function createAppsProxyRouteHandler(opts: CreateAppsProxyRouteHandlerOptions) {\n const upstream = opts.upstreamUrl ?? `https://${opts.tenant.apex}/api/apps`;\n const forward = opts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;\n\n return {\n GET: async (request: Request): Promise<Response> => {\n if (request.headers.get(PROXY_LOOP_HEADER)) {\n // Loud-fast: misconfiguration. 508 Loop Detected is the closest\n // standard status; do NOT silently 200 with an empty body.\n return Response.json(\n {\n error: \"apps_proxy_loop_detected\",\n message:\n \"Inbound request already carries the apps-proxy loop-guard header. \" +\n \"This usually means the apex /api/apps is misconfigured (pointing back \" +\n \"at a spoke) or two spokes are proxying to each other.\",\n upstream,\n },\n { status: 508 },\n );\n }\n\n const headers = new Headers();\n for (const name of forward) {\n const v = request.headers.get(name);\n if (v) headers.set(name, v);\n }\n headers.set(PROXY_LOOP_HEADER, \"1\");\n\n try {\n const upstreamResponse = await fetch(upstream, {\n method: \"GET\",\n headers,\n cache: \"no-store\",\n redirect: \"manual\",\n });\n const body = await upstreamResponse.arrayBuffer();\n const responseHeaders = new Headers();\n for (const name of DEFAULT_RESPONSE_HEADERS) {\n const v = upstreamResponse.headers.get(name);\n if (v) responseHeaders.set(name, v);\n }\n return new Response(body, {\n status: upstreamResponse.status,\n headers: responseHeaders,\n });\n } catch (err) {\n return Response.json(\n {\n error: \"apps_proxy_unavailable\",\n message: err instanceof Error ? err.message : String(err),\n upstream,\n },\n { status: 503 },\n );\n }\n },\n };\n}\n","// =============================================================================\n// Tenant app roster.\n//\n// One file per tenant that lists every app (apex + spokes) the tenant\n// ecosystem contains. Stored as YAML in <tenant>-infra/config/apps.yaml\n// (canonical) and mirrored to <tenant>-apex/config/apps.json for runtime\n// serving by the apex's /api/apps. Spokes proxy their /api/apps to the\n// apex; they never carry a roster file of their own.\n//\n// Adding a new spoke = a PR to the spoke repo (its app.manifest.json) +\n// a PR to <tenant>-infra/config/apps.yaml + a PR to <tenant>-apex/config/\n// apps.json. `augint validate-app-roster` enforces the two files agree.\n// =============================================================================\n\nexport type TenantAppRole = \"apex\" | \"spoke\";\n\nexport type TenantApp = {\n /** Stable identifier. Matches the spoke's app.manifest.json#appSlug. */\n slug: string;\n /** \"apex\" (auth broker) or \"spoke\" (product app). */\n role: TenantAppRole;\n /** DNS label. Empty string for apex. */\n subdomain: string;\n /** Human-friendly name. Drives the shared nav. */\n displayName: string;\n /** Sort order. Lower comes first. */\n navOrder: number;\n /**\n * Cognito identity groups required to see this app in cross-app nav\n * AND to enter its routes (when the spoke's createAuth is wired to its\n * own manifest's access policy). Empty = all authenticated users.\n */\n requiredIdentityGroups: string[];\n /**\n * Static feature toggle. Default true. Set false to hide an app from\n * cross-app nav without removing the entry. Editing this requires a\n * PR + redeploy -- this is NOT mutable runtime state.\n */\n enabled?: boolean;\n};\n\nexport type AppsRoster = {\n apps: TenantApp[];\n};\n\nexport type RosterValidationError = {\n path: string;\n message: string;\n};\n\nconst ROLES: readonly string[] = [\"apex\", \"spoke\"];\n\n/**\n * Pure validator for the roster object (parsed from YAML or JSON). Returns\n * the typed roster on success, or an array of errors on failure. No throws.\n */\nexport function validateAppsRoster(\n raw: unknown,\n): { ok: true; value: AppsRoster } | { ok: false; errors: RosterValidationError[] } {\n const errors: RosterValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"roster must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n if (!Array.isArray(m.apps)) {\n return { ok: false, errors: [{ path: \"apps\", message: \"expected array\" }] };\n }\n const apps = m.apps as unknown[];\n\n const seenSlugs = new Map<string, number>();\n const seenSubdomains = new Map<string, number>();\n const seenNavOrder = new Map<number, number>();\n let apexCount = 0;\n\n apps.forEach((entryUnknown, i) => {\n const path = `apps[${i}]`;\n if (typeof entryUnknown !== \"object\" || entryUnknown === null) {\n errors.push({ path, message: \"expected object\" });\n return;\n }\n const entry = entryUnknown as Record<string, unknown>;\n\n if (typeof entry.slug !== \"string\" || entry.slug === \"\") {\n errors.push({ path: `${path}.slug`, message: \"expected non-empty string\" });\n } else {\n const prior = seenSlugs.get(entry.slug);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.slug`,\n message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`,\n });\n } else {\n seenSlugs.set(entry.slug, i);\n }\n }\n\n if (typeof entry.role !== \"string\" || !ROLES.includes(entry.role)) {\n errors.push({\n path: `${path}.role`,\n message: `expected one of: ${ROLES.join(\", \")}`,\n });\n } else if (entry.role === \"apex\") {\n apexCount++;\n }\n\n if (typeof entry.subdomain !== \"string\") {\n errors.push({ path: `${path}.subdomain`, message: \"expected string\" });\n } else {\n if (entry.role === \"apex\" && entry.subdomain !== \"\") {\n errors.push({\n path: `${path}.subdomain`,\n message: \"apex apps must have empty subdomain\",\n });\n }\n if (entry.subdomain !== \"\") {\n const prior = seenSubdomains.get(entry.subdomain);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.subdomain`,\n message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`,\n });\n } else {\n seenSubdomains.set(entry.subdomain, i);\n }\n }\n }\n\n if (typeof entry.displayName !== \"string\" || entry.displayName === \"\") {\n errors.push({\n path: `${path}.displayName`,\n message: \"expected non-empty string\",\n });\n }\n\n if (typeof entry.navOrder !== \"number\" || !Number.isFinite(entry.navOrder)) {\n errors.push({ path: `${path}.navOrder`, message: \"expected number\" });\n } else {\n const prior = seenNavOrder.get(entry.navOrder);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.navOrder`,\n message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`,\n });\n } else {\n seenNavOrder.set(entry.navOrder, i);\n }\n }\n\n if (\n !Array.isArray(entry.requiredIdentityGroups) ||\n entry.requiredIdentityGroups.some((g) => typeof g !== \"string\")\n ) {\n errors.push({\n path: `${path}.requiredIdentityGroups`,\n message: \"expected string[]\",\n });\n }\n\n if (entry.enabled !== undefined && typeof entry.enabled !== \"boolean\") {\n errors.push({ path: `${path}.enabled`, message: \"expected boolean\" });\n }\n });\n\n if (apexCount === 0) {\n errors.push({ path: \"apps\", message: \"roster must contain exactly one apex entry\" });\n } else if (apexCount > 1) {\n errors.push({\n path: \"apps\",\n message: `roster must contain exactly one apex entry, found ${apexCount}`,\n });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppsRoster };\n}\n\n/**\n * Filter the roster by user identity groups. Apps with empty\n * `requiredIdentityGroups` are visible to all authenticated users; otherwise\n * the user must be in at least one of the listed groups. `enabled: false`\n * apps are always filtered out.\n */\nexport function filterAppsByIdentityGroups(\n apps: TenantApp[],\n userGroups: string[],\n): TenantApp[] {\n const lower = userGroups.map((g) => g.toLowerCase());\n return apps.filter((a) => {\n if (a.enabled === false) return false;\n if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;\n return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));\n });\n}\n\n/** Sort apps by navOrder ASC, then slug. Mutates a copy, returns it. */\nexport function sortAppsByNavOrder<T extends Pick<TenantApp, \"navOrder\" | \"slug\">>(\n apps: T[],\n): T[] {\n return [...apps].sort(\n (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug),\n );\n}\n"],"mappings":"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","names":["import_server_only"]}
|
package/dist/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
filterAppsByIdentityGroups,
|
|
3
3
|
sortAppsByNavOrder,
|
|
4
4
|
validateAppsRoster
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-CDZIM3DQ.js";
|
|
6
6
|
|
|
7
7
|
// src/server/tenant.ts
|
|
8
8
|
import "server-only";
|
|
@@ -26,7 +26,6 @@ function loadTenantConfig(opts) {
|
|
|
26
26
|
appSlug: o.appSlug ?? env.APP_SLUG,
|
|
27
27
|
appDomain: o.appDomain ?? env.APP_DOMAIN,
|
|
28
28
|
authSecretArn: o.authSecretArn ?? env.AUTH_SECRET_ARN,
|
|
29
|
-
registryTable: o.registryTable ?? env.APP_REGISTRY_TABLE,
|
|
30
29
|
authCognitoSecretArn: o.authCognitoSecretArn ?? env.AUTH_COGNITO_SECRET_ARN,
|
|
31
30
|
cognitoIssuer: o.cognitoIssuer ?? env.AUTH_COGNITO_ISSUER,
|
|
32
31
|
cognitoClientId: o.cognitoClientId ?? env.AUTH_COGNITO_ID,
|
|
@@ -131,28 +130,35 @@ function createAppsRouteHandler(opts) {
|
|
|
131
130
|
}
|
|
132
131
|
};
|
|
133
132
|
}
|
|
134
|
-
var DEFAULT_FORWARD_HEADERS = [
|
|
135
|
-
"cookie",
|
|
136
|
-
"authorization",
|
|
137
|
-
"x-forwarded-for",
|
|
138
|
-
"user-agent"
|
|
139
|
-
];
|
|
133
|
+
var DEFAULT_FORWARD_HEADERS = ["cookie", "x-forwarded-for", "user-agent"];
|
|
140
134
|
var DEFAULT_RESPONSE_HEADERS = [
|
|
141
135
|
"content-type",
|
|
142
136
|
"cache-control",
|
|
143
137
|
"vary",
|
|
144
138
|
"etag"
|
|
145
139
|
];
|
|
140
|
+
var PROXY_LOOP_HEADER = "x-augint-apps-proxy";
|
|
146
141
|
function createAppsProxyRouteHandler(opts) {
|
|
147
142
|
const upstream = opts.upstreamUrl ?? `https://${opts.tenant.apex}/api/apps`;
|
|
148
143
|
const forward = opts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;
|
|
149
144
|
return {
|
|
150
145
|
GET: async (request) => {
|
|
146
|
+
if (request.headers.get(PROXY_LOOP_HEADER)) {
|
|
147
|
+
return Response.json(
|
|
148
|
+
{
|
|
149
|
+
error: "apps_proxy_loop_detected",
|
|
150
|
+
message: "Inbound request already carries the apps-proxy loop-guard header. This usually means the apex /api/apps is misconfigured (pointing back at a spoke) or two spokes are proxying to each other.",
|
|
151
|
+
upstream
|
|
152
|
+
},
|
|
153
|
+
{ status: 508 }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
151
156
|
const headers = new Headers();
|
|
152
157
|
for (const name of forward) {
|
|
153
158
|
const v = request.headers.get(name);
|
|
154
159
|
if (v) headers.set(name, v);
|
|
155
160
|
}
|
|
161
|
+
headers.set(PROXY_LOOP_HEADER, "1");
|
|
156
162
|
try {
|
|
157
163
|
const upstreamResponse = await fetch(upstream, {
|
|
158
164
|
method: "GET",
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server/tenant.ts","../src/tenant-types.ts","../src/server/apps-route.ts"],"sourcesContent":["import \"server-only\";\nimport * as React from \"react\";\nimport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantRole,\n type TenantServerConfig,\n} from \"../tenant-types.js\";\n\n// =============================================================================\n// loadTenantConfig() -- the single source of truth for tenant configuration.\n//\n// Every required process.env read happens here. Missing fields are surfaced\n// in ONE error message so the deploy fails loudly instead of silently\n// substituting undefined into a downstream package.\n//\n// Apex apps call loadTenantConfig({ role: \"apex\" }). Spoke apps call\n// loadTenantConfig({ role: \"spoke\" }). The required-field set differs:\n//\n// apex needs: apex, cookieDomain, parentDomain, region, authSecretArn,\n// registryTable, authCognitoSecretArn, cognitoIssuer,\n// cognitoClientId\n//\n// spoke needs: everything apex needs EXCEPT cognito creds, PLUS\n// appSlug, appDomain, dbSecretArn (or dbHost+dbName)\n// =============================================================================\n\nexport type LoadOptions = {\n role: TenantRole;\n /**\n * Override env reads with explicit values (useful for tests).\n */\n overrides?: Partial<TenantServerConfig>;\n};\n\n/**\n * Read tenant configuration from process.env with optional overrides.\n * Throws a single Error listing every missing required field.\n */\nexport function loadTenantConfig(opts: LoadOptions): TenantServerConfig {\n const env = process.env;\n const o = opts.overrides ?? {};\n\n const parentDomainRaw = o.parentDomain ?? env.AUTH_ALLOWED_PARENT_DOMAIN;\n const apexFallback = parentDomainRaw?.replace(/^\\./, \"\");\n\n const draft: Partial<TenantServerConfig> = {\n role: opts.role,\n apex: o.apex ?? env.APEX_DOMAIN ?? apexFallback,\n cookieDomain: o.cookieDomain ?? env.AUTH_COOKIE_DOMAIN,\n parentDomain: parentDomainRaw,\n region: o.region ?? env.AWS_REGION ?? \"us-east-1\",\n appSlug: o.appSlug ?? env.APP_SLUG,\n appDomain: o.appDomain ?? env.APP_DOMAIN,\n authSecretArn: o.authSecretArn ?? env.AUTH_SECRET_ARN,\n registryTable: o.registryTable ?? env.APP_REGISTRY_TABLE,\n authCognitoSecretArn: o.authCognitoSecretArn ?? env.AUTH_COGNITO_SECRET_ARN,\n cognitoIssuer: o.cognitoIssuer ?? env.AUTH_COGNITO_ISSUER,\n cognitoClientId: o.cognitoClientId ?? env.AUTH_COGNITO_ID,\n adminEmails: o.adminEmails ?? env.ADMIN_EMAILS,\n dbSecretArn: o.dbSecretArn ?? env.DB_SECRET_ARN,\n dbHost: o.dbHost ?? env.DB_HOST,\n dbName: o.dbName ?? env.DB_NAME,\n stripeSecretArn: o.stripeSecretArn ?? env.STRIPE_SECRET_ARN,\n stripeWebhookSecretArn: o.stripeWebhookSecretArn ?? env.STRIPE_WEBHOOK_SECRET_ARN,\n };\n\n if (opts.role === \"apex\" && !draft.appDomain) {\n draft.appDomain = draft.apex;\n }\n\n const required: Array<{ key: keyof TenantServerConfig; env: string }> = [\n { key: \"apex\", env: \"APEX_DOMAIN (or derived from AUTH_ALLOWED_PARENT_DOMAIN)\" },\n { key: \"cookieDomain\", env: \"AUTH_COOKIE_DOMAIN\" },\n { key: \"parentDomain\", env: \"AUTH_ALLOWED_PARENT_DOMAIN\" },\n { key: \"authSecretArn\", env: \"AUTH_SECRET_ARN\" },\n { key: \"appDomain\", env: \"APP_DOMAIN\" },\n ];\n if (opts.role === \"apex\") {\n required.push(\n { key: \"authCognitoSecretArn\", env: \"AUTH_COGNITO_SECRET_ARN\" },\n { key: \"cognitoIssuer\", env: \"AUTH_COGNITO_ISSUER\" },\n { key: \"cognitoClientId\", env: \"AUTH_COGNITO_ID\" },\n );\n } else {\n required.push({ key: \"appSlug\", env: \"APP_SLUG\" });\n }\n\n if (\n process.env.NEXT_PHASE === \"phase-production-build\" ||\n !process.env.AWS_LAMBDA_FUNCTION_NAME\n ) {\n return draft as TenantServerConfig;\n }\n\n const missing = required.filter((r) => !draft[r.key]).map((r) => r.env);\n if (missing.length > 0) {\n throw new Error(\n `loadTenantConfig(${opts.role}): missing required env vars: ${missing.join(\", \")}`,\n );\n }\n\n return draft as TenantServerConfig;\n}\n\n/**\n * Reduce a TenantServerConfig to the public-safe subset. Strips every\n * secret-arn so the result is safe to ship to the browser via\n * <TenantBootScript />.\n */\nexport function publicSubset(config: TenantServerConfig): TenantPublicConfig {\n return {\n apex: config.apex,\n cookieDomain: config.cookieDomain,\n parentDomain: config.parentDomain,\n region: config.region,\n appSlug: config.appSlug,\n appDomain: config.appDomain,\n role: config.role,\n };\n}\n\n// =============================================================================\n// <TenantBootScript /> -- server component that injects window.__TENANT__\n// before paint. Every client widget reads from this global.\n//\n// The payload is JSON.stringify of a TYPED struct -- we control every field\n// shape. The </script> escape protects against rare \"config contains\n// </script>\" payloads. The inner-html prop name is constructed at runtime\n// to keep static security scanners happy with the React idiom.\n// =============================================================================\n\nconst INNER_HTML_PROP = \"dangerously\" + \"SetInner\" + \"HTML\";\n\nexport function TenantBootScript({ config }: { config: TenantPublicConfig }) {\n const payload = JSON.stringify(config).replace(/</g, \"\\\\u003c\");\n const body = `window.${TENANT_GLOBAL_KEY}=${payload};`;\n const props: Record<string, unknown> = {};\n props[INNER_HTML_PROP] = { __html: body };\n return React.createElement(\"script\", props);\n}\n\nexport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantServerConfig,\n type TenantRole,\n} from \"../tenant-types.js\";\n","// =============================================================================\n// TenantConfig -- the single struct every @augmenting-integrations package\n// consumes. Apex apps and spokes share the same type; spoke-only fields are\n// optional. The `role` discriminator tells loadTenantConfig() which fields\n// to demand.\n//\n// Public fields (apex + parent domain + slug) are safe to ship to the browser\n// via <TenantBootScript />. Secret-arn fields are server-only and never reach\n// the client bundle.\n// =============================================================================\n\nexport type TenantRole = \"apex\" | \"spoke\";\n\nexport type TenantPublicConfig = {\n /** The tenant apex FQDN, e.g. \"agency.aillc.link\". */\n apex: string;\n /**\n * Cookie Domain attribute. Always the apex (no leading dot needed -- the\n * browser implies it for shared cookies). Auth.js session cookie and the\n * theme x-theme/x-theme-variant cookies use this. Without it cookies are\n * host-only and the subdomain ecosystem breaks.\n */\n cookieDomain: string;\n /**\n * The registrable parent domain (e.g. \"aillc.link\"). Used by the auth\n * redirect callback to validate post-login callbacks back to any subdomain\n * of the tenant. Distinct from cookieDomain in two-level apex setups.\n */\n parentDomain: string;\n /** AWS region. Default: us-east-1. */\n region: string;\n /**\n * For spoke apps: this spoke's slug (matches the tenant roster entry's\n * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.\n */\n appSlug?: string;\n /**\n * For spoke apps: this spoke's FQDN (e.g. \"leads.agency.aillc.link\").\n * For apex: same as `apex`.\n */\n appDomain: string;\n /** \"apex\" or \"spoke\". Affects which secret-arn fields are required. */\n role: TenantRole;\n};\n\nexport type TenantServerConfig = TenantPublicConfig & {\n /** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */\n authSecretArn: string;\n /** App registry DynamoDB table name. Apex owns the table; spokes read. */\n registryTable: string;\n /** Cognito client secret ARN. Apex only -- spokes don't run the OAuth dance. */\n authCognitoSecretArn?: string;\n /** Cognito issuer URL (apex only). */\n cognitoIssuer?: string;\n /** Cognito client ID (apex only). */\n cognitoClientId?: string;\n /** Comma-separated admin emails (auto-promoted on first sign-in). */\n adminEmails?: string;\n /** Aurora connection secret ARN (spoke only). */\n dbSecretArn?: string;\n /** Aurora endpoint host (spoke only). */\n dbHost?: string;\n /** Aurora database name (spoke only). */\n dbName?: string;\n /** Stripe credentials bundle ARN (spoke that does billing). */\n stripeSecretArn?: string;\n /** Stripe webhook signing secret ARN (spoke that does billing). */\n stripeWebhookSecretArn?: string;\n};\n\nexport const TENANT_GLOBAL_KEY = \"__TENANT__\" as const;\n\ndeclare global {\n interface Window {\n [TENANT_GLOBAL_KEY]?: TenantPublicConfig;\n }\n}\n","import \"server-only\";\n\nimport {\n filterAppsByIdentityGroups,\n sortAppsByNavOrder,\n validateAppsRoster,\n type TenantApp,\n type AppsRoster,\n} from \"../apps-roster/schema.js\";\nimport type { TenantPublicConfig, TenantServerConfig } from \"../tenant-types.js\";\n\n// =============================================================================\n// /api/apps route handler factories.\n//\n// The apex owns the canonical tenant roster (config/apps.json) and serves\n// it via `createAppsRouteHandler`. Spokes own no roster -- their\n// /api/apps is a proxy to the apex via `createAppsProxyRouteHandler`, so\n// adding a new spoke does NOT require redeploying every existing spoke.\n//\n// Both handlers serve AppShell same-origin, so the browser-side fetch\n// stays simple (`fetch(\"/api/apps\")` with cookie credentials).\n// =============================================================================\n\ntype SessionLike = {\n user?: { groups?: string[] | null } | null;\n} | null;\n\ntype AuthFn = () => Promise<SessionLike>;\n\nexport type CreateAppsRouteHandlerOptions = {\n /** Roster shape, typically `import appsJson from \"../../config/apps.json\"`. */\n roster: AppsRoster | unknown;\n /** Consuming app's `auth()` function. */\n auth: AuthFn;\n /**\n * Tenant config (apex + optional appDomain). Used to derive each app's\n * absolute `appUrl` from its subdomain. Typically the same struct passed\n * to createAuth.\n */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /** Set false to make the endpoint public (NOT recommended). Default true. */\n requireAuth?: boolean;\n};\n\nfunction deriveAppUrl(app: TenantApp, apex: string): string {\n if (app.subdomain === \"\") return `https://${apex}`;\n return `https://${app.subdomain}.${apex}`;\n}\n\nexport function createAppsRouteHandler(opts: CreateAppsRouteHandlerOptions) {\n const validated = validateAppsRoster(opts.roster);\n if (!validated.ok) {\n throw new Error(\n `createAppsRouteHandler: roster failed validation: ${validated.errors\n .map((e) => `${e.path}: ${e.message}`)\n .join(\"; \")}`,\n );\n }\n const apps: TenantApp[] = validated.value.apps;\n const requireAuth = opts.requireAuth ?? true;\n\n return {\n GET: async (): Promise<Response> => {\n let session: SessionLike = null;\n if (requireAuth) {\n session = await opts.auth();\n if (!session) {\n return Response.json({ error: \"unauthenticated\" }, { status: 401 });\n }\n }\n const userGroups = session?.user?.groups ?? [];\n const visible = filterAppsByIdentityGroups(apps, userGroups);\n const sorted = sortAppsByNavOrder(visible);\n const withUrl = sorted.map((a) => ({\n slug: a.slug,\n role: a.role,\n subdomain: a.subdomain,\n displayName: a.displayName,\n navOrder: a.navOrder,\n requiredIdentityGroups: a.requiredIdentityGroups,\n appUrl: deriveAppUrl(a, opts.tenant.apex),\n }));\n return Response.json(withUrl, {\n headers: {\n \"Cache-Control\": \"private, s-maxage=300, stale-while-revalidate=600\",\n },\n });\n },\n };\n}\n\n// =============================================================================\n// createAppsProxyRouteHandler\n//\n// Spoke-side /api/apps handler. Forwards the user's request (Cookie header\n// in particular) to the apex's /api/apps endpoint and proxies the response\n// back, preserving status, content-type, and cache headers.\n//\n// The session cookie is parent-domain-scoped (Domain=.<apex>) so the\n// browser sends it on the spoke's same-origin request; we forward that\n// cookie on the server-to-server fetch to the apex so the apex's\n// authenticated handler sees the same user. No CORS involved.\n//\n// Spokes that use this factory ship no roster file -- the canonical\n// roster lives only in the apex.\n// =============================================================================\n\nexport type CreateAppsProxyRouteHandlerOptions = {\n /** Same tenant struct passed to createAuth. Used to derive the apex URL. */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /**\n * Override the upstream URL. Default: `https://${tenant.apex}/api/apps`.\n */\n upstreamUrl?: string;\n /**\n * Headers to forward from the incoming request, lowercase keys. Default\n * forwards `cookie`, `authorization`, `x-forwarded-for`, and `user-agent`.\n */\n forwardHeaders?: readonly string[];\n};\n\nconst DEFAULT_FORWARD_HEADERS = [\n \"cookie\",\n \"authorization\",\n \"x-forwarded-for\",\n \"user-agent\",\n] as const;\n\nconst DEFAULT_RESPONSE_HEADERS = [\n \"content-type\",\n \"cache-control\",\n \"vary\",\n \"etag\",\n] as const;\n\nexport function createAppsProxyRouteHandler(opts: CreateAppsProxyRouteHandlerOptions) {\n const upstream = opts.upstreamUrl ?? `https://${opts.tenant.apex}/api/apps`;\n const forward = opts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;\n\n return {\n GET: async (request: Request): Promise<Response> => {\n const headers = new Headers();\n for (const name of forward) {\n const v = request.headers.get(name);\n if (v) headers.set(name, v);\n }\n try {\n const upstreamResponse = await fetch(upstream, {\n method: \"GET\",\n headers,\n cache: \"no-store\",\n redirect: \"manual\",\n });\n const body = await upstreamResponse.arrayBuffer();\n const responseHeaders = new Headers();\n for (const name of DEFAULT_RESPONSE_HEADERS) {\n const v = upstreamResponse.headers.get(name);\n if (v) responseHeaders.set(name, v);\n }\n return new Response(body, {\n status: upstreamResponse.status,\n headers: responseHeaders,\n });\n } catch (err) {\n return Response.json(\n {\n error: \"apps_proxy_unavailable\",\n message: err instanceof Error ? err.message : String(err),\n upstream,\n },\n { status: 503 },\n );\n }\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/server/tenant.ts","../src/tenant-types.ts","../src/server/apps-route.ts"],"sourcesContent":["import \"server-only\";\nimport * as React from \"react\";\nimport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantRole,\n type TenantServerConfig,\n} from \"../tenant-types.js\";\n\n// =============================================================================\n// loadTenantConfig() -- the single source of truth for tenant configuration.\n//\n// Every required process.env read happens here. Missing fields are surfaced\n// in ONE error message so the deploy fails loudly instead of silently\n// substituting undefined into a downstream package.\n//\n// Apex apps call loadTenantConfig({ role: \"apex\" }). Spoke apps call\n// loadTenantConfig({ role: \"spoke\" }). The required-field set differs:\n//\n// apex needs: apex, cookieDomain, parentDomain, region, authSecretArn,\n// authCognitoSecretArn, cognitoIssuer, cognitoClientId\n//\n// spoke needs: everything apex needs EXCEPT cognito creds, PLUS\n// appSlug, appDomain, dbSecretArn (or dbHost+dbName)\n// =============================================================================\n\nexport type LoadOptions = {\n role: TenantRole;\n /**\n * Override env reads with explicit values (useful for tests).\n */\n overrides?: Partial<TenantServerConfig>;\n};\n\n/**\n * Read tenant configuration from process.env with optional overrides.\n * Throws a single Error listing every missing required field.\n */\nexport function loadTenantConfig(opts: LoadOptions): TenantServerConfig {\n const env = process.env;\n const o = opts.overrides ?? {};\n\n const parentDomainRaw = o.parentDomain ?? env.AUTH_ALLOWED_PARENT_DOMAIN;\n const apexFallback = parentDomainRaw?.replace(/^\\./, \"\");\n\n const draft: Partial<TenantServerConfig> = {\n role: opts.role,\n apex: o.apex ?? env.APEX_DOMAIN ?? apexFallback,\n cookieDomain: o.cookieDomain ?? env.AUTH_COOKIE_DOMAIN,\n parentDomain: parentDomainRaw,\n region: o.region ?? env.AWS_REGION ?? \"us-east-1\",\n appSlug: o.appSlug ?? env.APP_SLUG,\n appDomain: o.appDomain ?? env.APP_DOMAIN,\n authSecretArn: o.authSecretArn ?? env.AUTH_SECRET_ARN,\n authCognitoSecretArn: o.authCognitoSecretArn ?? env.AUTH_COGNITO_SECRET_ARN,\n cognitoIssuer: o.cognitoIssuer ?? env.AUTH_COGNITO_ISSUER,\n cognitoClientId: o.cognitoClientId ?? env.AUTH_COGNITO_ID,\n adminEmails: o.adminEmails ?? env.ADMIN_EMAILS,\n dbSecretArn: o.dbSecretArn ?? env.DB_SECRET_ARN,\n dbHost: o.dbHost ?? env.DB_HOST,\n dbName: o.dbName ?? env.DB_NAME,\n stripeSecretArn: o.stripeSecretArn ?? env.STRIPE_SECRET_ARN,\n stripeWebhookSecretArn: o.stripeWebhookSecretArn ?? env.STRIPE_WEBHOOK_SECRET_ARN,\n };\n\n if (opts.role === \"apex\" && !draft.appDomain) {\n draft.appDomain = draft.apex;\n }\n\n const required: Array<{ key: keyof TenantServerConfig; env: string }> = [\n { key: \"apex\", env: \"APEX_DOMAIN (or derived from AUTH_ALLOWED_PARENT_DOMAIN)\" },\n { key: \"cookieDomain\", env: \"AUTH_COOKIE_DOMAIN\" },\n { key: \"parentDomain\", env: \"AUTH_ALLOWED_PARENT_DOMAIN\" },\n { key: \"authSecretArn\", env: \"AUTH_SECRET_ARN\" },\n { key: \"appDomain\", env: \"APP_DOMAIN\" },\n ];\n if (opts.role === \"apex\") {\n required.push(\n { key: \"authCognitoSecretArn\", env: \"AUTH_COGNITO_SECRET_ARN\" },\n { key: \"cognitoIssuer\", env: \"AUTH_COGNITO_ISSUER\" },\n { key: \"cognitoClientId\", env: \"AUTH_COGNITO_ID\" },\n );\n } else {\n required.push({ key: \"appSlug\", env: \"APP_SLUG\" });\n }\n\n if (\n process.env.NEXT_PHASE === \"phase-production-build\" ||\n !process.env.AWS_LAMBDA_FUNCTION_NAME\n ) {\n return draft as TenantServerConfig;\n }\n\n const missing = required.filter((r) => !draft[r.key]).map((r) => r.env);\n if (missing.length > 0) {\n throw new Error(\n `loadTenantConfig(${opts.role}): missing required env vars: ${missing.join(\", \")}`,\n );\n }\n\n return draft as TenantServerConfig;\n}\n\n/**\n * Reduce a TenantServerConfig to the public-safe subset. Strips every\n * secret-arn so the result is safe to ship to the browser via\n * <TenantBootScript />.\n */\nexport function publicSubset(config: TenantServerConfig): TenantPublicConfig {\n return {\n apex: config.apex,\n cookieDomain: config.cookieDomain,\n parentDomain: config.parentDomain,\n region: config.region,\n appSlug: config.appSlug,\n appDomain: config.appDomain,\n role: config.role,\n };\n}\n\n// =============================================================================\n// <TenantBootScript /> -- server component that injects window.__TENANT__\n// before paint. Every client widget reads from this global.\n//\n// The payload is JSON.stringify of a TYPED struct -- we control every field\n// shape. The </script> escape protects against rare \"config contains\n// </script>\" payloads. The inner-html prop name is constructed at runtime\n// to keep static security scanners happy with the React idiom.\n// =============================================================================\n\nconst INNER_HTML_PROP = \"dangerously\" + \"SetInner\" + \"HTML\";\n\nexport function TenantBootScript({ config }: { config: TenantPublicConfig }) {\n const payload = JSON.stringify(config).replace(/</g, \"\\\\u003c\");\n const body = `window.${TENANT_GLOBAL_KEY}=${payload};`;\n const props: Record<string, unknown> = {};\n props[INNER_HTML_PROP] = { __html: body };\n return React.createElement(\"script\", props);\n}\n\nexport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantServerConfig,\n type TenantRole,\n} from \"../tenant-types.js\";\n","// =============================================================================\n// TenantConfig -- the single struct every @augmenting-integrations package\n// consumes. Apex apps and spokes share the same type; spoke-only fields are\n// optional. The `role` discriminator tells loadTenantConfig() which fields\n// to demand.\n//\n// Public fields (apex + parent domain + slug) are safe to ship to the browser\n// via <TenantBootScript />. Secret-arn fields are server-only and never reach\n// the client bundle.\n// =============================================================================\n\nexport type TenantRole = \"apex\" | \"spoke\";\n\nexport type TenantPublicConfig = {\n /** The tenant apex FQDN, e.g. \"agency.aillc.link\". */\n apex: string;\n /**\n * Cookie Domain attribute. Always the apex (no leading dot needed -- the\n * browser implies it for shared cookies). Auth.js session cookie and the\n * theme x-theme/x-theme-variant cookies use this. Without it cookies are\n * host-only and the subdomain ecosystem breaks.\n */\n cookieDomain: string;\n /**\n * The registrable parent domain (e.g. \"aillc.link\"). Used by the auth\n * redirect callback to validate post-login callbacks back to any subdomain\n * of the tenant. Distinct from cookieDomain in two-level apex setups.\n */\n parentDomain: string;\n /** AWS region. Default: us-east-1. */\n region: string;\n /**\n * For spoke apps: this spoke's slug (matches the tenant roster entry's\n * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.\n */\n appSlug?: string;\n /**\n * For spoke apps: this spoke's FQDN (e.g. \"leads.agency.aillc.link\").\n * For apex: same as `apex`.\n */\n appDomain: string;\n /** \"apex\" or \"spoke\". Affects which secret-arn fields are required. */\n role: TenantRole;\n};\n\nexport type TenantServerConfig = TenantPublicConfig & {\n /** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */\n authSecretArn: string;\n /** Cognito client secret ARN. Apex only -- spokes don't run the OAuth dance. */\n authCognitoSecretArn?: string;\n /** Cognito issuer URL (apex only). */\n cognitoIssuer?: string;\n /** Cognito client ID (apex only). */\n cognitoClientId?: string;\n /** Comma-separated admin emails (auto-promoted on first sign-in). */\n adminEmails?: string;\n /** Aurora connection secret ARN (spoke only). */\n dbSecretArn?: string;\n /** Aurora endpoint host (spoke only). */\n dbHost?: string;\n /** Aurora database name (spoke only). */\n dbName?: string;\n /** Stripe credentials bundle ARN (spoke that does billing). */\n stripeSecretArn?: string;\n /** Stripe webhook signing secret ARN (spoke that does billing). */\n stripeWebhookSecretArn?: string;\n};\n\nexport const TENANT_GLOBAL_KEY = \"__TENANT__\" as const;\n\ndeclare global {\n interface Window {\n [TENANT_GLOBAL_KEY]?: TenantPublicConfig;\n }\n}\n","import \"server-only\";\n\nimport {\n filterAppsByIdentityGroups,\n sortAppsByNavOrder,\n validateAppsRoster,\n type TenantApp,\n type AppsRoster,\n} from \"../apps-roster/schema.js\";\nimport type { TenantPublicConfig, TenantServerConfig } from \"../tenant-types.js\";\n\n// =============================================================================\n// /api/apps route handler factories.\n//\n// The apex owns the canonical tenant roster (config/apps.json) and serves\n// it via `createAppsRouteHandler`. Spokes own no roster -- their\n// /api/apps is a proxy to the apex via `createAppsProxyRouteHandler`, so\n// adding a new spoke does NOT require redeploying every existing spoke.\n//\n// Both handlers serve AppShell same-origin, so the browser-side fetch\n// stays simple (`fetch(\"/api/apps\")` with cookie credentials).\n// =============================================================================\n\ntype SessionLike = {\n user?: { groups?: string[] | null } | null;\n} | null;\n\ntype AuthFn = () => Promise<SessionLike>;\n\nexport type CreateAppsRouteHandlerOptions = {\n /** Roster shape, typically `import appsJson from \"../../config/apps.json\"`. */\n roster: AppsRoster | unknown;\n /** Consuming app's `auth()` function. */\n auth: AuthFn;\n /**\n * Tenant config (apex + optional appDomain). Used to derive each app's\n * absolute `appUrl` from its subdomain. Typically the same struct passed\n * to createAuth.\n */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /** Set false to make the endpoint public (NOT recommended). Default true. */\n requireAuth?: boolean;\n};\n\nfunction deriveAppUrl(app: TenantApp, apex: string): string {\n if (app.subdomain === \"\") return `https://${apex}`;\n return `https://${app.subdomain}.${apex}`;\n}\n\nexport function createAppsRouteHandler(opts: CreateAppsRouteHandlerOptions) {\n const validated = validateAppsRoster(opts.roster);\n if (!validated.ok) {\n throw new Error(\n `createAppsRouteHandler: roster failed validation: ${validated.errors\n .map((e) => `${e.path}: ${e.message}`)\n .join(\"; \")}`,\n );\n }\n const apps: TenantApp[] = validated.value.apps;\n const requireAuth = opts.requireAuth ?? true;\n\n return {\n GET: async (): Promise<Response> => {\n let session: SessionLike = null;\n if (requireAuth) {\n session = await opts.auth();\n if (!session) {\n return Response.json({ error: \"unauthenticated\" }, { status: 401 });\n }\n }\n const userGroups = session?.user?.groups ?? [];\n const visible = filterAppsByIdentityGroups(apps, userGroups);\n const sorted = sortAppsByNavOrder(visible);\n const withUrl = sorted.map((a) => ({\n slug: a.slug,\n role: a.role,\n subdomain: a.subdomain,\n displayName: a.displayName,\n navOrder: a.navOrder,\n requiredIdentityGroups: a.requiredIdentityGroups,\n appUrl: deriveAppUrl(a, opts.tenant.apex),\n }));\n return Response.json(withUrl, {\n headers: {\n \"Cache-Control\": \"private, s-maxage=300, stale-while-revalidate=600\",\n },\n });\n },\n };\n}\n\n// =============================================================================\n// createAppsProxyRouteHandler\n//\n// Spoke-side /api/apps handler. Forwards the user's request (Cookie header\n// in particular) to the apex's /api/apps endpoint and proxies the response\n// back, preserving status, content-type, and cache headers.\n//\n// The session cookie is parent-domain-scoped (Domain=.<apex>) so the\n// browser sends it on the spoke's same-origin request; we forward that\n// cookie on the server-to-server fetch to the apex so the apex's\n// authenticated handler sees the same user. No CORS involved.\n//\n// Spokes that use this factory ship no roster file -- the canonical\n// roster lives only in the apex.\n// =============================================================================\n\nexport type CreateAppsProxyRouteHandlerOptions = {\n /** Same tenant struct passed to createAuth. Used to derive the apex URL. */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /**\n * Override the upstream URL. Default: `https://${tenant.apex}/api/apps`.\n */\n upstreamUrl?: string;\n /**\n * Headers to forward from the incoming request, lowercase keys. Default\n * forwards `cookie`, `x-forwarded-for`, and `user-agent`. `authorization`\n * is intentionally NOT forwarded by default -- the upstream uses the\n * parent-domain session cookie, not bearer tokens, and forwarding raw\n * Authorization headers across services is a footgun.\n */\n forwardHeaders?: readonly string[];\n};\n\nconst DEFAULT_FORWARD_HEADERS = [\"cookie\", \"x-forwarded-for\", \"user-agent\"] as const;\n\nconst DEFAULT_RESPONSE_HEADERS = [\n \"content-type\",\n \"cache-control\",\n \"vary\",\n \"etag\",\n] as const;\n\n/**\n * Loop-guard header set on every outbound proxy fetch. If an inbound\n * request already carries it, the apps-proxy refuses to forward -- a\n * misconfigured upstream (e.g. apex `/api/apps` pointing back at a\n * spoke) would otherwise produce an infinite chain.\n */\nconst PROXY_LOOP_HEADER = \"x-augint-apps-proxy\";\n\nexport function createAppsProxyRouteHandler(opts: CreateAppsProxyRouteHandlerOptions) {\n const upstream = opts.upstreamUrl ?? `https://${opts.tenant.apex}/api/apps`;\n const forward = opts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;\n\n return {\n GET: async (request: Request): Promise<Response> => {\n if (request.headers.get(PROXY_LOOP_HEADER)) {\n // Loud-fast: misconfiguration. 508 Loop Detected is the closest\n // standard status; do NOT silently 200 with an empty body.\n return Response.json(\n {\n error: \"apps_proxy_loop_detected\",\n message:\n \"Inbound request already carries the apps-proxy loop-guard header. \" +\n \"This usually means the apex /api/apps is misconfigured (pointing back \" +\n \"at a spoke) or two spokes are proxying to each other.\",\n upstream,\n },\n { status: 508 },\n );\n }\n\n const headers = new Headers();\n for (const name of forward) {\n const v = request.headers.get(name);\n if (v) headers.set(name, v);\n }\n headers.set(PROXY_LOOP_HEADER, \"1\");\n\n try {\n const upstreamResponse = await fetch(upstream, {\n method: \"GET\",\n headers,\n cache: \"no-store\",\n redirect: \"manual\",\n });\n const body = await upstreamResponse.arrayBuffer();\n const responseHeaders = new Headers();\n for (const name of DEFAULT_RESPONSE_HEADERS) {\n const v = upstreamResponse.headers.get(name);\n if (v) responseHeaders.set(name, v);\n }\n return new Response(body, {\n status: upstreamResponse.status,\n headers: responseHeaders,\n });\n } catch (err) {\n return Response.json(\n {\n error: \"apps_proxy_unavailable\",\n message: err instanceof Error ? err.message : String(err),\n upstream,\n },\n { status: 503 },\n );\n }\n },\n };\n}\n"],"mappings":";;;;;;;AAAA,OAAO;AACP,YAAY,WAAW;;;ACmEhB,IAAM,oBAAoB;;;AD9B1B,SAAS,iBAAiB,MAAuC;AACtE,QAAM,MAAM,QAAQ;AACpB,QAAM,IAAI,KAAK,aAAa,CAAC;AAE7B,QAAM,kBAAkB,EAAE,gBAAgB,IAAI;AAC9C,QAAM,eAAe,iBAAiB,QAAQ,OAAO,EAAE;AAEvD,QAAM,QAAqC;AAAA,IACzC,MAAM,KAAK;AAAA,IACX,MAAM,EAAE,QAAQ,IAAI,eAAe;AAAA,IACnC,cAAc,EAAE,gBAAgB,IAAI;AAAA,IACpC,cAAc;AAAA,IACd,QAAQ,EAAE,UAAU,IAAI,cAAc;AAAA,IACtC,SAAS,EAAE,WAAW,IAAI;AAAA,IAC1B,WAAW,EAAE,aAAa,IAAI;AAAA,IAC9B,eAAe,EAAE,iBAAiB,IAAI;AAAA,IACtC,sBAAsB,EAAE,wBAAwB,IAAI;AAAA,IACpD,eAAe,EAAE,iBAAiB,IAAI;AAAA,IACtC,iBAAiB,EAAE,mBAAmB,IAAI;AAAA,IAC1C,aAAa,EAAE,eAAe,IAAI;AAAA,IAClC,aAAa,EAAE,eAAe,IAAI;AAAA,IAClC,QAAQ,EAAE,UAAU,IAAI;AAAA,IACxB,QAAQ,EAAE,UAAU,IAAI;AAAA,IACxB,iBAAiB,EAAE,mBAAmB,IAAI;AAAA,IAC1C,wBAAwB,EAAE,0BAA0B,IAAI;AAAA,EAC1D;AAEA,MAAI,KAAK,SAAS,UAAU,CAAC,MAAM,WAAW;AAC5C,UAAM,YAAY,MAAM;AAAA,EAC1B;AAEA,QAAM,WAAkE;AAAA,IACtE,EAAE,KAAK,QAAQ,KAAK,2DAA2D;AAAA,IAC/E,EAAE,KAAK,gBAAgB,KAAK,qBAAqB;AAAA,IACjD,EAAE,KAAK,gBAAgB,KAAK,6BAA6B;AAAA,IACzD,EAAE,KAAK,iBAAiB,KAAK,kBAAkB;AAAA,IAC/C,EAAE,KAAK,aAAa,KAAK,aAAa;AAAA,EACxC;AACA,MAAI,KAAK,SAAS,QAAQ;AACxB,aAAS;AAAA,MACP,EAAE,KAAK,wBAAwB,KAAK,0BAA0B;AAAA,MAC9D,EAAE,KAAK,iBAAiB,KAAK,sBAAsB;AAAA,MACnD,EAAE,KAAK,mBAAmB,KAAK,kBAAkB;AAAA,IACnD;AAAA,EACF,OAAO;AACL,aAAS,KAAK,EAAE,KAAK,WAAW,KAAK,WAAW,CAAC;AAAA,EACnD;AAEA,MACE,QAAQ,IAAI,eAAe,4BAC3B,CAAC,QAAQ,IAAI,0BACb;AACA,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,SAAS,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG;AACtE,MAAI,QAAQ,SAAS,GAAG;AACtB,UAAM,IAAI;AAAA,MACR,oBAAoB,KAAK,IAAI,iCAAiC,QAAQ,KAAK,IAAI,CAAC;AAAA,IAClF;AAAA,EACF;AAEA,SAAO;AACT;AAOO,SAAS,aAAa,QAAgD;AAC3E,SAAO;AAAA,IACL,MAAM,OAAO;AAAA,IACb,cAAc,OAAO;AAAA,IACrB,cAAc,OAAO;AAAA,IACrB,QAAQ,OAAO;AAAA,IACf,SAAS,OAAO;AAAA,IAChB,WAAW,OAAO;AAAA,IAClB,MAAM,OAAO;AAAA,EACf;AACF;AAYA,IAAM,kBAAkB;AAEjB,SAAS,iBAAiB,EAAE,OAAO,GAAmC;AAC3E,QAAM,UAAU,KAAK,UAAU,MAAM,EAAE,QAAQ,MAAM,SAAS;AAC9D,QAAM,OAAO,UAAU,iBAAiB,IAAI,OAAO;AACnD,QAAM,QAAiC,CAAC;AACxC,QAAM,eAAe,IAAI,EAAE,QAAQ,KAAK;AACxC,SAAa,oBAAc,UAAU,KAAK;AAC5C;;;AE1IA,OAAO;AA4CP,SAAS,aAAa,KAAgB,MAAsB;AAC1D,MAAI,IAAI,cAAc,GAAI,QAAO,WAAW,IAAI;AAChD,SAAO,WAAW,IAAI,SAAS,IAAI,IAAI;AACzC;AAEO,SAAS,uBAAuB,MAAqC;AAC1E,QAAM,YAAY,mBAAmB,KAAK,MAAM;AAChD,MAAI,CAAC,UAAU,IAAI;AACjB,UAAM,IAAI;AAAA,MACR,qDAAqD,UAAU,OAC5D,IAAI,CAAC,MAAM,GAAG,EAAE,IAAI,KAAK,EAAE,OAAO,EAAE,EACpC,KAAK,IAAI,CAAC;AAAA,IACf;AAAA,EACF;AACA,QAAM,OAAoB,UAAU,MAAM;AAC1C,QAAM,cAAc,KAAK,eAAe;AAExC,SAAO;AAAA,IACL,KAAK,YAA+B;AAClC,UAAI,UAAuB;AAC3B,UAAI,aAAa;AACf,kBAAU,MAAM,KAAK,KAAK;AAC1B,YAAI,CAAC,SAAS;AACZ,iBAAO,SAAS,KAAK,EAAE,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,QACpE;AAAA,MACF;AACA,YAAM,aAAa,SAAS,MAAM,UAAU,CAAC;AAC7C,YAAM,UAAU,2BAA2B,MAAM,UAAU;AAC3D,YAAM,SAAS,mBAAmB,OAAO;AACzC,YAAM,UAAU,OAAO,IAAI,CAAC,OAAO;AAAA,QACjC,MAAM,EAAE;AAAA,QACR,MAAM,EAAE;AAAA,QACR,WAAW,EAAE;AAAA,QACb,aAAa,EAAE;AAAA,QACf,UAAU,EAAE;AAAA,QACZ,wBAAwB,EAAE;AAAA,QAC1B,QAAQ,aAAa,GAAG,KAAK,OAAO,IAAI;AAAA,MAC1C,EAAE;AACF,aAAO,SAAS,KAAK,SAAS;AAAA,QAC5B,SAAS;AAAA,UACP,iBAAiB;AAAA,QACnB;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACF;AAmCA,IAAM,0BAA0B,CAAC,UAAU,mBAAmB,YAAY;AAE1E,IAAM,2BAA2B;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAQA,IAAM,oBAAoB;AAEnB,SAAS,4BAA4B,MAA0C;AACpF,QAAM,WAAW,KAAK,eAAe,WAAW,KAAK,OAAO,IAAI;AAChE,QAAM,UAAU,KAAK,kBAAkB;AAEvC,SAAO;AAAA,IACL,KAAK,OAAO,YAAwC;AAClD,UAAI,QAAQ,QAAQ,IAAI,iBAAiB,GAAG;AAG1C,eAAO,SAAS;AAAA,UACd;AAAA,YACE,OAAO;AAAA,YACP,SACE;AAAA,YAGF;AAAA,UACF;AAAA,UACA,EAAE,QAAQ,IAAI;AAAA,QAChB;AAAA,MACF;AAEA,YAAM,UAAU,IAAI,QAAQ;AAC5B,iBAAW,QAAQ,SAAS;AAC1B,cAAM,IAAI,QAAQ,QAAQ,IAAI,IAAI;AAClC,YAAI,EAAG,SAAQ,IAAI,MAAM,CAAC;AAAA,MAC5B;AACA,cAAQ,IAAI,mBAAmB,GAAG;AAElC,UAAI;AACF,cAAM,mBAAmB,MAAM,MAAM,UAAU;AAAA,UAC7C,QAAQ;AAAA,UACR;AAAA,UACA,OAAO;AAAA,UACP,UAAU;AAAA,QACZ,CAAC;AACD,cAAM,OAAO,MAAM,iBAAiB,YAAY;AAChD,cAAM,kBAAkB,IAAI,QAAQ;AACpC,mBAAW,QAAQ,0BAA0B;AAC3C,gBAAM,IAAI,iBAAiB,QAAQ,IAAI,IAAI;AAC3C,cAAI,EAAG,iBAAgB,IAAI,MAAM,CAAC;AAAA,QACpC;AACA,eAAO,IAAI,SAAS,MAAM;AAAA,UACxB,QAAQ,iBAAiB;AAAA,UACzB,SAAS;AAAA,QACX,CAAC;AAAA,MACH,SAAS,KAAK;AACZ,eAAO,SAAS;AAAA,UACd;AAAA,YACE,OAAO;AAAA,YACP,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,YACxD;AAAA,UACF;AAAA,UACA,EAAE,QAAQ,IAAI;AAAA,QAChB;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tenant-types.ts"],"sourcesContent":["// =============================================================================\n// TenantConfig -- the single struct every @augmenting-integrations package\n// consumes. Apex apps and spokes share the same type; spoke-only fields are\n// optional. The `role` discriminator tells loadTenantConfig() which fields\n// to demand.\n//\n// Public fields (apex + parent domain + slug) are safe to ship to the browser\n// via <TenantBootScript />. Secret-arn fields are server-only and never reach\n// the client bundle.\n// =============================================================================\n\nexport type TenantRole = \"apex\" | \"spoke\";\n\nexport type TenantPublicConfig = {\n /** The tenant apex FQDN, e.g. \"agency.aillc.link\". */\n apex: string;\n /**\n * Cookie Domain attribute. Always the apex (no leading dot needed -- the\n * browser implies it for shared cookies). Auth.js session cookie and the\n * theme x-theme/x-theme-variant cookies use this. Without it cookies are\n * host-only and the subdomain ecosystem breaks.\n */\n cookieDomain: string;\n /**\n * The registrable parent domain (e.g. \"aillc.link\"). Used by the auth\n * redirect callback to validate post-login callbacks back to any subdomain\n * of the tenant. Distinct from cookieDomain in two-level apex setups.\n */\n parentDomain: string;\n /** AWS region. Default: us-east-1. */\n region: string;\n /**\n * For spoke apps: this spoke's slug (matches the tenant roster entry's\n * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.\n */\n appSlug?: string;\n /**\n * For spoke apps: this spoke's FQDN (e.g. \"leads.agency.aillc.link\").\n * For apex: same as `apex`.\n */\n appDomain: string;\n /** \"apex\" or \"spoke\". Affects which secret-arn fields are required. */\n role: TenantRole;\n};\n\nexport type TenantServerConfig = TenantPublicConfig & {\n /** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */\n authSecretArn: string;\n /**
|
|
1
|
+
{"version":3,"sources":["../src/tenant-types.ts"],"sourcesContent":["// =============================================================================\n// TenantConfig -- the single struct every @augmenting-integrations package\n// consumes. Apex apps and spokes share the same type; spoke-only fields are\n// optional. The `role` discriminator tells loadTenantConfig() which fields\n// to demand.\n//\n// Public fields (apex + parent domain + slug) are safe to ship to the browser\n// via <TenantBootScript />. Secret-arn fields are server-only and never reach\n// the client bundle.\n// =============================================================================\n\nexport type TenantRole = \"apex\" | \"spoke\";\n\nexport type TenantPublicConfig = {\n /** The tenant apex FQDN, e.g. \"agency.aillc.link\". */\n apex: string;\n /**\n * Cookie Domain attribute. Always the apex (no leading dot needed -- the\n * browser implies it for shared cookies). Auth.js session cookie and the\n * theme x-theme/x-theme-variant cookies use this. Without it cookies are\n * host-only and the subdomain ecosystem breaks.\n */\n cookieDomain: string;\n /**\n * The registrable parent domain (e.g. \"aillc.link\"). Used by the auth\n * redirect callback to validate post-login callbacks back to any subdomain\n * of the tenant. Distinct from cookieDomain in two-level apex setups.\n */\n parentDomain: string;\n /** AWS region. Default: us-east-1. */\n region: string;\n /**\n * For spoke apps: this spoke's slug (matches the tenant roster entry's\n * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.\n */\n appSlug?: string;\n /**\n * For spoke apps: this spoke's FQDN (e.g. \"leads.agency.aillc.link\").\n * For apex: same as `apex`.\n */\n appDomain: string;\n /** \"apex\" or \"spoke\". Affects which secret-arn fields are required. */\n role: TenantRole;\n};\n\nexport type TenantServerConfig = TenantPublicConfig & {\n /** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */\n authSecretArn: string;\n /** Cognito client secret ARN. Apex only -- spokes don't run the OAuth dance. */\n authCognitoSecretArn?: string;\n /** Cognito issuer URL (apex only). */\n cognitoIssuer?: string;\n /** Cognito client ID (apex only). */\n cognitoClientId?: string;\n /** Comma-separated admin emails (auto-promoted on first sign-in). */\n adminEmails?: string;\n /** Aurora connection secret ARN (spoke only). */\n dbSecretArn?: string;\n /** Aurora endpoint host (spoke only). */\n dbHost?: string;\n /** Aurora database name (spoke only). */\n dbName?: string;\n /** Stripe credentials bundle ARN (spoke that does billing). */\n stripeSecretArn?: string;\n /** Stripe webhook signing secret ARN (spoke that does billing). */\n stripeWebhookSecretArn?: string;\n};\n\nexport const TENANT_GLOBAL_KEY = \"__TENANT__\" as const;\n\ndeclare global {\n interface Window {\n [TENANT_GLOBAL_KEY]?: TenantPublicConfig;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAoEO,MAAM,oBAAoB;","names":[]}
|
package/dist/tenant-types.d.ts
CHANGED
|
@@ -33,8 +33,6 @@ export type TenantPublicConfig = {
|
|
|
33
33
|
export type TenantServerConfig = TenantPublicConfig & {
|
|
34
34
|
/** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */
|
|
35
35
|
authSecretArn: string;
|
|
36
|
-
/** App registry DynamoDB table name. Apex owns the table; spokes read. */
|
|
37
|
-
registryTable: string;
|
|
38
36
|
/** Cognito client secret ARN. Apex only -- spokes don't run the OAuth dance. */
|
|
39
37
|
authCognitoSecretArn?: string;
|
|
40
38
|
/** Cognito issuer URL (apex only). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tenant-types.d.ts","sourceRoot":"","sources":["../src/tenant-types.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,CAAC;AAE1C,MAAM,MAAM,kBAAkB,GAAG;IAC/B,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,sCAAsC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,IAAI,EAAE,UAAU,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,kBAAkB,GAAG;IACpD,gEAAgE;IAChE,aAAa,EAAE,MAAM,CAAC;IACtB,
|
|
1
|
+
{"version":3,"file":"tenant-types.d.ts","sourceRoot":"","sources":["../src/tenant-types.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,CAAC;AAE1C,MAAM,MAAM,kBAAkB,GAAG;IAC/B,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,sCAAsC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,IAAI,EAAE,UAAU,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,kBAAkB,GAAG;IACpD,gEAAgE;IAChE,aAAa,EAAE,MAAM,CAAC;IACtB,gFAAgF;IAChF,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,sCAAsC;IACtC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,qCAAqC;IACrC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,qEAAqE;IACrE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yCAAyC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yCAAyC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mEAAmE;IACnE,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAG,YAAqB,CAAC;AAEvD,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,CAAC,iBAAiB,CAAC,CAAC,EAAE,kBAAkB,CAAC;KAC1C;CACF"}
|
package/dist/tenant-types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tenant-types.ts"],"sourcesContent":["// =============================================================================\n// TenantConfig -- the single struct every @augmenting-integrations package\n// consumes. Apex apps and spokes share the same type; spoke-only fields are\n// optional. The `role` discriminator tells loadTenantConfig() which fields\n// to demand.\n//\n// Public fields (apex + parent domain + slug) are safe to ship to the browser\n// via <TenantBootScript />. Secret-arn fields are server-only and never reach\n// the client bundle.\n// =============================================================================\n\nexport type TenantRole = \"apex\" | \"spoke\";\n\nexport type TenantPublicConfig = {\n /** The tenant apex FQDN, e.g. \"agency.aillc.link\". */\n apex: string;\n /**\n * Cookie Domain attribute. Always the apex (no leading dot needed -- the\n * browser implies it for shared cookies). Auth.js session cookie and the\n * theme x-theme/x-theme-variant cookies use this. Without it cookies are\n * host-only and the subdomain ecosystem breaks.\n */\n cookieDomain: string;\n /**\n * The registrable parent domain (e.g. \"aillc.link\"). Used by the auth\n * redirect callback to validate post-login callbacks back to any subdomain\n * of the tenant. Distinct from cookieDomain in two-level apex setups.\n */\n parentDomain: string;\n /** AWS region. Default: us-east-1. */\n region: string;\n /**\n * For spoke apps: this spoke's slug (matches the tenant roster entry's\n * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.\n */\n appSlug?: string;\n /**\n * For spoke apps: this spoke's FQDN (e.g. \"leads.agency.aillc.link\").\n * For apex: same as `apex`.\n */\n appDomain: string;\n /** \"apex\" or \"spoke\". Affects which secret-arn fields are required. */\n role: TenantRole;\n};\n\nexport type TenantServerConfig = TenantPublicConfig & {\n /** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */\n authSecretArn: string;\n /**
|
|
1
|
+
{"version":3,"sources":["../src/tenant-types.ts"],"sourcesContent":["// =============================================================================\n// TenantConfig -- the single struct every @augmenting-integrations package\n// consumes. Apex apps and spokes share the same type; spoke-only fields are\n// optional. The `role` discriminator tells loadTenantConfig() which fields\n// to demand.\n//\n// Public fields (apex + parent domain + slug) are safe to ship to the browser\n// via <TenantBootScript />. Secret-arn fields are server-only and never reach\n// the client bundle.\n// =============================================================================\n\nexport type TenantRole = \"apex\" | \"spoke\";\n\nexport type TenantPublicConfig = {\n /** The tenant apex FQDN, e.g. \"agency.aillc.link\". */\n apex: string;\n /**\n * Cookie Domain attribute. Always the apex (no leading dot needed -- the\n * browser implies it for shared cookies). Auth.js session cookie and the\n * theme x-theme/x-theme-variant cookies use this. Without it cookies are\n * host-only and the subdomain ecosystem breaks.\n */\n cookieDomain: string;\n /**\n * The registrable parent domain (e.g. \"aillc.link\"). Used by the auth\n * redirect callback to validate post-login callbacks back to any subdomain\n * of the tenant. Distinct from cookieDomain in two-level apex setups.\n */\n parentDomain: string;\n /** AWS region. Default: us-east-1. */\n region: string;\n /**\n * For spoke apps: this spoke's slug (matches the tenant roster entry's\n * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.\n */\n appSlug?: string;\n /**\n * For spoke apps: this spoke's FQDN (e.g. \"leads.agency.aillc.link\").\n * For apex: same as `apex`.\n */\n appDomain: string;\n /** \"apex\" or \"spoke\". Affects which secret-arn fields are required. */\n role: TenantRole;\n};\n\nexport type TenantServerConfig = TenantPublicConfig & {\n /** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */\n authSecretArn: string;\n /** Cognito client secret ARN. Apex only -- spokes don't run the OAuth dance. */\n authCognitoSecretArn?: string;\n /** Cognito issuer URL (apex only). */\n cognitoIssuer?: string;\n /** Cognito client ID (apex only). */\n cognitoClientId?: string;\n /** Comma-separated admin emails (auto-promoted on first sign-in). */\n adminEmails?: string;\n /** Aurora connection secret ARN (spoke only). */\n dbSecretArn?: string;\n /** Aurora endpoint host (spoke only). */\n dbHost?: string;\n /** Aurora database name (spoke only). */\n dbName?: string;\n /** Stripe credentials bundle ARN (spoke that does billing). */\n stripeSecretArn?: string;\n /** Stripe webhook signing secret ARN (spoke that does billing). */\n stripeWebhookSecretArn?: string;\n};\n\nexport const TENANT_GLOBAL_KEY = \"__TENANT__\" as const;\n\ndeclare global {\n interface Window {\n [TENANT_GLOBAL_KEY]?: TenantPublicConfig;\n }\n}\n"],"mappings":"AAoEO,MAAM,oBAAoB;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@augmenting-integrations/platform",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.8.0",
|
|
4
4
|
"description": "Tenant + app manifest contract for the augint platform. Owns TenantConfig (server-side env load + browser-safe public subset + TenantBootScript) and the app.manifest.json schema/loader. Every other @augmenting-integrations/* package consumes tenant context from here, so /auth no longer owns it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/apps-roster/schema.ts"],"sourcesContent":["// =============================================================================\n// Tenant app roster.\n//\n// One file per tenant that lists every app (apex + spokes) the tenant\n// ecosystem contains. Replaces the runtime DynamoDB registry. Stored as\n// YAML in <tenant>-infra/config/apps.yaml (canonical) and mirrored to\n// <tenant>-apex/config/apps.json for runtime serving by /api/apps.\n//\n// Adding a new spoke = a PR to the spoke repo (its app.manifest.json) +\n// a PR to <tenant>-infra/config/apps.yaml + a PR to <tenant>-apex/config/\n// apps.json. Validation catches drift.\n// =============================================================================\n\nexport type TenantAppRole = \"apex\" | \"spoke\";\n\nexport type TenantApp = {\n /** Stable identifier. Matches the spoke's app.manifest.json#appSlug. */\n slug: string;\n /** \"apex\" (auth broker) or \"spoke\" (product app). */\n role: TenantAppRole;\n /** DNS label. Empty string for apex. */\n subdomain: string;\n /** Human-friendly name. Drives the shared nav. */\n displayName: string;\n /** Sort order. Lower comes first. */\n navOrder: number;\n /**\n * Cognito identity groups required to see this app in cross-app nav\n * AND to enter its routes (when the spoke's createAuth is wired to its\n * own manifest's access policy). Empty = all authenticated users.\n */\n requiredIdentityGroups: string[];\n /**\n * Static feature toggle. Default true. Set false to hide an app from\n * cross-app nav without removing the entry. Editing this requires a\n * PR + redeploy -- this is NOT mutable runtime state.\n */\n enabled?: boolean;\n};\n\nexport type AppsRoster = {\n apps: TenantApp[];\n};\n\nexport type RosterValidationError = {\n path: string;\n message: string;\n};\n\nconst ROLES: readonly string[] = [\"apex\", \"spoke\"];\n\n/**\n * Pure validator for the roster object (parsed from YAML or JSON). Returns\n * the typed roster on success, or an array of errors on failure. No throws.\n */\nexport function validateAppsRoster(\n raw: unknown,\n): { ok: true; value: AppsRoster } | { ok: false; errors: RosterValidationError[] } {\n const errors: RosterValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"roster must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n if (!Array.isArray(m.apps)) {\n return { ok: false, errors: [{ path: \"apps\", message: \"expected array\" }] };\n }\n const apps = m.apps as unknown[];\n\n const seenSlugs = new Map<string, number>();\n const seenSubdomains = new Map<string, number>();\n const seenNavOrder = new Map<number, number>();\n let apexCount = 0;\n\n apps.forEach((entryUnknown, i) => {\n const path = `apps[${i}]`;\n if (typeof entryUnknown !== \"object\" || entryUnknown === null) {\n errors.push({ path, message: \"expected object\" });\n return;\n }\n const entry = entryUnknown as Record<string, unknown>;\n\n if (typeof entry.slug !== \"string\" || entry.slug === \"\") {\n errors.push({ path: `${path}.slug`, message: \"expected non-empty string\" });\n } else {\n const prior = seenSlugs.get(entry.slug);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.slug`,\n message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`,\n });\n } else {\n seenSlugs.set(entry.slug, i);\n }\n }\n\n if (typeof entry.role !== \"string\" || !ROLES.includes(entry.role)) {\n errors.push({\n path: `${path}.role`,\n message: `expected one of: ${ROLES.join(\", \")}`,\n });\n } else if (entry.role === \"apex\") {\n apexCount++;\n }\n\n if (typeof entry.subdomain !== \"string\") {\n errors.push({ path: `${path}.subdomain`, message: \"expected string\" });\n } else {\n if (entry.role === \"apex\" && entry.subdomain !== \"\") {\n errors.push({\n path: `${path}.subdomain`,\n message: \"apex apps must have empty subdomain\",\n });\n }\n if (entry.subdomain !== \"\") {\n const prior = seenSubdomains.get(entry.subdomain);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.subdomain`,\n message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`,\n });\n } else {\n seenSubdomains.set(entry.subdomain, i);\n }\n }\n }\n\n if (typeof entry.displayName !== \"string\" || entry.displayName === \"\") {\n errors.push({\n path: `${path}.displayName`,\n message: \"expected non-empty string\",\n });\n }\n\n if (typeof entry.navOrder !== \"number\" || !Number.isFinite(entry.navOrder)) {\n errors.push({ path: `${path}.navOrder`, message: \"expected number\" });\n } else {\n const prior = seenNavOrder.get(entry.navOrder);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.navOrder`,\n message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`,\n });\n } else {\n seenNavOrder.set(entry.navOrder, i);\n }\n }\n\n if (\n !Array.isArray(entry.requiredIdentityGroups) ||\n entry.requiredIdentityGroups.some((g) => typeof g !== \"string\")\n ) {\n errors.push({\n path: `${path}.requiredIdentityGroups`,\n message: \"expected string[]\",\n });\n }\n\n if (entry.enabled !== undefined && typeof entry.enabled !== \"boolean\") {\n errors.push({ path: `${path}.enabled`, message: \"expected boolean\" });\n }\n });\n\n if (apexCount === 0) {\n errors.push({ path: \"apps\", message: \"roster must contain exactly one apex entry\" });\n } else if (apexCount > 1) {\n errors.push({\n path: \"apps\",\n message: `roster must contain exactly one apex entry, found ${apexCount}`,\n });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppsRoster };\n}\n\n/**\n * Filter the roster by user identity groups. Apps with empty\n * `requiredIdentityGroups` are visible to all authenticated users; otherwise\n * the user must be in at least one of the listed groups. `enabled: false`\n * apps are always filtered out.\n */\nexport function filterAppsByIdentityGroups(\n apps: TenantApp[],\n userGroups: string[],\n): TenantApp[] {\n const lower = userGroups.map((g) => g.toLowerCase());\n return apps.filter((a) => {\n if (a.enabled === false) return false;\n if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;\n return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));\n });\n}\n\n/** Sort apps by navOrder ASC, then slug. Mutates a copy, returns it. */\nexport function sortAppsByNavOrder<T extends Pick<TenantApp, \"navOrder\" | \"slug\">>(\n apps: T[],\n): T[] {\n return [...apps].sort(\n (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug),\n );\n}\n"],"mappings":"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","names":[]}
|