@augmenting-integrations/platform 8.5.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.
@@ -0,0 +1,44 @@
1
+ import "server-only";
2
+ import { type AppsRoster } from "../apps-roster/schema.js";
3
+ import type { TenantPublicConfig, TenantServerConfig } from "../tenant-types.js";
4
+ type SessionLike = {
5
+ user?: {
6
+ groups?: string[] | null;
7
+ } | null;
8
+ } | null;
9
+ type AuthFn = () => Promise<SessionLike>;
10
+ export type CreateAppsRouteHandlerOptions = {
11
+ /** Roster shape, typically `import appsJson from "../../config/apps.json"`. */
12
+ roster: AppsRoster | unknown;
13
+ /** Consuming app's `auth()` function. */
14
+ auth: AuthFn;
15
+ /**
16
+ * Tenant config (apex + optional appDomain). Used to derive each app's
17
+ * absolute `appUrl` from its subdomain. Typically the same struct passed
18
+ * to createAuth.
19
+ */
20
+ tenant: Pick<TenantServerConfig, "apex"> | Pick<TenantPublicConfig, "apex">;
21
+ /** Set false to make the endpoint public (NOT recommended). Default true. */
22
+ requireAuth?: boolean;
23
+ };
24
+ export declare function createAppsRouteHandler(opts: CreateAppsRouteHandlerOptions): {
25
+ GET: () => Promise<Response>;
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
+ };
43
+ export {};
44
+ //# sourceMappingURL=apps-route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apps-route.d.ts","sourceRoot":"","sources":["../../src/server/apps-route.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAC;AAErB,OAAO,EAKL,KAAK,UAAU,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAcjF,KAAK,WAAW,GAAG;IACjB,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;CAC5C,GAAG,IAAI,CAAC;AAET,KAAK,MAAM,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,CAAC;AAEzC,MAAM,MAAM,6BAA6B,GAAG;IAC1C,+EAA+E;IAC/E,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC;IAC7B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,EAAE,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;IAC5E,6EAA6E;IAC7E,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAOF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,6BAA6B;eAavD,OAAO,CAAC,QAAQ,CAAC;EA2BnC;AAkBD,MAAM,MAAM,kCAAkC,GAAG;IAC/C,4EAA4E;IAC5E,MAAM,EAAE,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;IAC5E;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;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,8 @@ var server_exports = {};
32
32
  __export(server_exports, {
33
33
  TENANT_GLOBAL_KEY: () => TENANT_GLOBAL_KEY,
34
34
  TenantBootScript: () => TenantBootScript,
35
+ createAppsProxyRouteHandler: () => createAppsProxyRouteHandler,
36
+ createAppsRouteHandler: () => createAppsRouteHandler,
35
37
  loadTenantConfig: () => loadTenantConfig,
36
38
  publicSubset: () => publicSubset
37
39
  });
@@ -119,10 +121,230 @@ function TenantBootScript({ config }) {
119
121
  props[INNER_HTML_PROP] = { __html: body };
120
122
  return React.createElement("script", props);
121
123
  }
