@augmenting-integrations/platform 8.6.0 → 8.7.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.
@@ -11,7 +11,7 @@ export type AppManifest = {
11
11
  role: AppRole;
12
12
  /** Subdomain label. "" for apex. */
13
13
  subdomain: string;
14
- /** Human-friendly name for nav + admin UI. */
14
+ /** Human-friendly name for the shared cross-app nav. */
15
15
  displayName: string;
16
16
  /** Sort order in the cross-app nav. Lower = first. */
17
17
  navOrder: number;
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/manifest/schema.ts"],"names":[],"mappings":"AAYA,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,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,IAAI,EAAE,OAAO,CAAC;IACd,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,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"}
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,uDAAuD;IACvD,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"}
@@ -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// registry registration call, the schema validator, the runtime app-access\n// enforcement, and the spoke kernel infra wiring. Product developers should\n// only have to edit this file (not env vars, workflow files, or registry\n// rows) to change their 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 nav + admin UI. */\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. 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 +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// registry registration call, the schema validator, the runtime app-access\n// enforcement, and the spoke kernel infra wiring. Product developers should\n// only have to edit this file (not env vars, workflow files, or registry\n// rows) to change their 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 nav + admin UI. */\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. 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":[]}
@@ -24,5 +24,21 @@ export type CreateAppsRouteHandlerOptions = {
24
24
  export declare function createAppsRouteHandler(opts: CreateAppsRouteHandlerOptions): {
25
25
  GET: () => Promise<Response>;
26
26
  };
27
+ export type CreateAppsProxyRouteHandlerOptions = {
28
+ /** Same tenant struct passed to createAuth. Used to derive the apex URL. */
29
+ tenant: Pick<TenantServerConfig, "apex"> | Pick<TenantPublicConfig, "apex">;
30
+ /**
31
+ * Override the upstream URL. Default: `https://${tenant.apex}/api/apps`.
32
+ */
33
+ upstreamUrl?: string;
34
+ /**
35
+ * Headers to forward from the incoming request, lowercase keys. Default
36
+ * forwards `cookie`, `authorization`, `x-forwarded-for`, and `user-agent`.
37
+ */
38
+ forwardHeaders?: readonly string[];
39
+ };
40
+ export declare function createAppsProxyRouteHandler(opts: CreateAppsProxyRouteHandlerOptions): {
41
+ GET: (request: Request) => Promise<Response>;
42
+ };
27
43
  export {};
28
44
  //# sourceMappingURL=apps-route.d.ts.map
@@ -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"}
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;;;OAGG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,CAAC;AAgBF,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,kCAAkC;mBAK3D,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;EAmCnD"}
package/dist/server.cjs CHANGED
@@ -32,6 +32,7 @@ var server_exports = {};
32
32
  __export(server_exports, {
33
33
  TENANT_GLOBAL_KEY: () => TENANT_GLOBAL_KEY,
34
34
  TenantBootScript: () => TenantBootScript,
35
+ createAppsProxyRouteHandler: () => createAppsProxyRouteHandler,
35
36
  createAppsRouteHandler: () => createAppsRouteHandler,
36
37
  loadTenantConfig: () => loadTenantConfig,
37
38
  publicSubset: () => publicSubset
@@ -286,10 +287,63 @@ function createAppsRouteHandler(opts) {
286
287
  }
287
288
  };
288
289
  }
290
+ var DEFAULT_FORWARD_HEADERS = [
291
+ "cookie",
292
+ "authorization",
293
+ "x-forwarded-for",
294
+ "user-agent"
295
+ ];
296
+ var DEFAULT_RESPONSE_HEADERS = [
297
+ "content-type",
298
+ "cache-control",
299
+ "vary",
300
+ "etag"
301
+ ];
302
+ function createAppsProxyRouteHandler(opts) {
303
+ const upstream = opts.upstreamUrl ?? `https://${opts.tenant.apex}/api/apps`;
304
+ const forward = opts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;
305
+ return {
306
+ GET: async (request) => {
307
+ const headers = new Headers();
308
+ for (const name of forward) {
309
+ const v = request.headers.get(name);
310
+ if (v) headers.set(name, v);
311
+ }
312
+ try {
313
+ const upstreamResponse = await fetch(upstream, {
314
+ method: "GET",
315
+ headers,
316
+ cache: "no-store",
317
+ redirect: "manual"
318
+ });
319
+ const body = await upstreamResponse.arrayBuffer();
320
+ const responseHeaders = new Headers();
321
+ for (const name of DEFAULT_RESPONSE_HEADERS) {
322
+ const v = upstreamResponse.headers.get(name);
323
+ if (v) responseHeaders.set(name, v);
324
+ }
325
+ return new Response(body, {
326
+ status: upstreamResponse.status,
327
+ headers: responseHeaders
328
+ });
329
+ } catch (err) {
330
+ return Response.json(
331
+ {
332
+ error: "apps_proxy_unavailable",
333
+ message: err instanceof Error ? err.message : String(err),
334
+ upstream
335
+ },
336
+ { status: 503 }
337
+ );
338
+ }
339
+ }
340
+ };
341
+ }
289
342
  // Annotate the CommonJS export names for ESM import in node:
290
343
  0 && (module.exports = {
291
344
  TENANT_GLOBAL_KEY,
292
345
  TenantBootScript,
346
+ createAppsProxyRouteHandler,
293
347
  createAppsRouteHandler,
294
348
  loadTenantConfig,
295
349
  publicSubset
@@ -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 type CreateAppsRouteHandlerOptions,\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 app registry primary key).\n * 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 factory.\n//\n// Replaces the DynamoDB-backed createGetHandler. Reads from a statically\n// imported config/apps.json shipped with the Lambda. Authenticates the user\n// (via the consuming app's `auth` function), filters apps by the user's\n// identity groups, sorts by navOrder, returns JSON. No DynamoDB.\n//\n// The apex's AppShell + each spoke's AppShell both call /api/apps\n// same-origin; both wire this factory with their own auth + roster.\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// 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// 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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,yBAAO;AACP,YAAuB;;;ACqEhB,IAAM,oBAAoB;;;AD/B1B,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,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;;;AE5IA,IAAAA,sBAAO;;;ACiDP,IAAM,QAA2B,CAAC,QAAQ,OAAO;AAM1C,SAAS,mBACd,KACkF;AAClF,QAAM,SAAkC,CAAC;AACzC,MAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM;AAC3C,WAAO,EAAE,IAAI,OAAO,QAAQ,CAAC,EAAE,MAAM,IAAI,SAAS,2BAA2B,CAAC,EAAE;AAAA,EAClF;AACA,QAAM,IAAI;AACV,MAAI,CAAC,MAAM,QAAQ,EAAE,IAAI,GAAG;AAC1B,WAAO,EAAE,IAAI,OAAO,QAAQ,CAAC,EAAE,MAAM,QAAQ,SAAS,iBAAiB,CAAC,EAAE;AAAA,EAC5E;AACA,QAAM,OAAO,EAAE;AAEf,QAAM,YAAY,oBAAI,IAAoB;AAC1C,QAAM,iBAAiB,oBAAI,IAAoB;AAC/C,QAAM,eAAe,oBAAI,IAAoB;AAC7C,MAAI,YAAY;AAEhB,OAAK,QAAQ,CAAC,cAAc,MAAM;AAChC,UAAM,OAAO,QAAQ,CAAC;AACtB,QAAI,OAAO,iBAAiB,YAAY,iBAAiB,MAAM;AAC7D,aAAO,KAAK,EAAE,MAAM,SAAS,kBAAkB,CAAC;AAChD;AAAA,IACF;AACA,UAAM,QAAQ;AAEd,QAAI,OAAO,MAAM,SAAS,YAAY,MAAM,SAAS,IAAI;AACvD,aAAO,KAAK,EAAE,MAAM,GAAG,IAAI,SAAS,SAAS,4BAA4B,CAAC;AAAA,IAC5E,OAAO;AACL,YAAM,QAAQ,UAAU,IAAI,MAAM,IAAI;AACtC,UAAI,UAAU,QAAW;AACvB,eAAO,KAAK;AAAA,UACV,MAAM,GAAG,IAAI;AAAA,UACb,SAAS,kBAAkB,KAAK,UAAU,MAAM,IAAI,CAAC,kBAAkB,KAAK;AAAA,QAC9E,CAAC;AAAA,MACH,OAAO;AACL,kBAAU,IAAI,MAAM,MAAM,CAAC;AAAA,MAC7B;AAAA,IACF;AAEA,QAAI,OAAO,MAAM,SAAS,YAAY,CAAC,MAAM,SAAS,MAAM,IAAI,GAAG;AACjE,aAAO,KAAK;AAAA,QACV,MAAM,GAAG,IAAI;AAAA,QACb,SAAS,oBAAoB,MAAM,KAAK,IAAI,CAAC;AAAA,MAC/C,CAAC;AAAA,IACH,WAAW,MAAM,SAAS,QAAQ;AAChC;AAAA,IACF;AAEA,QAAI,OAAO,MAAM,cAAc,UAAU;AACvC,aAAO,KAAK,EAAE,MAAM,GAAG,IAAI,cAAc,SAAS,kBAAkB,CAAC;AAAA,IACvE,OAAO;AACL,UAAI,MAAM,SAAS,UAAU,MAAM,cAAc,IAAI;AACnD,eAAO,KAAK;AAAA,UACV,MAAM,GAAG,IAAI;AAAA,UACb,SAAS;AAAA,QACX,CAAC;AAAA,MACH;AACA,UAAI,MAAM,cAAc,IAAI;AAC1B,cAAM,QAAQ,eAAe,IAAI,MAAM,SAAS;AAChD,YAAI,UAAU,QAAW;AACvB,iBAAO,KAAK;AAAA,YACV,MAAM,GAAG,IAAI;AAAA,YACb,SAAS,uBAAuB,KAAK,UAAU,MAAM,SAAS,CAAC,kBAAkB,KAAK;AAAA,UACxF,CAAC;AAAA,QACH,OAAO;AACL,yBAAe,IAAI,MAAM,WAAW,CAAC;AAAA,QACvC;AAAA,MACF;AAAA,IACF;AAEA,QAAI,OAAO,MAAM,gBAAgB,YAAY,MAAM,gBAAgB,IAAI;AACrE,aAAO,KAAK;AAAA,QACV,MAAM,GAAG,IAAI;AAAA,QACb,SAAS;AAAA,MACX,CAAC;AAAA,IACH;AAEA,QAAI,OAAO,MAAM,aAAa,YAAY,CAAC,OAAO,SAAS,MAAM,QAAQ,GAAG;AAC1E,aAAO,KAAK,EAAE,MAAM,GAAG,IAAI,aAAa,SAAS,kBAAkB,CAAC;AAAA,IACtE,OAAO;AACL,YAAM,QAAQ,aAAa,IAAI,MAAM,QAAQ;AAC7C,UAAI,UAAU,QAAW;AACvB,eAAO,KAAK;AAAA,UACV,MAAM,GAAG,IAAI;AAAA,UACb,SAAS,sBAAsB,MAAM,QAAQ,kBAAkB,KAAK;AAAA,QACtE,CAAC;AAAA,MACH,OAAO;AACL,qBAAa,IAAI,MAAM,UAAU,CAAC;AAAA,MACpC;AAAA,IACF;AAEA,QACE,CAAC,MAAM,QAAQ,MAAM,sBAAsB,KAC3C,MAAM,uBAAuB,KAAK,CAAC,MAAM,OAAO,MAAM,QAAQ,GAC9D;AACA,aAAO,KAAK;AAAA,QACV,MAAM,GAAG,IAAI;AAAA,QACb,SAAS;AAAA,MACX,CAAC;AAAA,IACH;AAEA,QAAI,MAAM,YAAY,UAAa,OAAO,MAAM,YAAY,WAAW;AACrE,aAAO,KAAK,EAAE,MAAM,GAAG,IAAI,YAAY,SAAS,mBAAmB,CAAC;AAAA,IACtE;AAAA,EACF,CAAC;AAED,MAAI,cAAc,GAAG;AACnB,WAAO,KAAK,EAAE,MAAM,QAAQ,SAAS,6CAA6C,CAAC;AAAA,EACrF,WAAW,YAAY,GAAG;AACxB,WAAO,KAAK;AAAA,MACV,MAAM;AAAA,MACN,SAAS,qDAAqD,SAAS;AAAA,IACzE,CAAC;AAAA,EACH;AAEA,MAAI,OAAO,SAAS,EAAG,QAAO,EAAE,IAAI,OAAO,OAAO;AAClD,SAAO,EAAE,IAAI,MAAM,OAAO,EAA2B;AACvD;AAQO,SAAS,2BACd,MACA,YACa;AACb,QAAM,QAAQ,WAAW,IAAI,CAAC,MAAM,EAAE,YAAY,CAAC;AACnD,SAAO,KAAK,OAAO,CAAC,MAAM;AACxB,QAAI,EAAE,YAAY,MAAO,QAAO;AAChC,QAAI,CAAC,EAAE,0BAA0B,EAAE,uBAAuB,WAAW,EAAG,QAAO;AAC/E,WAAO,EAAE,uBAAuB,KAAK,CAAC,MAAM,MAAM,SAAS,EAAE,YAAY,CAAC,CAAC;AAAA,EAC7E,CAAC;AACH;AAGO,SAAS,mBACd,MACK;AACL,SAAO,CAAC,GAAG,IAAI,EAAE;AAAA,IACf,CAAC,GAAG,OAAO,EAAE,YAAY,MAAM,EAAE,YAAY,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI;AAAA,EAChF;AACF;;;AD5JA,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;AAgCA,IAAM,0BAA0B;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,IAAM,2BAA2B;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEO,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,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,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":["import_server_only"]}
package/dist/server.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { loadTenantConfig, publicSubset, TenantBootScript, TENANT_GLOBAL_KEY, type LoadOptions, type TenantPublicConfig, type TenantServerConfig, type TenantRole, } from "./server/tenant.js";
2
- export { createAppsRouteHandler, type CreateAppsRouteHandlerOptions, } from "./server/apps-route.js";
2
+ export { createAppsRouteHandler, createAppsProxyRouteHandler, type CreateAppsRouteHandlerOptions, type CreateAppsProxyRouteHandlerOptions, } from "./server/apps-route.js";
3
3
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,KAAK,UAAU,GAChB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,sBAAsB,EACtB,KAAK,6BAA6B,GACnC,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,KAAK,UAAU,GAChB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,KAAK,6BAA6B,EAClC,KAAK,kCAAkC,GACxC,MAAM,wBAAwB,CAAC"}
package/dist/server.js CHANGED
@@ -131,9 +131,62 @@ function createAppsRouteHandler(opts) {
131
131
  }
132
132
  };
133
133
  }
134
+ var DEFAULT_FORWARD_HEADERS = [
135
+ "cookie",
136
+ "authorization",
137
+ "x-forwarded-for",
138
+ "user-agent"
139
+ ];
140
+ var DEFAULT_RESPONSE_HEADERS = [
141
+ "content-type",
142
+ "cache-control",
143
+ "vary",
144
+ "etag"
145
+ ];
146
+ function createAppsProxyRouteHandler(opts) {
147
+ const upstream = opts.upstreamUrl ?? `https://${opts.tenant.apex}/api/apps`;
148
+ const forward = opts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;
149
+ return {
150
+ GET: async (request) => {
151
+ const headers = new Headers();
152
+ for (const name of forward) {
153
+ const v = request.headers.get(name);
154
+ if (v) headers.set(name, v);
155
+ }
156
+ try {
157
+ const upstreamResponse = await fetch(upstream, {
158
+ method: "GET",
159
+ headers,
160
+ cache: "no-store",
161
+ redirect: "manual"
162
+ });
163
+ const body = await upstreamResponse.arrayBuffer();
164
+ const responseHeaders = new Headers();
165
+ for (const name of DEFAULT_RESPONSE_HEADERS) {
166
+ const v = upstreamResponse.headers.get(name);
167
+ if (v) responseHeaders.set(name, v);
168
+ }
169
+ return new Response(body, {
170
+ status: upstreamResponse.status,
171
+ headers: responseHeaders
172
+ });
173
+ } catch (err) {
174
+ return Response.json(
175
+ {
176
+ error: "apps_proxy_unavailable",
177
+ message: err instanceof Error ? err.message : String(err),
178
+ upstream
179
+ },
180
+ { status: 503 }
181
+ );
182
+ }
183
+ }
184
+ };
185
+ }
134
186
  export {
135
187
  TENANT_GLOBAL_KEY,
136
188
  TenantBootScript,
189
+ createAppsProxyRouteHandler,
137
190
  createAppsRouteHandler,
138
191
  loadTenantConfig,
139
192
  publicSubset
@@ -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 app registry primary key).\n * 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 factory.\n//\n// Replaces the DynamoDB-backed createGetHandler. Reads from a statically\n// imported config/apps.json shipped with the Lambda. Authenticates the user\n// (via the consuming app's `auth` function), filters apps by the user's\n// identity groups, sorts by navOrder, returns JSON. No DynamoDB.\n//\n// The apex's AppShell + each spoke's AppShell both call /api/apps\n// same-origin; both wire this factory with their own auth + roster.\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"],"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// 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 +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 app registry primary key).\n * 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"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAsEO,MAAM,oBAAoB;","names":[]}
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 /** 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"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAsEO,MAAM,oBAAoB;","names":[]}
@@ -18,8 +18,8 @@ export type TenantPublicConfig = {
18
18
  /** AWS region. Default: us-east-1. */
19
19
  region: string;
20
20
  /**
21
- * For spoke apps: this spoke's slug (matches app registry primary key).
22
- * For apex: undefined.
21
+ * For spoke apps: this spoke's slug (matches the tenant roster entry's
22
+ * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.
23
23
  */
24
24
  appSlug?: string;
25
25
  /**
@@ -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 app registry primary key).\n * 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"],"mappings":"AAsEO,MAAM,oBAAoB;","names":[]}
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 /** 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"],"mappings":"AAsEO,MAAM,oBAAoB;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augmenting-integrations/platform",
3
- "version": "8.6.0",
3
+ "version": "8.7.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": {