@augmenting-integrations/platform 8.5.0 → 8.6.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,15 @@
1
+ import { type AppsRoster } from "./schema.js";
2
+ export type LoadAppsRosterOptions = {
3
+ /** Absolute path to a JSON apps-roster file. */
4
+ path: string;
5
+ };
6
+ /**
7
+ * Read + validate a JSON apps-roster file. Throws with a consolidated error
8
+ * message listing every validation failure.
9
+ *
10
+ * YAML parsing is intentionally not bundled in this package; use
11
+ * @augmenting-integrations/deploy-tools `augint validate-app-roster` for the
12
+ * cross-format check between the infra YAML source and the apex JSON mirror.
13
+ */
14
+ export declare function loadAppsRosterJson(opts: LoadAppsRosterOptions): AppsRoster;
15
+ //# sourceMappingURL=load.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load.d.ts","sourceRoot":"","sources":["../../src/apps-roster/load.ts"],"names":[],"mappings":"AAGA,OAAO,EAAsB,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAElE,MAAM,MAAM,qBAAqB,GAAG;IAClC,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,qBAAqB,GAAG,UAAU,CA0B1E"}
@@ -0,0 +1,53 @@
1
+ export type TenantAppRole = "apex" | "spoke";
2
+ export type TenantApp = {
3
+ /** Stable identifier. Matches the spoke's app.manifest.json#appSlug. */
4
+ slug: string;
5
+ /** "apex" (auth broker) or "spoke" (product app). */
6
+ role: TenantAppRole;
7
+ /** DNS label. Empty string for apex. */
8
+ subdomain: string;
9
+ /** Human-friendly name. Drives the shared nav. */
10
+ displayName: string;
11
+ /** Sort order. Lower comes first. */
12
+ navOrder: number;
13
+ /**
14
+ * Cognito identity groups required to see this app in cross-app nav
15
+ * AND to enter its routes (when the spoke's createAuth is wired to its
16
+ * own manifest's access policy). Empty = all authenticated users.
17
+ */
18
+ requiredIdentityGroups: string[];
19
+ /**
20
+ * Static feature toggle. Default true. Set false to hide an app from
21
+ * cross-app nav without removing the entry. Editing this requires a
22
+ * PR + redeploy -- this is NOT mutable runtime state.
23
+ */
24
+ enabled?: boolean;
25
+ };
26
+ export type AppsRoster = {
27
+ apps: TenantApp[];
28
+ };
29
+ export type RosterValidationError = {
30
+ path: string;
31
+ message: string;
32
+ };
33
+ /**
34
+ * Pure validator for the roster object (parsed from YAML or JSON). Returns
35
+ * the typed roster on success, or an array of errors on failure. No throws.
36
+ */
37
+ export declare function validateAppsRoster(raw: unknown): {
38
+ ok: true;
39
+ value: AppsRoster;
40
+ } | {
41
+ ok: false;
42
+ errors: RosterValidationError[];
43
+ };
44
+ /**
45
+ * Filter the roster by user identity groups. Apps with empty
46
+ * `requiredIdentityGroups` are visible to all authenticated users; otherwise
47
+ * the user must be in at least one of the listed groups. `enabled: false`
48
+ * apps are always filtered out.
49
+ */
50
+ export declare function filterAppsByIdentityGroups(apps: TenantApp[], userGroups: string[]): TenantApp[];
51
+ /** Sort apps by navOrder ASC, then slug. Mutates a copy, returns it. */
52
+ export declare function sortAppsByNavOrder<T extends Pick<TenantApp, "navOrder" | "slug">>(apps: T[]): T[];
53
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/apps-roster/schema.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,CAAC;AAE7C,MAAM,MAAM,SAAS,GAAG;IACtB,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,IAAI,EAAE,aAAa,CAAC;IACpB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,qCAAqC;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,sBAAsB,EAAE,MAAM,EAAE,CAAC;IACjC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,SAAS,EAAE,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAIF;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,OAAO,GACX;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,qBAAqB,EAAE,CAAA;CAAE,CAoHlF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,SAAS,EAAE,EACjB,UAAU,EAAE,MAAM,EAAE,GACnB,SAAS,EAAE,CAOb;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,IAAI,CAAC,SAAS,EAAE,UAAU,GAAG,MAAM,CAAC,EAC/E,IAAI,EAAE,CAAC,EAAE,GACR,CAAC,EAAE,CAIL"}
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/apps-roster.ts
21
+ var apps_roster_exports = {};
22
+ __export(apps_roster_exports, {
23
+ filterAppsByIdentityGroups: () => filterAppsByIdentityGroups,
24
+ loadAppsRosterJson: () => loadAppsRosterJson,
25
+ sortAppsByNavOrder: () => sortAppsByNavOrder,
26
+ validateAppsRoster: () => validateAppsRoster
27
+ });
28
+ module.exports = __toCommonJS(apps_roster_exports);
29
+
30
+ // src/apps-roster/schema.ts
31
+ var ROLES = ["apex", "spoke"];
32
+ function validateAppsRoster(raw) {
33
+ const errors = [];
34
+ if (typeof raw !== "object" || raw === null) {
35
+ return { ok: false, errors: [{ path: "", message: "roster must be an object" }] };
36
+ }
37
+ const m = raw;
38
+ if (!Array.isArray(m.apps)) {
39
+ return { ok: false, errors: [{ path: "apps", message: "expected array" }] };
40
+ }
41
+ const apps = m.apps;
42
+ const seenSlugs = /* @__PURE__ */ new Map();
43
+ const seenSubdomains = /* @__PURE__ */ new Map();
44
+ const seenNavOrder = /* @__PURE__ */ new Map();
45
+ let apexCount = 0;
46
+ apps.forEach((entryUnknown, i) => {
47
+ const path = `apps[${i}]`;
48
+ if (typeof entryUnknown !== "object" || entryUnknown === null) {
49
+ errors.push({ path, message: "expected object" });
50
+ return;
51
+ }
52
+ const entry = entryUnknown;
53
+ if (typeof entry.slug !== "string" || entry.slug === "") {
54
+ errors.push({ path: `${path}.slug`, message: "expected non-empty string" });
55
+ } else {
56
+ const prior = seenSlugs.get(entry.slug);
57
+ if (prior !== void 0) {
58
+ errors.push({
59
+ path: `${path}.slug`,
60
+ message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`
61
+ });
62
+ } else {
63
+ seenSlugs.set(entry.slug, i);
64
+ }
65
+ }
66
+ if (typeof entry.role !== "string" || !ROLES.includes(entry.role)) {
67
+ errors.push({
68
+ path: `${path}.role`,
69
+ message: `expected one of: ${ROLES.join(", ")}`
70
+ });
71
+ } else if (entry.role === "apex") {
72
+ apexCount++;
73
+ }
74
+ if (typeof entry.subdomain !== "string") {
75
+ errors.push({ path: `${path}.subdomain`, message: "expected string" });
76
+ } else {
77
+ if (entry.role === "apex" && entry.subdomain !== "") {
78
+ errors.push({
79
+ path: `${path}.subdomain`,
80
+ message: "apex apps must have empty subdomain"
81
+ });
82
+ }
83
+ if (entry.subdomain !== "") {
84
+ const prior = seenSubdomains.get(entry.subdomain);
85
+ if (prior !== void 0) {
86
+ errors.push({
87
+ path: `${path}.subdomain`,
88
+ message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`
89
+ });
90
+ } else {
91
+ seenSubdomains.set(entry.subdomain, i);
92
+ }
93
+ }
94
+ }
95
+ if (typeof entry.displayName !== "string" || entry.displayName === "") {
96
+ errors.push({
97
+ path: `${path}.displayName`,
98
+ message: "expected non-empty string"
99
+ });
100
+ }
101
+ if (typeof entry.navOrder !== "number" || !Number.isFinite(entry.navOrder)) {
102
+ errors.push({ path: `${path}.navOrder`, message: "expected number" });
103
+ } else {
104
+ const prior = seenNavOrder.get(entry.navOrder);
105
+ if (prior !== void 0) {
106
+ errors.push({
107
+ path: `${path}.navOrder`,
108
+ message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`
109
+ });
110
+ } else {
111
+ seenNavOrder.set(entry.navOrder, i);
112
+ }
113
+ }
114
+ if (!Array.isArray(entry.requiredIdentityGroups) || entry.requiredIdentityGroups.some((g) => typeof g !== "string")) {
115
+ errors.push({
116
+ path: `${path}.requiredIdentityGroups`,
117
+ message: "expected string[]"
118
+ });
119
+ }
120
+ if (entry.enabled !== void 0 && typeof entry.enabled !== "boolean") {
121
+ errors.push({ path: `${path}.enabled`, message: "expected boolean" });
122
+ }
123
+ });
124
+ if (apexCount === 0) {
125
+ errors.push({ path: "apps", message: "roster must contain exactly one apex entry" });
126
+ } else if (apexCount > 1) {
127
+ errors.push({
128
+ path: "apps",
129
+ message: `roster must contain exactly one apex entry, found ${apexCount}`
130
+ });
131
+ }
132
+ if (errors.length > 0) return { ok: false, errors };
133
+ return { ok: true, value: m };
134
+ }
135
+ function filterAppsByIdentityGroups(apps, userGroups) {
136
+ const lower = userGroups.map((g) => g.toLowerCase());
137
+ return apps.filter((a) => {
138
+ if (a.enabled === false) return false;
139
+ if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;
140
+ return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));
141
+ });
142
+ }
143
+ function sortAppsByNavOrder(apps) {
144
+ return [...apps].sort(
145
+ (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug)
146
+ );
147
+ }
148
+
149
+ // src/apps-roster/load.ts
150
+ var import_node_fs = require("fs");
151
+ var import_node_path = require("path");
152
+ function loadAppsRosterJson(opts) {
153
+ const file = (0, import_node_path.resolve)(opts.path);
154
+ let raw;
155
+ try {
156
+ raw = (0, import_node_fs.readFileSync)(file, "utf8");
157
+ } catch (err) {
158
+ throw new Error(
159
+ `loadAppsRosterJson: cannot read ${file}: ${err.message}`,
160
+ { cause: err }
161
+ );
162
+ }
163
+ let parsed;
164
+ try {
165
+ parsed = JSON.parse(raw);
166
+ } catch (err) {
167
+ throw new Error(
168
+ `loadAppsRosterJson: invalid JSON in ${file}: ${err.message}`,
169
+ { cause: err }
170
+ );
171
+ }
172
+ const result = validateAppsRoster(parsed);
173
+ if (!result.ok) {
174
+ const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
175
+ throw new Error(`loadAppsRosterJson: ${file} failed validation:
176
+ ${lines}`);
177
+ }
178
+ return result.value;
179
+ }
180
+ // Annotate the CommonJS export names for ESM import in node:
181
+ 0 && (module.exports = {
182
+ filterAppsByIdentityGroups,
183
+ loadAppsRosterJson,
184
+ sortAppsByNavOrder,
185
+ validateAppsRoster
186
+ });
187
+ //# sourceMappingURL=apps-roster.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/apps-roster.ts","../src/apps-roster/schema.ts","../src/apps-roster/load.ts"],"sourcesContent":["export {\n validateAppsRoster,\n filterAppsByIdentityGroups,\n sortAppsByNavOrder,\n type TenantApp,\n type TenantAppRole,\n type AppsRoster,\n type RosterValidationError,\n} from \"./apps-roster/schema.js\";\nexport { loadAppsRosterJson, type LoadAppsRosterOptions } from \"./apps-roster/load.js\";\n","// =============================================================================\n// Tenant app roster.\n//\n// One file per tenant that lists every app (apex + spokes) the tenant\n// ecosystem contains. Replaces the runtime DynamoDB registry. Stored as\n// YAML in <tenant>-infra/config/apps.yaml (canonical) and mirrored to\n// <tenant>-apex/config/apps.json for runtime serving by /api/apps.\n//\n// Adding a new spoke = a PR to the spoke repo (its app.manifest.json) +\n// a PR to <tenant>-infra/config/apps.yaml + a PR to <tenant>-apex/config/\n// apps.json. Validation catches drift.\n// =============================================================================\n\nexport type TenantAppRole = \"apex\" | \"spoke\";\n\nexport type TenantApp = {\n /** Stable identifier. Matches the spoke's app.manifest.json#appSlug. */\n slug: string;\n /** \"apex\" (auth broker) or \"spoke\" (product app). */\n role: TenantAppRole;\n /** DNS label. Empty string for apex. */\n subdomain: string;\n /** Human-friendly name. Drives the shared nav. */\n displayName: string;\n /** Sort order. Lower comes first. */\n navOrder: number;\n /**\n * Cognito identity groups required to see this app in cross-app nav\n * AND to enter its routes (when the spoke's createAuth is wired to its\n * own manifest's access policy). Empty = all authenticated users.\n */\n requiredIdentityGroups: string[];\n /**\n * Static feature toggle. Default true. Set false to hide an app from\n * cross-app nav without removing the entry. Editing this requires a\n * PR + redeploy -- this is NOT mutable runtime state.\n */\n enabled?: boolean;\n};\n\nexport type AppsRoster = {\n apps: TenantApp[];\n};\n\nexport type RosterValidationError = {\n path: string;\n message: string;\n};\n\nconst ROLES: readonly string[] = [\"apex\", \"spoke\"];\n\n/**\n * Pure validator for the roster object (parsed from YAML or JSON). Returns\n * the typed roster on success, or an array of errors on failure. No throws.\n */\nexport function validateAppsRoster(\n raw: unknown,\n): { ok: true; value: AppsRoster } | { ok: false; errors: RosterValidationError[] } {\n const errors: RosterValidationError[] = [];\n if (typeof raw !== \"object\" || raw === null) {\n return { ok: false, errors: [{ path: \"\", message: \"roster must be an object\" }] };\n }\n const m = raw as Record<string, unknown>;\n if (!Array.isArray(m.apps)) {\n return { ok: false, errors: [{ path: \"apps\", message: \"expected array\" }] };\n }\n const apps = m.apps as unknown[];\n\n const seenSlugs = new Map<string, number>();\n const seenSubdomains = new Map<string, number>();\n const seenNavOrder = new Map<number, number>();\n let apexCount = 0;\n\n apps.forEach((entryUnknown, i) => {\n const path = `apps[${i}]`;\n if (typeof entryUnknown !== \"object\" || entryUnknown === null) {\n errors.push({ path, message: \"expected object\" });\n return;\n }\n const entry = entryUnknown as Record<string, unknown>;\n\n if (typeof entry.slug !== \"string\" || entry.slug === \"\") {\n errors.push({ path: `${path}.slug`, message: \"expected non-empty string\" });\n } else {\n const prior = seenSlugs.get(entry.slug);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.slug`,\n message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`,\n });\n } else {\n seenSlugs.set(entry.slug, i);\n }\n }\n\n if (typeof entry.role !== \"string\" || !ROLES.includes(entry.role)) {\n errors.push({\n path: `${path}.role`,\n message: `expected one of: ${ROLES.join(\", \")}`,\n });\n } else if (entry.role === \"apex\") {\n apexCount++;\n }\n\n if (typeof entry.subdomain !== \"string\") {\n errors.push({ path: `${path}.subdomain`, message: \"expected string\" });\n } else {\n if (entry.role === \"apex\" && entry.subdomain !== \"\") {\n errors.push({\n path: `${path}.subdomain`,\n message: \"apex apps must have empty subdomain\",\n });\n }\n if (entry.subdomain !== \"\") {\n const prior = seenSubdomains.get(entry.subdomain);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.subdomain`,\n message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`,\n });\n } else {\n seenSubdomains.set(entry.subdomain, i);\n }\n }\n }\n\n if (typeof entry.displayName !== \"string\" || entry.displayName === \"\") {\n errors.push({\n path: `${path}.displayName`,\n message: \"expected non-empty string\",\n });\n }\n\n if (typeof entry.navOrder !== \"number\" || !Number.isFinite(entry.navOrder)) {\n errors.push({ path: `${path}.navOrder`, message: \"expected number\" });\n } else {\n const prior = seenNavOrder.get(entry.navOrder);\n if (prior !== undefined) {\n errors.push({\n path: `${path}.navOrder`,\n message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`,\n });\n } else {\n seenNavOrder.set(entry.navOrder, i);\n }\n }\n\n if (\n !Array.isArray(entry.requiredIdentityGroups) ||\n entry.requiredIdentityGroups.some((g) => typeof g !== \"string\")\n ) {\n errors.push({\n path: `${path}.requiredIdentityGroups`,\n message: \"expected string[]\",\n });\n }\n\n if (entry.enabled !== undefined && typeof entry.enabled !== \"boolean\") {\n errors.push({ path: `${path}.enabled`, message: \"expected boolean\" });\n }\n });\n\n if (apexCount === 0) {\n errors.push({ path: \"apps\", message: \"roster must contain exactly one apex entry\" });\n } else if (apexCount > 1) {\n errors.push({\n path: \"apps\",\n message: `roster must contain exactly one apex entry, found ${apexCount}`,\n });\n }\n\n if (errors.length > 0) return { ok: false, errors };\n return { ok: true, value: m as unknown as AppsRoster };\n}\n\n/**\n * Filter the roster by user identity groups. Apps with empty\n * `requiredIdentityGroups` are visible to all authenticated users; otherwise\n * the user must be in at least one of the listed groups. `enabled: false`\n * apps are always filtered out.\n */\nexport function filterAppsByIdentityGroups(\n apps: TenantApp[],\n userGroups: string[],\n): TenantApp[] {\n const lower = userGroups.map((g) => g.toLowerCase());\n return apps.filter((a) => {\n if (a.enabled === false) return false;\n if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;\n return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));\n });\n}\n\n/** Sort apps by navOrder ASC, then slug. Mutates a copy, returns it. */\nexport function sortAppsByNavOrder<T extends Pick<TenantApp, \"navOrder\" | \"slug\">>(\n apps: T[],\n): T[] {\n return [...apps].sort(\n (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug),\n );\n}\n","import { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\n\nimport { validateAppsRoster, type AppsRoster } from \"./schema.js\";\n\nexport type LoadAppsRosterOptions = {\n /** Absolute path to a JSON apps-roster file. */\n path: string;\n};\n\n/**\n * Read + validate a JSON apps-roster file. Throws with a consolidated error\n * message listing every validation failure.\n *\n * YAML parsing is intentionally not bundled in this package; use\n * @augmenting-integrations/deploy-tools `augint validate-app-roster` for the\n * cross-format check between the infra YAML source and the apex JSON mirror.\n */\nexport function loadAppsRosterJson(opts: LoadAppsRosterOptions): AppsRoster {\n const file = resolve(opts.path);\n let raw: string;\n try {\n raw = readFileSync(file, \"utf8\");\n } catch (err) {\n throw new Error(\n `loadAppsRosterJson: cannot read ${file}: ${(err as Error).message}`,\n { cause: err },\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new Error(\n `loadAppsRosterJson: invalid JSON in ${file}: ${(err as Error).message}`,\n { cause: err },\n );\n }\n const result = validateAppsRoster(parsed);\n if (!result.ok) {\n const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join(\"\\n\");\n throw new Error(`loadAppsRosterJson: ${file} failed validation:\\n${lines}`);\n }\n return result.value;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACiDA,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;;;ACxMA,qBAA6B;AAC7B,uBAAwB;AAiBjB,SAAS,mBAAmB,MAAyC;AAC1E,QAAM,WAAO,0BAAQ,KAAK,IAAI;AAC9B,MAAI;AACJ,MAAI;AACF,cAAM,6BAAa,MAAM,MAAM;AAAA,EACjC,SAAS,KAAK;AACZ,UAAM,IAAI;AAAA,MACR,mCAAmC,IAAI,KAAM,IAAc,OAAO;AAAA,MAClE,EAAE,OAAO,IAAI;AAAA,IACf;AAAA,EACF;AACA,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM,GAAG;AAAA,EACzB,SAAS,KAAK;AACZ,UAAM,IAAI;AAAA,MACR,uCAAuC,IAAI,KAAM,IAAc,OAAO;AAAA,MACtE,EAAE,OAAO,IAAI;AAAA,IACf;AAAA,EACF;AACA,QAAM,SAAS,mBAAmB,MAAM;AACxC,MAAI,CAAC,OAAO,IAAI;AACd,UAAM,QAAQ,OAAO,OAAO,IAAI,CAAC,MAAM,OAAO,EAAE,IAAI,KAAK,EAAE,OAAO,EAAE,EAAE,KAAK,IAAI;AAC/E,UAAM,IAAI,MAAM,uBAAuB,IAAI;AAAA,EAAwB,KAAK,EAAE;AAAA,EAC5E;AACA,SAAO,OAAO;AAChB;","names":[]}
@@ -0,0 +1,3 @@
1
+ export { validateAppsRoster, filterAppsByIdentityGroups, sortAppsByNavOrder, type TenantApp, type TenantAppRole, type AppsRoster, type RosterValidationError, } from "./apps-roster/schema.js";
2
+ export { loadAppsRosterJson, type LoadAppsRosterOptions } from "./apps-roster/load.js";
3
+ //# sourceMappingURL=apps-roster.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apps-roster.d.ts","sourceRoot":"","sources":["../src/apps-roster.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,kBAAkB,EAClB,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,qBAAqB,GAC3B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,kBAAkB,EAAE,KAAK,qBAAqB,EAAE,MAAM,uBAAuB,CAAC"}
@@ -0,0 +1,44 @@
1
+ import {
2
+ filterAppsByIdentityGroups,
3
+ sortAppsByNavOrder,
4
+ validateAppsRoster
5
+ } from "./chunk-ZJFI7R4O.js";
6
+
7
+ // src/apps-roster/load.ts
8
+ import { readFileSync } from "fs";
9
+ import { resolve } from "path";
10
+ function loadAppsRosterJson(opts) {
11
+ const file = resolve(opts.path);
12
+ let raw;
13
+ try {
14
+ raw = readFileSync(file, "utf8");
15
+ } catch (err) {
16
+ throw new Error(
17
+ `loadAppsRosterJson: cannot read ${file}: ${err.message}`,
18
+ { cause: err }
19
+ );
20
+ }
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(raw);
24
+ } catch (err) {
25
+ throw new Error(
26
+ `loadAppsRosterJson: invalid JSON in ${file}: ${err.message}`,
27
+ { cause: err }
28
+ );
29
+ }
30
+ const result = validateAppsRoster(parsed);
31
+ if (!result.ok) {
32
+ const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
33
+ throw new Error(`loadAppsRosterJson: ${file} failed validation:
34
+ ${lines}`);
35
+ }
36
+ return result.value;
37
+ }
38
+ export {
39
+ filterAppsByIdentityGroups,
40
+ loadAppsRosterJson,
41
+ sortAppsByNavOrder,
42
+ validateAppsRoster
43
+ };
44
+ //# sourceMappingURL=apps-roster.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/apps-roster/load.ts"],"sourcesContent":["import { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\n\nimport { validateAppsRoster, type AppsRoster } from \"./schema.js\";\n\nexport type LoadAppsRosterOptions = {\n /** Absolute path to a JSON apps-roster file. */\n path: string;\n};\n\n/**\n * Read + validate a JSON apps-roster file. Throws with a consolidated error\n * message listing every validation failure.\n *\n * YAML parsing is intentionally not bundled in this package; use\n * @augmenting-integrations/deploy-tools `augint validate-app-roster` for the\n * cross-format check between the infra YAML source and the apex JSON mirror.\n */\nexport function loadAppsRosterJson(opts: LoadAppsRosterOptions): AppsRoster {\n const file = resolve(opts.path);\n let raw: string;\n try {\n raw = readFileSync(file, \"utf8\");\n } catch (err) {\n throw new Error(\n `loadAppsRosterJson: cannot read ${file}: ${(err as Error).message}`,\n { cause: err },\n );\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n throw new Error(\n `loadAppsRosterJson: invalid JSON in ${file}: ${(err as Error).message}`,\n { cause: err },\n );\n }\n const result = validateAppsRoster(parsed);\n if (!result.ok) {\n const lines = result.errors.map((e) => ` - ${e.path}: ${e.message}`).join(\"\\n\");\n throw new Error(`loadAppsRosterJson: ${file} failed validation:\\n${lines}`);\n }\n return result.value;\n}\n"],"mappings":";;;;;;;AAAA,SAAS,oBAAoB;AAC7B,SAAS,eAAe;AAiBjB,SAAS,mBAAmB,MAAyC;AAC1E,QAAM,OAAO,QAAQ,KAAK,IAAI;AAC9B,MAAI;AACJ,MAAI;AACF,UAAM,aAAa,MAAM,MAAM;AAAA,EACjC,SAAS,KAAK;AACZ,UAAM,IAAI;AAAA,MACR,mCAAmC,IAAI,KAAM,IAAc,OAAO;AAAA,MAClE,EAAE,OAAO,IAAI;AAAA,IACf;AAAA,EACF;AACA,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM,GAAG;AAAA,EACzB,SAAS,KAAK;AACZ,UAAM,IAAI;AAAA,MACR,uCAAuC,IAAI,KAAM,IAAc,OAAO;AAAA,MACtE,EAAE,OAAO,IAAI;AAAA,IACf;AAAA,EACF;AACA,QAAM,SAAS,mBAAmB,MAAM;AACxC,MAAI,CAAC,OAAO,IAAI;AACd,UAAM,QAAQ,OAAO,OAAO,IAAI,CAAC,MAAM,OAAO,EAAE,IAAI,KAAK,EAAE,OAAO,EAAE,EAAE,KAAK,IAAI;AAC/E,UAAM,IAAI,MAAM,uBAAuB,IAAI;AAAA,EAAwB,KAAK,EAAE;AAAA,EAC5E;AACA,SAAO,OAAO;AAChB;","names":[]}
@@ -0,0 +1,125 @@
1
+ // src/apps-roster/schema.ts
2
+ var ROLES = ["apex", "spoke"];
3
+ function validateAppsRoster(raw) {
4
+ const errors = [];
5
+ if (typeof raw !== "object" || raw === null) {
6
+ return { ok: false, errors: [{ path: "", message: "roster must be an object" }] };
7
+ }
8
+ const m = raw;
9
+ if (!Array.isArray(m.apps)) {
10
+ return { ok: false, errors: [{ path: "apps", message: "expected array" }] };
11
+ }
12
+ const apps = m.apps;
13
+ const seenSlugs = /* @__PURE__ */ new Map();
14
+ const seenSubdomains = /* @__PURE__ */ new Map();
15
+ const seenNavOrder = /* @__PURE__ */ new Map();
16
+ let apexCount = 0;
17
+ apps.forEach((entryUnknown, i) => {
18
+ const path = `apps[${i}]`;
19
+ if (typeof entryUnknown !== "object" || entryUnknown === null) {
20
+ errors.push({ path, message: "expected object" });
21
+ return;
22
+ }
23
+ const entry = entryUnknown;
24
+ if (typeof entry.slug !== "string" || entry.slug === "") {
25
+ errors.push({ path: `${path}.slug`, message: "expected non-empty string" });
26
+ } else {
27
+ const prior = seenSlugs.get(entry.slug);
28
+ if (prior !== void 0) {
29
+ errors.push({
30
+ path: `${path}.slug`,
31
+ message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`
32
+ });
33
+ } else {
34
+ seenSlugs.set(entry.slug, i);
35
+ }
36
+ }
37
+ if (typeof entry.role !== "string" || !ROLES.includes(entry.role)) {
38
+ errors.push({
39
+ path: `${path}.role`,
40
+ message: `expected one of: ${ROLES.join(", ")}`
41
+ });
42
+ } else if (entry.role === "apex") {
43
+ apexCount++;
44
+ }
45
+ if (typeof entry.subdomain !== "string") {
46
+ errors.push({ path: `${path}.subdomain`, message: "expected string" });
47
+ } else {
48
+ if (entry.role === "apex" && entry.subdomain !== "") {
49
+ errors.push({
50
+ path: `${path}.subdomain`,
51
+ message: "apex apps must have empty subdomain"
52
+ });
53
+ }
54
+ if (entry.subdomain !== "") {
55
+ const prior = seenSubdomains.get(entry.subdomain);
56
+ if (prior !== void 0) {
57
+ errors.push({
58
+ path: `${path}.subdomain`,
59
+ message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`
60
+ });
61
+ } else {
62
+ seenSubdomains.set(entry.subdomain, i);
63
+ }
64
+ }
65
+ }
66
+ if (typeof entry.displayName !== "string" || entry.displayName === "") {
67
+ errors.push({
68
+ path: `${path}.displayName`,
69
+ message: "expected non-empty string"
70
+ });
71
+ }
72
+ if (typeof entry.navOrder !== "number" || !Number.isFinite(entry.navOrder)) {
73
+ errors.push({ path: `${path}.navOrder`, message: "expected number" });
74
+ } else {
75
+ const prior = seenNavOrder.get(entry.navOrder);
76
+ if (prior !== void 0) {
77
+ errors.push({
78
+ path: `${path}.navOrder`,
79
+ message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`
80
+ });
81
+ } else {
82
+ seenNavOrder.set(entry.navOrder, i);
83
+ }
84
+ }
85
+ if (!Array.isArray(entry.requiredIdentityGroups) || entry.requiredIdentityGroups.some((g) => typeof g !== "string")) {
86
+ errors.push({
87
+ path: `${path}.requiredIdentityGroups`,
88
+ message: "expected string[]"
89
+ });
90
+ }
91
+ if (entry.enabled !== void 0 && typeof entry.enabled !== "boolean") {
92
+ errors.push({ path: `${path}.enabled`, message: "expected boolean" });
93
+ }
94
+ });
95
+ if (apexCount === 0) {
96
+ errors.push({ path: "apps", message: "roster must contain exactly one apex entry" });
97
+ } else if (apexCount > 1) {
98
+ errors.push({
99
+ path: "apps",
100
+ message: `roster must contain exactly one apex entry, found ${apexCount}`
101
+ });
102
+ }
103
+ if (errors.length > 0) return { ok: false, errors };
104
+ return { ok: true, value: m };
105
+ }
106
+ function filterAppsByIdentityGroups(apps, userGroups) {
107
+ const lower = userGroups.map((g) => g.toLowerCase());
108
+ return apps.filter((a) => {
109
+ if (a.enabled === false) return false;
110
+ if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;
111
+ return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));
112
+ });
113
+ }
114
+ function sortAppsByNavOrder(apps) {
115
+ return [...apps].sort(
116
+ (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug)
117
+ );
118
+ }
119
+
120
+ export {
121
+ validateAppsRoster,
122
+ filterAppsByIdentityGroups,
123
+ sortAppsByNavOrder
124
+ };
125
+ //# sourceMappingURL=chunk-ZJFI7R4O.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/apps-roster/schema.ts"],"sourcesContent":["// =============================================================================\n// Tenant app roster.\n//\n// One file per tenant that lists every app (apex + spokes) the tenant\n// ecosystem contains. 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":[]}
@@ -0,0 +1,28 @@
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 {};
28
+ //# 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"}
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
+ createAppsRouteHandler: () => createAppsRouteHandler,
35
36
  loadTenantConfig: () => loadTenantConfig,
36
37
  publicSubset: () => publicSubset
37
38
  });
@@ -119,10 +120,177 @@ function TenantBootScript({ config }) {
119
120
  props[INNER_HTML_PROP] = { __html: body };
120
121
  return React.createElement("script", props);
121
122
  }
123
+
124
+ // src/server/apps-route.ts
125
+ var import_server_only2 = require("server-only");
126
+
127
+ // src/apps-roster/schema.ts
128
+ var ROLES = ["apex", "spoke"];
129
+ function validateAppsRoster(raw) {
130
+ const errors = [];
131
+ if (typeof raw !== "object" || raw === null) {
132
+ return { ok: false, errors: [{ path: "", message: "roster must be an object" }] };
133
+ }
134
+ const m = raw;
135
+ if (!Array.isArray(m.apps)) {
136
+ return { ok: false, errors: [{ path: "apps", message: "expected array" }] };
137
+ }
138
+ const apps = m.apps;
139
+ const seenSlugs = /* @__PURE__ */ new Map();
140
+ const seenSubdomains = /* @__PURE__ */ new Map();
141
+ const seenNavOrder = /* @__PURE__ */ new Map();
142
+ let apexCount = 0;
143
+ apps.forEach((entryUnknown, i) => {
144
+ const path = `apps[${i}]`;
145
+ if (typeof entryUnknown !== "object" || entryUnknown === null) {
146
+ errors.push({ path, message: "expected object" });
147
+ return;
148
+ }
149
+ const entry = entryUnknown;
150
+ if (typeof entry.slug !== "string" || entry.slug === "") {
151
+ errors.push({ path: `${path}.slug`, message: "expected non-empty string" });
152
+ } else {
153
+ const prior = seenSlugs.get(entry.slug);
154
+ if (prior !== void 0) {
155
+ errors.push({
156
+ path: `${path}.slug`,
157
+ message: `duplicate slug ${JSON.stringify(entry.slug)} (also at apps[${prior}])`
158
+ });
159
+ } else {
160
+ seenSlugs.set(entry.slug, i);
161
+ }
162
+ }
163
+ if (typeof entry.role !== "string" || !ROLES.includes(entry.role)) {
164
+ errors.push({
165
+ path: `${path}.role`,
166
+ message: `expected one of: ${ROLES.join(", ")}`
167
+ });
168
+ } else if (entry.role === "apex") {
169
+ apexCount++;
170
+ }
171
+ if (typeof entry.subdomain !== "string") {
172
+ errors.push({ path: `${path}.subdomain`, message: "expected string" });
173
+ } else {
174
+ if (entry.role === "apex" && entry.subdomain !== "") {
175
+ errors.push({
176
+ path: `${path}.subdomain`,
177
+ message: "apex apps must have empty subdomain"
178
+ });
179
+ }
180
+ if (entry.subdomain !== "") {
181
+ const prior = seenSubdomains.get(entry.subdomain);
182
+ if (prior !== void 0) {
183
+ errors.push({
184
+ path: `${path}.subdomain`,
185
+ message: `duplicate subdomain ${JSON.stringify(entry.subdomain)} (also at apps[${prior}])`
186
+ });
187
+ } else {
188
+ seenSubdomains.set(entry.subdomain, i);
189
+ }
190
+ }
191
+ }
192
+ if (typeof entry.displayName !== "string" || entry.displayName === "") {
193
+ errors.push({
194
+ path: `${path}.displayName`,
195
+ message: "expected non-empty string"
196
+ });
197
+ }
198
+ if (typeof entry.navOrder !== "number" || !Number.isFinite(entry.navOrder)) {
199
+ errors.push({ path: `${path}.navOrder`, message: "expected number" });
200
+ } else {
201
+ const prior = seenNavOrder.get(entry.navOrder);
202
+ if (prior !== void 0) {
203
+ errors.push({
204
+ path: `${path}.navOrder`,
205
+ message: `duplicate navOrder ${entry.navOrder} (also at apps[${prior}])`
206
+ });
207
+ } else {
208
+ seenNavOrder.set(entry.navOrder, i);
209
+ }
210
+ }
211
+ if (!Array.isArray(entry.requiredIdentityGroups) || entry.requiredIdentityGroups.some((g) => typeof g !== "string")) {
212
+ errors.push({
213
+ path: `${path}.requiredIdentityGroups`,
214
+ message: "expected string[]"
215
+ });
216
+ }
217
+ if (entry.enabled !== void 0 && typeof entry.enabled !== "boolean") {
218
+ errors.push({ path: `${path}.enabled`, message: "expected boolean" });
219
+ }
220
+ });
221
+ if (apexCount === 0) {
222
+ errors.push({ path: "apps", message: "roster must contain exactly one apex entry" });
223
+ } else if (apexCount > 1) {
224
+ errors.push({
225
+ path: "apps",
226
+ message: `roster must contain exactly one apex entry, found ${apexCount}`
227
+ });
228
+ }
229
+ if (errors.length > 0) return { ok: false, errors };
230
+ return { ok: true, value: m };
231
+ }
232
+ function filterAppsByIdentityGroups(apps, userGroups) {
233
+ const lower = userGroups.map((g) => g.toLowerCase());
234
+ return apps.filter((a) => {
235
+ if (a.enabled === false) return false;
236
+ if (!a.requiredIdentityGroups || a.requiredIdentityGroups.length === 0) return true;
237
+ return a.requiredIdentityGroups.some((g) => lower.includes(g.toLowerCase()));
238
+ });
239
+ }
240
+ function sortAppsByNavOrder(apps) {
241
+ return [...apps].sort(
242
+ (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) || a.slug.localeCompare(b.slug)
243
+ );
244
+ }
245
+
246
+ // src/server/apps-route.ts
247
+ function deriveAppUrl(app, apex) {
248
+ if (app.subdomain === "") return `https://${apex}`;
249
+ return `https://${app.subdomain}.${apex}`;
250
+ }
251
+ function createAppsRouteHandler(opts) {
252
+ const validated = validateAppsRoster(opts.roster);
253
+ if (!validated.ok) {
254
+ throw new Error(
255
+ `createAppsRouteHandler: roster failed validation: ${validated.errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`
256
+ );
257
+ }
258
+ const apps = validated.value.apps;
259
+ const requireAuth = opts.requireAuth ?? true;
260
+ return {
261
+ GET: async () => {
262
+ let session = null;
263
+ if (requireAuth) {
264
+ session = await opts.auth();
265
+ if (!session) {
266
+ return Response.json({ error: "unauthenticated" }, { status: 401 });
267
+ }
268
+ }
269
+ const userGroups = session?.user?.groups ?? [];
270
+ const visible = filterAppsByIdentityGroups(apps, userGroups);
271
+ const sorted = sortAppsByNavOrder(visible);
272
+ const withUrl = sorted.map((a) => ({
273
+ slug: a.slug,
274
+ role: a.role,
275
+ subdomain: a.subdomain,
276
+ displayName: a.displayName,
277
+ navOrder: a.navOrder,
278
+ requiredIdentityGroups: a.requiredIdentityGroups,
279
+ appUrl: deriveAppUrl(a, opts.tenant.apex)
280
+ }));
281
+ return Response.json(withUrl, {
282
+ headers: {
283
+ "Cache-Control": "private, s-maxage=300, stale-while-revalidate=600"
284
+ }
285
+ });
286
+ }
287
+ };
288
+ }
122
289
  // Annotate the CommonJS export names for ESM import in node:
123
290
  0 && (module.exports = {
124
291
  TENANT_GLOBAL_KEY,
125
292
  TenantBootScript,
293
+ createAppsRouteHandler,
126
294
  loadTenantConfig,
127
295
  publicSubset
128
296
  });
@@ -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 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"]}
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, type CreateAppsRouteHandlerOptions, } 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,KAAK,6BAA6B,GACnC,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,55 @@ 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
+ }
83
134
  export {
84
135
  TENANT_GLOBAL_KEY,
85
136
  TenantBootScript,
137
+ createAppsRouteHandler,
86
138
  loadTenantConfig,
87
139
  publicSubset
88
140
  };
@@ -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 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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augmenting-integrations/platform",
3
- "version": "8.5.0",
3
+ "version": "8.6.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": {
@@ -25,6 +25,11 @@
25
25
  "types": "./dist/manifest.d.ts",
26
26
  "import": "./dist/manifest.js",
27
27
  "require": "./dist/manifest.cjs"
28
+ },
29
+ "./apps-roster": {
30
+ "types": "./dist/apps-roster.d.ts",
31
+ "import": "./dist/apps-roster.js",
32
+ "require": "./dist/apps-roster.cjs"
28
33
  }
29
34
  },
30
35
  "files": [