124
+
125
+ // src/server/apps-route.ts
126
+ var import_server_only2 = require("server-only");
127
+
128
+ // src/apps-roster/schema.ts
129
+ var ROLES = ["apex", "spoke"];
130
+ function validateAppsRoster(raw) {
131
+ const errors = [];
132
+ if (typeof raw !== "object" || raw === null) {
133
+ return { ok: false, errors: [{ path: "", message: "roster must be an object" }] };
134
+ }
135
+ const m = raw;
136
+ if (!Array.isArray(m.apps)) {
137
+ return { ok: false, errors: [{ path: "apps", message: "expected array" }] };
138
+ }
139
+ const apps = m.apps;
140
+ const seenSlugs = /* @__PURE__ */ new Map();
141
+ const seenSubdomains = /* @__PURE__ */ new Map();
142
+ const seenNavOrder = /* @__PURE__ */ new Map();
143
+ let apexCount = 0;
144
+ apps.forEach((entryUnknown, i) => {
145
+ const path = `apps[${i}]`;
146
+ if (typeof entryUnknown !== "object" || entryUnknown === null) {
147
+ errors.push({ path, message: "expected object" });
148
+ return;
149
+ }
150
+ const entry = entryUnknown;
151
+ if (typeof entry.slug !== "string" || entry.slug === "") {
152
+ errors.push({ path: `${path}.slug`, message: "expected non-empty string" });
153
+ } else {
154
+ const prior = seenSlugs.get(entry.slug);
155
+ if (prior !== void 0) {
156
+ errors.push({
157
+ path: `${path}.slug`,
158
+ message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`
159
+ });
160
+ } else {
161
+ seenSlugs.set(entry.slug, i);
162
+ }
163
+ }
164
+ if (typeof entry.role !== "string" || !ROLES.includes(entry.role)) {
165
+ errors.push({
166
+ path: `${path}.role`,
167
+ message: `expected one of: ${ROLES.join(", ")}`
168
+ });
169
+ } else if (entry.role === "apex") {
170
+ apexCount++;
171
+ }
172
+ if (typeof entry.subdomain !== "string") {
173
+ errors.push({ path: `${path}.subdomain`, message: "expected string" });
174
+ } else {
175
+ if (entry.role === "apex" && entry.subdomain !== "") {
176
+ errors.push({
177
+ path: `${path}.subdomain`,
178
+ message: "apex apps must have empty subdomain"
179
+ });
180
+ }
181
+ if (entry.subdomain !== "") {
182
+ const prior = seenSubdomains.get(entry.subdomain);
183
+ if (prior !== void 0) {
184
+ errors.push({
185
+ path: `${path}.subdomain`,
186
+ message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`
187
+ });
188
+ } else {
189
+ seenSubdomains.set(entry.subdomain, i);
190
+ }
191
+ }
192
+ }
193
+ if (typeof entry.displayName !== "string" || entry.displayName === "") {
194
+ errors.push({
195
+ path: `${path}.displayName`,
196
+ message: "expected non-empty string"
197
+ });
198
+ }
199
+ if (typeof entry.navOrder !== "number" || !Number.isFinite(entry.navOrder)) {
200
+ errors.push({ path: `${path}.navOrder`, message: "expected number" });
201
+ } else {
202
+ const prior = seenNavOrder.get(entry.navOrder);
203
+ if (prior !== void 0) {
204
+ errors.push({
205
+ path: `${path}.navOrder`,
206
+ message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`
207
+ });
208
+ } else {
209
+ seenNavOrder.set(entry.navOrder, i);
210
+ }
211
+ }
212
+ if (!Array.isArray(entry.requiredIdentityGroups) || entry.requiredIdentityGroups.some((g) => typeof g !== "string")) {
213
+ errors.push({
214
+ path: `${path}.requiredIdentityGroups`,
215
+ message: "expected string[]"
216
+ });
217
+ }
218
+ if (entry.enabled !== void 0 && typeof entry.enabled !== "boolean") {
219
+ errors.push({ path: `${path}.enabled`, message: "expected boolean" });
220
+ }
221
+ });
222
+ if (apexCount === 0) {
223
+ errors.push({ path: "apps", message: "roster must contain exactly one apex entry" });
224
+ } else if (apexCount > 1) {
225
+ errors.push({
226
+ path: "apps",
227
+ message: `roster must contain exactly one apex entry, found ${apexCount}`
228
+ });
229
+ }
230
+ if (errors.length > 0) return { ok: false, errors };
231
+ return { ok: true, value: m };
232
+ }
233
+ function filterAppsByIdentityGroups(apps, userGroups) {
234
+ const lower = userGroups.map((g) => g.toLowerCase());
235
+ return apps.filter((a) => {
236
+ if (a.enabled === false) return false;
237
+ if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;
238
+ return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));
239
+ });
240
+ }
241
+ function sortAppsByNavOrder(apps) {
242
+ return [...apps].sort(
243
+ (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug)
244
+ );
245
+ }
246
+
247
+ // src/server/apps-route.ts
248
+ function deriveAppUrl(app, apex) {
249
+ if (app.subdomain === "") return `https://${apex}`;
250
+ return `https://${app.subdomain}.${apex}`;
251
+ }
252
+ function createAppsRouteHandler(opts) {
253
+ const validated = validateAppsRoster(opts.roster);
254
+ if (!validated.ok) {
255
+ throw new Error(
256
+ `createAppsRouteHandler: roster failed validation: ${validated.errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`
257
+ );
258
+ }
259
+ const apps = validated.value.apps;
260
+ const requireAuth = opts.requireAuth ?? true;
261
+ return {
262
+ GET: async () => {
263
+ let session = null;
264
+ if (requireAuth) {
265
+ session = await opts.auth();
266
+ if (!session) {
267
+ return Response.json({ error: "unauthenticated" }, { status: 401 });
268
+ }
269
+ }
270
+ const userGroups = session?.user?.groups ?? [];
271
+ const visible = filterAppsByIdentityGroups(apps, userGroups);
272
+ const sorted = sortAppsByNavOrder(visible);
273
+ const withUrl = sorted.map((a) => ({
274
+ slug: a.slug,
275
+ role: a.role,
276
+ subdomain: a.subdomain,
277
+ displayName: a.displayName,
278
+ navOrder: a.navOrder,
279
+ requiredIdentityGroups: a.requiredIdentityGroups,
280
+ appUrl: deriveAppUrl(a, opts.tenant.apex)
281
+ }));
282
+ return Response.json(withUrl, {
283
+ headers: {
284
+ "Cache-Control": "private, s-maxage=300, stale-while-revalidate=600"
285
+ }
286
+ });
287
+ }
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
+ }
122
342
  // Annotate the CommonJS export names for ESM import in node:
123
343
  0 && (module.exports = {
124
344
  TENANT_GLOBAL_KEY,
125
345
  TenantBootScript,
346
+ createAppsProxyRouteHandler,
347
+ createAppsRouteHandler,
126
348
  loadTenantConfig,
127
349
  publicSubset
128
350
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server.ts","../src/server/tenant.ts","../src/tenant-types.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\";\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"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/server.ts","../src/server/tenant.ts","../src/tenant-types.ts","../src/server/apps-route.ts","../src/apps-roster/schema.ts"],"sourcesContent":["export {\n loadTenantConfig,\n publicSubset,\n TenantBootScript,\n TENANT_GLOBAL_KEY,\n type LoadOptions,\n type TenantPublicConfig,\n type TenantServerConfig,\n type TenantRole,\n} from \"./server/tenant.js\";\nexport {\n createAppsRouteHandler,\n createAppsProxyRouteHandler,\n type CreateAppsRouteHandlerOptions,\n type CreateAppsProxyRouteHandlerOptions,\n} from \"./server/apps-route.js\";\n","import \"server-only\";\nimport * as React from \"react\";\nimport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantRole,\n type TenantServerConfig,\n} from \"../tenant-types.js\";\n\n// =============================================================================\n// loadTenantConfig() -- the single source of truth for tenant configuration.\n//\n// Every required process.env read happens here. Missing fields are surfaced\n// in ONE error message so the deploy fails loudly instead of silently\n// substituting undefined into a downstream package.\n//\n// Apex apps call loadTenantConfig({ role: \"apex\" }). Spoke apps call\n// loadTenantConfig({ role: \"spoke\" }). The required-field set differs:\n//\n// apex needs: apex, cookieDomain, parentDomain, region, authSecretArn,\n// registryTable, authCognitoSecretArn, cognitoIssuer,\n// cognitoClientId\n//\n// spoke needs: everything apex needs EXCEPT cognito creds, PLUS\n// appSlug, appDomain, dbSecretArn (or dbHost+dbName)\n// =============================================================================\n\nexport type LoadOptions = {\n role: TenantRole;\n /**\n * Override env reads with explicit values (useful for tests).\n */\n overrides?: Partial<TenantServerConfig>;\n};\n\n/**\n * Read tenant configuration from process.env with optional overrides.\n * Throws a single Error listing every missing required field.\n */\nexport function loadTenantConfig(opts: LoadOptions): TenantServerConfig {\n const env = process.env;\n const o = opts.overrides ?? {};\n\n const parentDomainRaw = o.parentDomain ?? env.AUTH_ALLOWED_PARENT_DOMAIN;\n const apexFallback = parentDomainRaw?.replace(/^\\./, \"\");\n\n const draft: Partial<TenantServerConfig> = {\n role: opts.role,\n apex: o.apex ?? env.APEX_DOMAIN ?? apexFallback,\n cookieDomain: o.cookieDomain ?? env.AUTH_COOKIE_DOMAIN,\n parentDomain: parentDomainRaw,\n region: o.region ?? env.AWS_REGION ?? \"us-east-1\",\n appSlug: o.appSlug ?? env.APP_SLUG,\n appDomain: o.appDomain ?? env.APP_DOMAIN,\n authSecretArn: o.authSecretArn ?? env.AUTH_SECRET_ARN,\n registryTable: o.registryTable ?? env.APP_REGISTRY_TABLE,\n authCognitoSecretArn: o.authCognitoSecretArn ?? env.AUTH_COGNITO_SECRET_ARN,\n cognitoIssuer: o.cognitoIssuer ?? env.AUTH_COGNITO_ISSUER,\n cognitoClientId: o.cognitoClientId ?? env.AUTH_COGNITO_ID,\n adminEmails: o.adminEmails ?? env.ADMIN_EMAILS,\n dbSecretArn: o.dbSecretArn ?? env.DB_SECRET_ARN,\n dbHost: o.dbHost ?? env.DB_HOST,\n dbName: o.dbName ?? env.DB_NAME,\n stripeSecretArn: o.stripeSecretArn ?? env.STRIPE_SECRET_ARN,\n stripeWebhookSecretArn: o.stripeWebhookSecretArn ?? env.STRIPE_WEBHOOK_SECRET_ARN,\n };\n\n if (opts.role === \"apex\" && !draft.appDomain) {\n draft.appDomain = draft.apex;\n }\n\n const required: Array<{ key: keyof TenantServerConfig; env: string }> = [\n { key: \"apex\", env: \"APEX_DOMAIN (or derived from AUTH_ALLOWED_PARENT_DOMAIN)\" },\n { key: \"cookieDomain\", env: \"AUTH_COOKIE_DOMAIN\" },\n { key: \"parentDomain\", env: \"AUTH_ALLOWED_PARENT_DOMAIN\" },\n { key: \"authSecretArn\", env: \"AUTH_SECRET_ARN\" },\n { key: \"appDomain\", env: \"APP_DOMAIN\" },\n ];\n if (opts.role === \"apex\") {\n required.push(\n { key: \"authCognitoSecretArn\", env: \"AUTH_COGNITO_SECRET_ARN\" },\n { key: \"cognitoIssuer\", env: \"AUTH_COGNITO_ISSUER\" },\n { key: \"cognitoClientId\", env: \"AUTH_COGNITO_ID\" },\n );\n } else {\n required.push({ key: \"appSlug\", env: \"APP_SLUG\" });\n }\n\n if (\n process.env.NEXT_PHASE === \"phase-production-build\" ||\n !process.env.AWS_LAMBDA_FUNCTION_NAME\n ) {\n return draft as TenantServerConfig;\n }\n\n const missing = required.filter((r) => !draft[r.key]).map((r) => r.env);\n if (missing.length > 0) {\n throw new Error(\n `loadTenantConfig(${opts.role}): missing required env vars: ${missing.join(\", \")}`,\n );\n }\n\n return draft as TenantServerConfig;\n}\n\n/**\n * Reduce a TenantServerConfig to the public-safe subset. Strips every\n * secret-arn so the result is safe to ship to the browser via\n * <TenantBootScript />.\n */\nexport function publicSubset(config: TenantServerConfig): TenantPublicConfig {\n return {\n apex: config.apex,\n cookieDomain: config.cookieDomain,\n parentDomain: config.parentDomain,\n region: config.region,\n appSlug: config.appSlug,\n appDomain: config.appDomain,\n role: config.role,\n };\n}\n\n// =============================================================================\n// <TenantBootScript /> -- server component that injects window.__TENANT__\n// before paint. Every client widget reads from this global.\n//\n// The payload is JSON.stringify of a TYPED struct -- we control every field\n// shape. The </script> escape protects against rare \"config contains\n// </script>\" payloads. The inner-html prop name is constructed at runtime\n// to keep static security scanners happy with the React idiom.\n// =============================================================================\n\nconst INNER_HTML_PROP = \"dangerously\" + \"SetInner\" + \"HTML\";\n\nexport function TenantBootScript({ config }: { config: TenantPublicConfig }) {\n const payload = JSON.stringify(config).replace(/</g, \"\\\\u003c\");\n const body = `window.${TENANT_GLOBAL_KEY}=${payload};`;\n const props: Record<string, unknown> = {};\n props[INNER_HTML_PROP] = { __html: body };\n return React.createElement(\"script\", props);\n}\n\nexport {\n TENANT_GLOBAL_KEY,\n type TenantPublicConfig,\n type TenantServerConfig,\n type TenantRole,\n} from \"../tenant-types.js\";\n","// =============================================================================\n// TenantConfig -- the single struct every @augmenting-integrations package\n// consumes. Apex apps and spokes share the same type; spoke-only fields are\n// optional. The `role` discriminator tells loadTenantConfig() which fields\n// to demand.\n//\n// Public fields (apex + parent domain + slug) are safe to ship to the browser\n// via <TenantBootScript />. Secret-arn fields are server-only and never reach\n// the client bundle.\n// =============================================================================\n\nexport type TenantRole = \"apex\" | \"spoke\";\n\nexport type TenantPublicConfig = {\n /** The tenant apex FQDN, e.g. \"agency.aillc.link\". */\n apex: string;\n /**\n * Cookie Domain attribute. Always the apex (no leading dot needed -- the\n * browser implies it for shared cookies). Auth.js session cookie and the\n * theme x-theme/x-theme-variant cookies use this. Without it cookies are\n * host-only and the subdomain ecosystem breaks.\n */\n cookieDomain: string;\n /**\n * The registrable parent domain (e.g. \"aillc.link\"). Used by the auth\n * redirect callback to validate post-login callbacks back to any subdomain\n * of the tenant. Distinct from cookieDomain in two-level apex setups.\n */\n parentDomain: string;\n /** AWS region. Default: us-east-1. */\n region: string;\n /**\n * For spoke apps: this spoke's slug (matches the tenant roster entry's\n * slug in <tenant>-infra/config/apps.yaml). For apex: undefined.\n */\n appSlug?: string;\n /**\n * For spoke apps: this spoke's FQDN (e.g. \"leads.agency.aillc.link\").\n * For apex: same as `apex`.\n */\n appDomain: string;\n /** \"apex\" or \"spoke\". Affects which secret-arn fields are required. */\n role: TenantRole;\n};\n\nexport type TenantServerConfig = TenantPublicConfig & {\n /** AUTH_SECRET ARN in Secrets Manager. Used by createAuth(). */\n authSecretArn: string;\n /** App registry DynamoDB table name. Apex owns the table; spokes read. */\n registryTable: string;\n /** Cognito client secret ARN. Apex only -- spokes don't run the OAuth dance. */\n authCognitoSecretArn?: string;\n /** Cognito issuer URL (apex only). */\n cognitoIssuer?: string;\n /** Cognito client ID (apex only). */\n cognitoClientId?: string;\n /** Comma-separated admin emails (auto-promoted on first sign-in). */\n adminEmails?: string;\n /** Aurora connection secret ARN (spoke only). */\n dbSecretArn?: string;\n /** Aurora endpoint host (spoke only). */\n dbHost?: string;\n /** Aurora database name (spoke only). */\n dbName?: string;\n /** Stripe credentials bundle ARN (spoke that does billing). */\n stripeSecretArn?: string;\n /** Stripe webhook signing secret ARN (spoke that does billing). */\n stripeWebhookSecretArn?: string;\n};\n\nexport const TENANT_GLOBAL_KEY = \"__TENANT__\" as const;\n\ndeclare global {\n interface Window {\n [TENANT_GLOBAL_KEY]?: TenantPublicConfig;\n }\n}\n","import \"server-only\";\n\nimport {\n filterAppsByIdentityGroups,\n sortAppsByNavOrder,\n validateAppsRoster,\n type TenantApp,\n type AppsRoster,\n} from \"../apps-roster/schema.js\";\nimport type { TenantPublicConfig, TenantServerConfig } from \"../tenant-types.js\";\n\n// =============================================================================\n// /api/apps route handler factories.\n//\n// The apex owns the canonical tenant roster (config/apps.json) and serves\n// it via `createAppsRouteHandler`. Spokes own no roster -- their\n// /api/apps is a proxy to the apex via `createAppsProxyRouteHandler`, so\n// adding a new spoke does NOT require redeploying every existing spoke.\n//\n// Both handlers serve AppShell same-origin, so the browser-side fetch\n// stays simple (`fetch(\"/api/apps\")` with cookie credentials).\n// =============================================================================\n\ntype SessionLike = {\n user?: { groups?: string[] | null } | null;\n} | null;\n\ntype AuthFn = () => Promise<SessionLike>;\n\nexport type CreateAppsRouteHandlerOptions = {\n /** Roster shape, typically `import appsJson from \"../../config/apps.json\"`. */\n roster: AppsRoster | unknown;\n /** Consuming app's `auth()` function. */\n auth: AuthFn;\n /**\n * Tenant config (apex + optional appDomain). Used to derive each app's\n * absolute `appUrl` from its subdomain. Typically the same struct passed\n * to createAuth.\n */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /** Set false to make the endpoint public (NOT recommended). Default true. */\n requireAuth?: boolean;\n};\n\nfunction deriveAppUrl(app: TenantApp, apex: string): string {\n if (app.subdomain === \"\") return `https://${apex}`;\n return `https://${app.subdomain}.${apex}`;\n}\n\nexport function createAppsRouteHandler(opts: CreateAppsRouteHandlerOptions) {\n const validated = validateAppsRoster(opts.roster);\n if (!validated.ok) {\n throw new Error(\n `createAppsRouteHandler: roster failed validation: ${validated.errors\n .map((e) => `${e.path}: ${e.message}`)\n .join(\"; \")}`,\n );\n }\n const apps: TenantApp[] = validated.value.apps;\n const requireAuth = opts.requireAuth ?? true;\n\n return {\n GET: async (): Promise<Response> => {\n let session: SessionLike = null;\n if (requireAuth) {\n session = await opts.auth();\n if (!session) {\n return Response.json({ error: \"unauthenticated\" }, { status: 401 });\n }\n }\n const userGroups = session?.user?.groups ?? [];\n const visible = filterAppsByIdentityGroups(apps, userGroups);\n const sorted = sortAppsByNavOrder(visible);\n const withUrl = sorted.map((a) => ({\n slug: a.slug,\n role: a.role,\n subdomain: a.subdomain,\n displayName: a.displayName,\n navOrder: a.navOrder,\n requiredIdentityGroups: a.requiredIdentityGroups,\n appUrl: deriveAppUrl(a, opts.tenant.apex),\n }));\n return Response.json(withUrl, {\n headers: {\n \"Cache-Control\": \"private, s-maxage=300, stale-while-revalidate=600\",\n },\n });\n },\n };\n}\n\n// =============================================================================\n// createAppsProxyRouteHandler\n//\n// Spoke-side /api/apps handler. Forwards the user's request (Cookie header\n// in particular) to the apex's /api/apps endpoint and proxies the response\n// back, preserving status, content-type, and cache headers.\n//\n// The session cookie is parent-domain-scoped (Domain=.<apex>) so the\n// browser sends it on the spoke's same-origin request; we forward that\n// cookie on the server-to-server fetch to the apex so the apex's\n// authenticated handler sees the same user. No CORS involved.\n//\n// Spokes that use this factory ship no roster file -- the canonical\n// roster lives only in the apex.\n// =============================================================================\n\nexport type CreateAppsProxyRouteHandlerOptions = {\n /** Same tenant struct passed to createAuth. Used to derive the apex URL. */\n tenant: Pick<TenantServerConfig, \"apex\"> | Pick<TenantPublicConfig, \"apex\">;\n /**\n * Override the upstream URL. Default: `https://${tenant.apex}/api/apps`.\n */\n upstreamUrl?: string;\n /**\n * Headers to forward from the incoming request, lowercase keys. Default\n * forwards `cookie`, `authorization`, `x-forwarded-for`, and `user-agent`.\n */\n forwardHeaders?: readonly string[];\n};\n\nconst DEFAULT_FORWARD_HEADERS = [\n \"cookie\",\n \"authorization\",\n \"x-forwarded-for\",\n \"user-agent\",\n] as const;\n\nconst DEFAULT_RESPONSE_HEADERS = [\n \"content-type\",\n \"cache-control\",\n \"vary\",\n \"etag\",\n] as const;\n\nexport function createAppsProxyRouteHandler(opts: CreateAppsProxyRouteHandlerOptions) {\n const upstream = opts.upstreamUrl ?? `https://${opts.tenant.apex}/api/apps`;\n const forward = opts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;\n\n return {\n GET: async (request: Request): Promise<Response> => {\n const headers = new Headers();\n for (const name of forward) {\n const v = request.headers.get(name);\n if (v) headers.set(name, v);\n }\n try {\n const upstreamResponse = await fetch(upstream, {\n method: \"GET\",\n headers,\n cache: \"no-store\",\n redirect: \"manual\",\n });\n const body = await upstreamResponse.arrayBuffer();\n const responseHeaders = new Headers();\n for (const name of DEFAULT_RESPONSE_HEADERS) {\n const v = upstreamResponse.headers.get(name);\n if (v) responseHeaders.set(name, v);\n }\n return new Response(body, {\n status: upstreamResponse.status,\n headers: responseHeaders,\n });\n } catch (err) {\n return Response.json(\n {\n error: \"apps_proxy_unavailable\",\n message: err instanceof Error ? err.message : String(err),\n upstream,\n },\n { status: 503 },\n );\n }\n },\n };\n}\n","// =============================================================================\n// Tenant app roster.\n//\n// One file per tenant that lists every app (apex + spokes) the tenant\n// ecosystem contains. Replaces the runtime DynamoDB registry. Stored as\n// YAML in <tenant>-infra/config/apps.yaml (canonical) and mirrored to\n// <tenant>-apex/config/apps.json for runtime serving by /api/apps.\n//\n// Adding a new spoke = a PR to the spoke repo (its app.manifest.json) +\n// a PR to <tenant>-infra/config/apps.yaml + a PR to <tenant>-apex/config/\n// apps.json. Validation catches drift.\n// =============================================================================\n\nexport type TenantAppRole = \"apex\" | \"spoke\";\n\nexport type TenantApp = {\n /** Stable identifier. Matches the spoke's app.manifest.json#appSlug. */\n slug: string;\n /** \"apex\" (auth broker) or \"spoke\" (product app). */\n role: TenantAppRole;\n /** DNS label. Empty string for apex. */\n subdomain: string;\n /** Human-friendly name. Drives the shared nav. */\n displayName: string;\n /** Sort order. Lower comes first. */\n navOrder: number;\n /**\n * Cognito identity groups required to see this app in cross-app nav\n * AND to enter its routes (when the spoke's createAuth is wired to its\n * own manifest's access policy). Empty = all authenticated users.\n */\n requiredIdentityGroups: string[];\n /**\n * Static feature toggle. Default true. Set false to hide an app from\n * cross-app nav without removing the entry. Editing this requires a\n * PR + redeploy -- this is NOT mutable runtime state.\n */\n enabled?: boolean;\n};\n\nexport type AppsRoster = {\n apps: TenantApp[];\n};\n\nexport type RosterValidationError = {\n path: string;\n message: string;\n};\n\nconst ROLES: readonly string[] = [\"apex\", \"spoke\"];\n\n/**\n * Pure validator for the roster object (parsed from YAML or JSON). Returns\n * the typed roster on success, or an array of errors on failure. No throws.\n */\nexport function validateAppsRoster(\n raw: unknown,\n): { ok: true; value: AppsRoster } | { ok: false; errors: RosterValidationError[] } {\n const errors: RosterValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"roster must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n if (!Array.isArray(m.apps)) {\n return { ok: false, errors: [{ path: \"apps\", message: \"expected array\" }] };\n }\n const apps = m.apps as unknown[];\n\n const seenSlugs = new Map<string, number>();\n const seenSubdomains = new Map<string, number>();\n const seenNavOrder = new Map<number, number>();\n let apexCount = 0;\n\n apps.forEach((entryUnknown, i) => {\n const path = `apps[${i}]`;\n if (typeof entryUnknown !== \"object\" || entryUnknown === null) {\n errors.push({ path, message: \"expected object\" });\n return;\n }\n const entry = entryUnknown as Record<string, unknown>;\n\n if (typeof entry.slug !== \"string\" || entry.slug === \"\") {\n errors.push({ path: `${path}.slug`, message: \"expected non-empty string\" });\n } else {\n const prior = seenSlugs.get(entry.slug);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.slug`,\n message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`,\n });\n } else {\n seenSlugs.set(entry.slug, i);\n }\n }\n\n if (typeof entry.role !== \"string\" || !ROLES.includes(entry.role)) {\n errors.push({\n path: `${path}.role`,\n message: `expected one of: ${ROLES.join(\", \")}`,\n });\n } else if (entry.role === \"apex\") {\n apexCount++;\n }\n\n if (typeof entry.subdomain !== \"string\") {\n errors.push({ path: `${path}.subdomain`, message: \"expected string\" });\n } else {\n if (entry.role === \"apex\" && entry.subdomain !== \"\") {\n errors.push({\n path: `${path}.subdomain`,\n message: \"apex apps must have empty subdomain\",\n });\n }\n if (entry.subdomain !== \"\") {\n const prior = seenSubdomains.get(entry.subdomain);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.subdomain`,\n message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`,\n });\n } else {\n seenSubdomains.set(entry.subdomain, i);\n }\n }\n }\n\n if (typeof entry.displayName !== \"string\" || entry.displayName === \"\") {\n errors.push({\n path: `${path}.displayName`,\n message: \"expected non-empty string\",\n });\n }\n\n if (typeof entry.navOrder !== \"number\" || !Number.isFinite(entry.navOrder)) {\n errors.push({ path: `${path}.navOrder`, message: \"expected number\" });\n } else {\n const prior = seenNavOrder.get(entry.navOrder);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.navOrder`,\n message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`,\n });\n } else {\n seenNavOrder.set(entry.navOrder, i);\n }\n }\n\n if (\n !Array.isArray(entry.requiredIdentityGroups) ||\n entry.requiredIdentityGroups.some((g) => typeof g !== \"string\")\n ) {\n errors.push({\n path: `${path}.requiredIdentityGroups`,\n message: \"expected string[]\",\n });\n }\n\n if (entry.enabled !== undefined && typeof entry.enabled !== \"boolean\") {\n errors.push({ path: `${path}.enabled`, message: \"expected boolean\" });\n }\n });\n\n if (apexCount === 0) {\n errors.push({ path: \"apps\", message: \"roster must contain exactly one apex entry\" });\n } else if (apexCount > 1) {\n errors.push({\n path: \"apps\",\n message: `roster must contain exactly one apex entry, found ${apexCount}`,\n });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppsRoster };\n}\n\n/**\n * Filter the roster by user identity groups. Apps with empty\n * `requiredIdentityGroups` are visible to all authenticated users; otherwise\n * the user must be in at least one of the listed groups. `enabled: false`\n * apps are always filtered out.\n */\nexport function filterAppsByIdentityGroups(\n apps: TenantApp[],\n userGroups: string[],\n): TenantApp[] {\n const lower = userGroups.map((g) => g.toLowerCase());\n return apps.filter((a) => {\n if (a.enabled === false) return false;\n if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;\n return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));\n });\n}\n\n/** Sort apps by navOrder ASC, then slug. Mutates a copy, returns it. */\nexport function sortAppsByNavOrder<T extends Pick<TenantApp, \"navOrder\" | \"slug\">>(\n apps: T[],\n): T[] {\n return [...apps].sort(\n (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug),\n );\n}\n"],"mappings":"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","names":["import_server_only"]}
package/dist/server.d.ts CHANGED
@@ -1,2 +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, createAppsProxyRouteHandler, type CreateAppsRouteHandlerOptions, type CreateAppsProxyRouteHandlerOptions, } from "./server/apps-route.js";
2
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"}
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
@@ -1,3 +1,9 @@
1
+ import {
2
+ filterAppsByIdentityGroups,
3
+ sortAppsByNavOrder,
4
+ validateAppsRoster
5
+ } from "./chunk-ZJFI7R4O.js";
6
+
1
7
  // src/server/tenant.ts
2
8
  import "server-only";
3
9
  import * as React from "react";
@@ -80,9 +86,108 @@ function TenantBootScript({ config }) {
80
86
  props[INNER_HTML_PROP] = { __html: body };
81
87
  return React.createElement("script", props);
82
88
  }
89
+
90
+ // src/server/apps-route.ts
91
+ import "server-only";
92
+ function deriveAppUrl(app, apex) {
93
+ if (app.subdomain === "") return `https://${apex}`;
94
+ return `https://${app.subdomain}.${apex}`;
95
+ }
96
+ function createAppsRouteHandler(opts) {
97
+ const validated = validateAppsRoster(opts.roster);
98
+ if (!validated.ok) {
99
+ throw new Error(
100
+ `createAppsRouteHandler: roster failed validation: ${validated.errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`
101
+ );
102
+ }
103
+ const apps = validated.value.apps;
104
+ const requireAuth = opts.requireAuth ?? true;
105
+ return {
106
+ GET: async () => {
107
+ let session = null;
108
+ if (requireAuth) {
109
+ session = await opts.auth();
110
+ if (!session) {
111
+ return Response.json({ error: "unauthenticated" }, { status: 401 });
112
+ }
113
+ }
114
+ const userGroups = session?.user?.groups ?? [];
115
+ const visible = filterAppsByIdentityGroups(apps, userGroups);
116
+ const sorted = sortAppsByNavOrder(visible);
117
+ const withUrl = sorted.map((a) => ({
118
+ slug: a.slug,
119
+ role: a.role,
120
+ subdomain: a.subdomain,
121
+ displayName: a.displayName,
122
+ navOrder: a.navOrder,
123
+ requiredIdentityGroups: a.requiredIdentityGroups,
124
+ appUrl: deriveAppUrl(a, opts.tenant.apex)
125
+ }));
126
+ return Response.json(withUrl, {
127
+ headers: {
128
+ "Cache-Control": "private, s-maxage=300, stale-while-revalidate=600"
129
+ }
130
+ });
131
+ }
132
+ };
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
+ }
83
186
  export {
84
187
  TENANT_GLOBAL_KEY,
85
188
  TenantBootScript,
189
+ createAppsProxyRouteHandler,
190
+ createAppsRouteHandler,
86
191
  loadTenantConfig,
87
192
  publicSubset
88
193
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server/tenant.ts","../src/tenant-types.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"],"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":";;;;;;;AAAA,OAAO;AACP,YAAY,WAAW;;;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,OAAO;AA4CP,SAAS,aAAa,KAAgB,MAAsB;AAC1D,MAAI,IAAI,cAAc,GAAI,QAAO,WAAW,IAAI;AAChD,SAAO,WAAW,IAAI,SAAS,IAAI,IAAI;AACzC;AAEO,SAAS,uBAAuB,MAAqC;AAC1E,QAAM,YAAY,mBAAmB,KAAK,MAAM;AAChD,MAAI,CAAC,UAAU,IAAI;AACjB,UAAM,IAAI;AAAA,MACR,qDAAqD,UAAU,OAC5D,IAAI,CAAC,MAAM,GAAG,EAAE,IAAI,KAAK,EAAE,OAAO,EAAE,EACpC,KAAK,IAAI,CAAC;AAAA,IACf;AAAA,EACF;AACA,QAAM,OAAoB,UAAU,MAAM;AAC1C,QAAM,cAAc,KAAK,eAAe;AAExC,SAAO;AAAA,IACL,KAAK,YAA+B;AAClC,UAAI,UAAuB;AAC3B,UAAI,aAAa;AACf,kBAAU,MAAM,KAAK,KAAK;AAC1B,YAAI,CAAC,SAAS;AACZ,iBAAO,SAAS,KAAK,EAAE,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,QACpE;AAAA,MACF;AACA,YAAM,aAAa,SAAS,MAAM,UAAU,CAAC;AAC7C,YAAM,UAAU,2BAA2B,MAAM,UAAU;AAC3D,YAAM,SAAS,mBAAmB,OAAO;AACzC,YAAM,UAAU,OAAO,IAAI,CAAC,OAAO;AAAA,QACjC,MAAM,EAAE;AAAA,QACR,MAAM,EAAE;AAAA,QACR,WAAW,EAAE;AAAA,QACb,aAAa,EAAE;AAAA,QACf,UAAU,EAAE;AAAA,QACZ,wBAAwB,EAAE;AAAA,QAC1B,QAAQ,aAAa,GAAG,KAAK,OAAO,IAAI;AAAA,MAC1C,EAAE;AACF,aAAO,SAAS,KAAK,SAAS;AAAA,QAC5B,SAAS;AAAA,UACP,iBAAiB;AAAA,QACnB;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACF;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":[]}
@@ -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
  /**