create-ailk 0.11.5 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.json +35 -2
- package/component-catalog.md +9 -6
- package/dist/add-module.js +13 -0
- package/dist/module-architecture.d.ts +8 -0
- package/dist/module-architecture.js +33 -0
- package/dist/paid-sections.d.ts +23 -0
- package/dist/paid-sections.js +119 -0
- package/dist/programmatic.js +8 -0
- package/package.json +3 -3
- package/templates/.env.example +69 -3
- package/templates/apps/api/.env.example +22 -0
- package/templates/apps/api/CLAUDE.md +19 -7
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/bin/spokes.ts +137 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +96 -0
- package/templates/apps/api/src/config/index.ts +97 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +35 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +152 -0
- package/templates/apps/api/src/lib/spoke-store.ts +131 -0
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +331 -0
- package/templates/apps/api/src/routes/leads/index.ts +33 -3
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +528 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +204 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -2
- package/templates/apps/api/src/routes/project-listings/public.ts +42 -1
- package/templates/apps/api/src/routes/project-listings/submit.ts +3 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +29 -0
- package/templates/apps/api/src/server.ts +48 -0
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +70 -0
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +374 -0
- package/templates/apps/api/src/services/default-tenant.ts +51 -28
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +89 -3
- package/templates/apps/api/src/services/lead-routing.ts +13 -1
- package/templates/apps/api/src/services/project-listings.ts +209 -18
- package/templates/apps/api/src/services/public-site-resolver.ts +149 -28
- package/templates/apps/api/src/services/slack-notify.ts +54 -3
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +137 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -20
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +5 -19
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/layout.tsx +4 -0
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +5 -16
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +4 -7
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +2 -2
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +5 -22
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +5 -14
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +8 -0
- package/templates/apps/web/jest.config.cjs +1 -1
- package/templates/apps/web/lib/__tests__/listings.test.tsx +352 -0
- package/templates/apps/web/lib/listings.tsx +472 -0
- package/templates/apps/web/lib/paid-section-contracts.ts +22 -0
- package/templates/apps/web/lib/paid-sections.generated.ts +16 -0
- package/templates/apps/web/messages/en.json +10 -0
- package/templates/apps/web/src/lib/__tests__/commerce-sections.test.ts +57 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +33 -71
- package/templates/database/CHANGELOG.md +34 -0
- package/templates/database/__tests__/build.test.ts +131 -0
- package/templates/database/__tests__/spoke-registry.db.test.ts +191 -0
- package/templates/database/auth/schema.prisma +31 -0
- package/templates/database/config/schema.prisma +26 -0
- package/templates/database/inbox/schema.prisma +21 -0
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +80 -0
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +49 -0
- package/templates/database/package.json +3 -3
- package/versions.json +34 -34
|
@@ -11,6 +11,14 @@ jest.mock("../../lib/prisma.js");
|
|
|
11
11
|
import * as configModule from "../../config/index.js";
|
|
12
12
|
import { prisma } from "../../lib/prisma.js";
|
|
13
13
|
import { postListingApplication, updateListingMessage } from "../slack-notify";
|
|
14
|
+
import {
|
|
15
|
+
LISTING_DECISION_BLOCK_NAMED_SINCE_SECONDS,
|
|
16
|
+
listingDecisionBlockId,
|
|
17
|
+
parseListingDecisionBlockId,
|
|
18
|
+
} from "../slack-notify";
|
|
19
|
+
|
|
20
|
+
// #7522 D5 — the workspace the listing belongs to; the posted actions block names it.
|
|
21
|
+
const WS = "wksp_00000000-0000-7000-8000-000000000001";
|
|
14
22
|
|
|
15
23
|
// #5036 D2/D5 reshaped what reaches Slack: `audience` is gone (folded into
|
|
16
24
|
// `oneLiner`'s guidance) and `stage` is the six-rung `product` ladder. Since
|
|
@@ -26,6 +34,7 @@ const LISTING = {
|
|
|
26
34
|
product: "working-prototype",
|
|
27
35
|
founderName: "Ada Founder",
|
|
28
36
|
founderCompany: "Ada Co",
|
|
37
|
+
workspaceId: WS,
|
|
29
38
|
};
|
|
30
39
|
|
|
31
40
|
function mockFetchOnce(body: unknown, ok = true): jest.Mock {
|
|
@@ -276,3 +285,64 @@ describe("updateListingMessage", () => {
|
|
|
276
285
|
await expect(updateListingMessage(decided, "active", "Erin")).resolves.toBeUndefined();
|
|
277
286
|
});
|
|
278
287
|
});
|
|
288
|
+
|
|
289
|
+
// ── #7522 — the actions block names the listing's workspace ────────────────
|
|
290
|
+
|
|
291
|
+
describe("the listing-decision block_id (#7522)", () => {
|
|
292
|
+
beforeEach(() => {
|
|
293
|
+
jest.clearAllMocks();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
afterEach(() => {
|
|
297
|
+
jest.replaceProperty(configModule, "config", {
|
|
298
|
+
...configModule.config,
|
|
299
|
+
AILK_SLACK_BOT_TOKEN: undefined,
|
|
300
|
+
AILK_SLACK_CHANNEL_ID: undefined,
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("posts an actions block whose block_id names the listing's workspace, in the format the route parses", async () => {
|
|
305
|
+
jest.replaceProperty(configModule, "config", {
|
|
306
|
+
...configModule.config,
|
|
307
|
+
AILK_SLACK_BOT_TOKEN: "xoxb-test",
|
|
308
|
+
AILK_SLACK_CHANNEL_ID: "C123",
|
|
309
|
+
});
|
|
310
|
+
const fetchMock = mockFetchOnce({ ok: true, channel: "C123", ts: "1699999999.000100" });
|
|
311
|
+
|
|
312
|
+
await postListingApplication({ ...LISTING });
|
|
313
|
+
|
|
314
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
315
|
+
const actionsBlock = body.blocks.find((b: { type: string }) => b.type === "actions");
|
|
316
|
+
expect(actionsBlock.block_id).toBe(listingDecisionBlockId(WS));
|
|
317
|
+
expect(actionsBlock.block_id).toBe("listing_decision:" + WS);
|
|
318
|
+
// The button values stay the bare listing id.
|
|
319
|
+
expect(actionsBlock.elements[0]).toMatchObject({ action_id: "listing_approve", value: LISTING.id });
|
|
320
|
+
expect(actionsBlock.elements[1]).toMatchObject({ action_id: "listing_reject", value: LISTING.id });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("round-trips: the parser reads back the workspace the producer wrote", () => {
|
|
324
|
+
expect(parseListingDecisionBlockId(listingDecisionBlockId(WS))).toEqual({
|
|
325
|
+
kind: "named",
|
|
326
|
+
workspaceId: WS,
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("reads an absent block_id or the bare legacy literal as the legacy arm", () => {
|
|
331
|
+
expect(parseListingDecisionBlockId(undefined)).toEqual({ kind: "legacy" });
|
|
332
|
+
expect(parseListingDecisionBlockId("listing_decision")).toEqual({ kind: "legacy" });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("reads an empty suffix, a non-workspace id, or a foreign block as invalid — never as legacy", () => {
|
|
336
|
+
expect(parseListingDecisionBlockId("listing_decision:")).toEqual({ kind: "invalid" });
|
|
337
|
+
expect(
|
|
338
|
+
parseListingDecisionBlockId("listing_decision:prjl_00000000-0000-7000-8000-000000000001"),
|
|
339
|
+
).toEqual({ kind: "invalid" });
|
|
340
|
+
expect(parseListingDecisionBlockId("other")).toEqual({ kind: "invalid" });
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("the cutover is a whole UTC hour after the pin", () => {
|
|
344
|
+
expect(Number.isInteger(LISTING_DECISION_BLOCK_NAMED_SINCE_SECONDS)).toBe(true);
|
|
345
|
+
expect(LISTING_DECISION_BLOCK_NAMED_SINCE_SECONDS % 3600).toBe(0);
|
|
346
|
+
expect(LISTING_DECISION_BLOCK_NAMED_SINCE_SECONDS).toBeGreaterThan(1790985600);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file spoke-resolution.test.ts
|
|
3
|
+
*
|
|
4
|
+
* `resolveCallerSite(request)` and the public resolver's spoke arms (#7532,
|
|
5
|
+
* spec docs/specs/7532-a-spoke-s-public-reads-spec.md D3, AC-1, AC-3, AC-4).
|
|
6
|
+
*
|
|
7
|
+
* A spoke names itself with ONE request header, `x-ailk-spoke-key`, looked up
|
|
8
|
+
* by its SHA-256 digest in the hub's registry. Nothing else on the request —
|
|
9
|
+
* a workspace id, `Host`, `X-Forwarded-Host`, `Origin` — selects a workspace.
|
|
10
|
+
*
|
|
11
|
+
* Fixture (spec § 5): three workspaces — the sentinel default `F`, `W1`, `W2`;
|
|
12
|
+
* spokes `S1` → `W1`, `S1b` → `W1`, `S2` → `W2`, each with its own key.
|
|
13
|
+
* `db.spoke.findUnique` answers by `credentialHash`.
|
|
14
|
+
*
|
|
15
|
+
* External dependency boundaries mocked:
|
|
16
|
+
* - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
|
|
17
|
+
* - lib/content-adapter.js (so the adapter the resolver picks is observable)
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
22
|
+
jest.mock("../../lib/prisma.js");
|
|
23
|
+
// eslint-disable-next-line no-restricted-syntax -- AC-1 asserts WHICH adapter the resolver asks for (workspace-scoped vs shared mdx); the double records that argument
|
|
24
|
+
jest.mock("../../lib/content-adapter.js", () => ({
|
|
25
|
+
getContentAdapter: jest.fn(() => ({ kind: "adapter-double" })),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import type { FastifyRequest } from "fastify";
|
|
29
|
+
|
|
30
|
+
import * as configModule from "../../config/index.js";
|
|
31
|
+
import { getContentAdapter } from "../../lib/content-adapter.js";
|
|
32
|
+
import { mockSentinelWorkspaceExists } from "../../lib/__mocks__/prisma.js";
|
|
33
|
+
import { prisma } from "../../lib/prisma.js";
|
|
34
|
+
import {
|
|
35
|
+
resolveCallerSite,
|
|
36
|
+
resolvePublicContentAdapter,
|
|
37
|
+
resolvePublicWorkspace,
|
|
38
|
+
} from "../public-site-resolver.js";
|
|
39
|
+
|
|
40
|
+
const db = jest.mocked(prisma);
|
|
41
|
+
|
|
42
|
+
const W1 = "wksp_01920000-0000-7000-8000-0000000000b1";
|
|
43
|
+
const W2 = "wksp_01920000-0000-7000-8000-0000000000b2";
|
|
44
|
+
const S1 = "spoke_01920000-0000-7000-8000-0000000000c1";
|
|
45
|
+
const S1B = "spoke_01920000-0000-7000-8000-0000000000c2";
|
|
46
|
+
const S2 = "spoke_01920000-0000-7000-8000-0000000000c3";
|
|
47
|
+
|
|
48
|
+
/** A well-formed 52-character spoke key, distinct per tag. */
|
|
49
|
+
function spokeKey(tag: string): string {
|
|
50
|
+
return `ailk_spk_${tag}${"x".repeat(43 - tag.length)}`;
|
|
51
|
+
}
|
|
52
|
+
const KEY_S1 = spokeKey("S1");
|
|
53
|
+
const KEY_S1B = spokeKey("S1b");
|
|
54
|
+
const KEY_S2 = spokeKey("S2");
|
|
55
|
+
const KEY_UNKNOWN = spokeKey("nobody");
|
|
56
|
+
|
|
57
|
+
const digest = (key: string) => createHash("sha256").update(key).digest("hex");
|
|
58
|
+
|
|
59
|
+
type SpokeRow = { id: string; workspaceId: string; revokedAt: Date | null };
|
|
60
|
+
|
|
61
|
+
/** Program the registry: `db.spoke.findUnique` answers by digest. */
|
|
62
|
+
function seedRegistry(revoked: readonly string[] = []): void {
|
|
63
|
+
const rows = new Map<string, SpokeRow>([
|
|
64
|
+
[digest(KEY_S1), { id: S1, workspaceId: W1, revokedAt: null }],
|
|
65
|
+
[digest(KEY_S1B), { id: S1B, workspaceId: W1, revokedAt: null }],
|
|
66
|
+
[digest(KEY_S2), { id: S2, workspaceId: W2, revokedAt: null }],
|
|
67
|
+
]);
|
|
68
|
+
for (const row of rows.values()) {
|
|
69
|
+
if (revoked.includes(row.id)) row.revokedAt = new Date();
|
|
70
|
+
}
|
|
71
|
+
db.spoke.findUnique.mockImplementation((async ({
|
|
72
|
+
where,
|
|
73
|
+
}: {
|
|
74
|
+
where: { credentialHash: string };
|
|
75
|
+
}) => rows.get(where.credentialHash) ?? null) as never);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A request double carrying only what the resolver may read. */
|
|
79
|
+
function requestWith(headers: Record<string, string | string[]>): {
|
|
80
|
+
request: FastifyRequest;
|
|
81
|
+
warn: jest.Mock;
|
|
82
|
+
} {
|
|
83
|
+
const warn = jest.fn();
|
|
84
|
+
// eslint-disable-next-line no-restricted-syntax -- a FastifyRequest double carrying only the two members the resolver reads (headers, log)
|
|
85
|
+
const request = {
|
|
86
|
+
headers,
|
|
87
|
+
log: { warn, info: jest.fn(), error: jest.fn() },
|
|
88
|
+
} as unknown as FastifyRequest;
|
|
89
|
+
return { request, warn };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setStrictMode(on: boolean): void {
|
|
93
|
+
jest.replaceProperty(configModule, "config", {
|
|
94
|
+
...configModule.config,
|
|
95
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: on,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** No logged object may carry a key, its digest or its 17-character prefix. */
|
|
100
|
+
function expectNoSecretIn(log: jest.Mock): void {
|
|
101
|
+
const serialized = JSON.stringify(log.mock.calls);
|
|
102
|
+
for (const key of [KEY_S1, KEY_S1B, KEY_S2, KEY_UNKNOWN]) {
|
|
103
|
+
expect(serialized).not.toContain(key);
|
|
104
|
+
expect(serialized).not.toContain(digest(key));
|
|
105
|
+
expect(serialized).not.toContain(key.slice(0, 17));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const savedAdapter = process.env.CONTENT_ADAPTER;
|
|
110
|
+
const savedDatabaseUrl = process.env.DATABASE_URL;
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
jest.resetAllMocks();
|
|
114
|
+
(getContentAdapter as jest.Mock).mockReturnValue({ kind: "adapter-double" });
|
|
115
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
116
|
+
process.env.CONTENT_ADAPTER = "database";
|
|
117
|
+
seedRegistry();
|
|
118
|
+
mockSentinelWorkspaceExists(db, W1, W2);
|
|
119
|
+
db.workspace.count.mockResolvedValue(3);
|
|
120
|
+
setStrictMode(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
jest.restoreAllMocks();
|
|
125
|
+
if (savedAdapter === undefined) delete process.env.CONTENT_ADAPTER;
|
|
126
|
+
else process.env.CONTENT_ADAPTER = savedAdapter;
|
|
127
|
+
if (savedDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
128
|
+
else process.env.DATABASE_URL = savedDatabaseUrl;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("resolveCallerSite — a registered spoke (AC-1)", () => {
|
|
132
|
+
it.each([
|
|
133
|
+
[KEY_S1, S1, W1],
|
|
134
|
+
[KEY_S1B, S1B, W1],
|
|
135
|
+
[KEY_S2, S2, W2],
|
|
136
|
+
])("resolves its key to its own workspace", async (key, spokeId, workspaceId) => {
|
|
137
|
+
const { request, warn } = requestWith({ "x-ailk-spoke-key": key });
|
|
138
|
+
|
|
139
|
+
await expect(resolveCallerSite(request)).resolves.toEqual({
|
|
140
|
+
kind: "spoke",
|
|
141
|
+
spokeId,
|
|
142
|
+
workspaceId,
|
|
143
|
+
});
|
|
144
|
+
expect(db.spoke.findUnique).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(db.spoke.findUnique).toHaveBeenCalledWith(
|
|
146
|
+
expect.objectContaining({ where: { credentialHash: digest(key) } }),
|
|
147
|
+
);
|
|
148
|
+
expectNoSecretIn(warn);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("trims the header value before hashing it", async () => {
|
|
152
|
+
const { request } = requestWith({ "x-ailk-spoke-key": ` ${KEY_S2} ` });
|
|
153
|
+
await expect(resolveCallerSite(request)).resolves.toMatchObject({
|
|
154
|
+
kind: "spoke",
|
|
155
|
+
workspaceId: W2,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("ignores a workspace id, host and origin a spoke ALSO sends", async () => {
|
|
160
|
+
const { request } = requestWith({
|
|
161
|
+
"x-ailk-spoke-key": KEY_S1,
|
|
162
|
+
host: "w2.example",
|
|
163
|
+
"x-forwarded-host": "w2.example",
|
|
164
|
+
origin: "https://w2.example",
|
|
165
|
+
"x-ailk-workspace-id": W2,
|
|
166
|
+
});
|
|
167
|
+
await expect(resolveCallerSite(request)).resolves.toEqual({
|
|
168
|
+
kind: "spoke",
|
|
169
|
+
spokeId: S1,
|
|
170
|
+
workspaceId: W1,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("resolveCallerSite — fail closed (AC-3)", () => {
|
|
176
|
+
it("(i) an unregistered key is refused as key-unknown", async () => {
|
|
177
|
+
const { request, warn } = requestWith({ "x-ailk-spoke-key": KEY_UNKNOWN });
|
|
178
|
+
await expect(resolveCallerSite(request)).resolves.toEqual({
|
|
179
|
+
kind: "refused",
|
|
180
|
+
reason: "key-unknown",
|
|
181
|
+
});
|
|
182
|
+
expect(warn).toHaveBeenCalledWith(
|
|
183
|
+
{ reason: "key-unknown" },
|
|
184
|
+
expect.any(String),
|
|
185
|
+
);
|
|
186
|
+
expectNoSecretIn(warn);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("a revoked key is refused as key-revoked, and only that key", async () => {
|
|
190
|
+
seedRegistry([S1]);
|
|
191
|
+
const revoked = requestWith({ "x-ailk-spoke-key": KEY_S1 });
|
|
192
|
+
await expect(resolveCallerSite(revoked.request)).resolves.toEqual({
|
|
193
|
+
kind: "refused",
|
|
194
|
+
reason: "key-revoked",
|
|
195
|
+
});
|
|
196
|
+
expect(revoked.warn).toHaveBeenCalledWith(
|
|
197
|
+
{ reason: "key-revoked", spokeId: S1 },
|
|
198
|
+
expect.any(String),
|
|
199
|
+
);
|
|
200
|
+
expectNoSecretIn(revoked.warn);
|
|
201
|
+
|
|
202
|
+
const sibling = requestWith({ "x-ailk-spoke-key": KEY_S1B });
|
|
203
|
+
await expect(resolveCallerSite(sibling.request)).resolves.toMatchObject({
|
|
204
|
+
kind: "spoke",
|
|
205
|
+
workspaceId: W1,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it.each<[string, string | string[]]>([
|
|
210
|
+
["a duplicated header, as Node joins it", `${KEY_S1}, ${KEY_S2}`],
|
|
211
|
+
["a string[]", [KEY_S1, KEY_S2]],
|
|
212
|
+
["an empty value", ""],
|
|
213
|
+
["a blank value", " "],
|
|
214
|
+
["a 129-character value", "k".repeat(129)],
|
|
215
|
+
["a non-ASCII value", `${KEY_S1.slice(0, 40)}é`],
|
|
216
|
+
["a value carrying a control character", `${KEY_S1.slice(0, 40)}\u0001`],
|
|
217
|
+
])("(ii)/(iii) %s is refused as key-malformed, before any lookup", async (_label, value) => {
|
|
218
|
+
const { request, warn } = requestWith({ "x-ailk-spoke-key": value });
|
|
219
|
+
await expect(resolveCallerSite(request)).resolves.toEqual({
|
|
220
|
+
kind: "refused",
|
|
221
|
+
reason: "key-malformed",
|
|
222
|
+
});
|
|
223
|
+
expect(db.spoke.findUnique).not.toHaveBeenCalled();
|
|
224
|
+
expectNoSecretIn(warn);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("(v) strict mode refuses a header-less request whatever host, origin or workspace header it carries", async () => {
|
|
228
|
+
setStrictMode(true);
|
|
229
|
+
const { request } = requestWith({
|
|
230
|
+
host: "w1.example",
|
|
231
|
+
"x-forwarded-host": "w1.example",
|
|
232
|
+
origin: "https://w1.example",
|
|
233
|
+
"x-ailk-workspace-id": W1,
|
|
234
|
+
});
|
|
235
|
+
await expect(resolveCallerSite(request)).resolves.toEqual({
|
|
236
|
+
kind: "refused",
|
|
237
|
+
reason: "key-required",
|
|
238
|
+
});
|
|
239
|
+
expect(db.spoke.findUnique).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("resolveCallerSite — the registry module is loaded only when a key needs it (AC-3 iv, AC-4)", () => {
|
|
244
|
+
/**
|
|
245
|
+
* A factory that throws when evaluated: requiring `lib/spoke-store.js` at
|
|
246
|
+
* all fails the test. `isolateModules` gives the resolver a fresh module
|
|
247
|
+
* registry, so a previous case's import cannot satisfy it.
|
|
248
|
+
*/
|
|
249
|
+
function withThrowingSpokeStore(
|
|
250
|
+
run: (mod: typeof import("../public-site-resolver.js")) => Promise<void>,
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
return new Promise<void>((resolve, reject) => {
|
|
253
|
+
jest.isolateModules(() => {
|
|
254
|
+
jest.doMock("../../lib/spoke-store.js", () => {
|
|
255
|
+
throw new Error("lib/spoke-store.js must not be imported");
|
|
256
|
+
});
|
|
257
|
+
// A fresh registry instance, required after doMock.
|
|
258
|
+
const mod = require("../public-site-resolver.js") as typeof import("../public-site-resolver.js");
|
|
259
|
+
run(mod).then(resolve, reject);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
afterEach(() => {
|
|
265
|
+
jest.dontMock("../../lib/spoke-store.js");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("a header-less request is anonymous: zero registry calls, no registry import", async () => {
|
|
269
|
+
await withThrowingSpokeStore(async (mod) => {
|
|
270
|
+
const { request } = requestWith({});
|
|
271
|
+
await expect(mod.resolveCallerSite(request)).resolves.toEqual({
|
|
272
|
+
kind: "anonymous",
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
expect(db.spoke.findUnique).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("a bare request object with no headers at all is anonymous (the frozen suite's shape)", async () => {
|
|
279
|
+
await withThrowingSpokeStore(async (mod) => {
|
|
280
|
+
await expect(
|
|
281
|
+
mod.resolveCallerSite({} as FastifyRequest),
|
|
282
|
+
).resolves.toEqual({ kind: "anonymous" });
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("(iv) a valid-looking key with DATABASE_URL unset is no-database, with no registry import", async () => {
|
|
287
|
+
delete process.env.DATABASE_URL;
|
|
288
|
+
await withThrowingSpokeStore(async (mod) => {
|
|
289
|
+
const { request, warn } = requestWith({ "x-ailk-spoke-key": KEY_S1 });
|
|
290
|
+
await expect(mod.resolveCallerSite(request)).resolves.toEqual({
|
|
291
|
+
kind: "refused",
|
|
292
|
+
reason: "no-database",
|
|
293
|
+
});
|
|
294
|
+
expectNoSecretIn(warn);
|
|
295
|
+
});
|
|
296
|
+
expect(db.spoke.findUnique).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("resolvePublicWorkspace — the spoke prefix (D3)", () => {
|
|
301
|
+
it.each(["database", "mdx"])(
|
|
302
|
+
"a spoke resolves to its own workspace on the %s adapter",
|
|
303
|
+
async (adapter) => {
|
|
304
|
+
process.env.CONTENT_ADAPTER = adapter;
|
|
305
|
+
const one = requestWith({ "x-ailk-spoke-key": KEY_S1 });
|
|
306
|
+
const two = requestWith({ "x-ailk-spoke-key": KEY_S2 });
|
|
307
|
+
|
|
308
|
+
await expect(resolvePublicWorkspace(one.request)).resolves.toEqual({
|
|
309
|
+
kind: "resolved",
|
|
310
|
+
workspaceId: W1,
|
|
311
|
+
});
|
|
312
|
+
await expect(resolvePublicWorkspace(two.request)).resolves.toEqual({
|
|
313
|
+
kind: "resolved",
|
|
314
|
+
workspaceId: W2,
|
|
315
|
+
});
|
|
316
|
+
// The one-workspace predicate is never consulted for a spoke.
|
|
317
|
+
expect(db.workspace.count).not.toHaveBeenCalled();
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
it("a refused key is unresolved (spoke-refused), never the alias or the default", async () => {
|
|
322
|
+
const { request } = requestWith({ "x-ailk-spoke-key": KEY_UNKNOWN });
|
|
323
|
+
await expect(resolvePublicWorkspace(request)).resolves.toEqual({
|
|
324
|
+
kind: "unresolved",
|
|
325
|
+
reason: "spoke-refused",
|
|
326
|
+
});
|
|
327
|
+
expect(db.workspace.count).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("a header-less request keeps today's answer — ambiguous on a three-workspace hub", async () => {
|
|
331
|
+
const { request } = requestWith({});
|
|
332
|
+
await expect(resolvePublicWorkspace(request)).resolves.toEqual({
|
|
333
|
+
kind: "unresolved",
|
|
334
|
+
reason: "ambiguous",
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("resolvePublicContentAdapter — a spoke's content read (AC-1)", () => {
|
|
340
|
+
it("on the database adapter, reads the spoke's own workspace", async () => {
|
|
341
|
+
const { request } = requestWith({ "x-ailk-spoke-key": KEY_S1 });
|
|
342
|
+
|
|
343
|
+
await expect(resolvePublicContentAdapter(request)).resolves.toEqual({
|
|
344
|
+
kind: "adapter-double",
|
|
345
|
+
});
|
|
346
|
+
expect(getContentAdapter).toHaveBeenCalledTimes(1);
|
|
347
|
+
expect(getContentAdapter).toHaveBeenCalledWith({
|
|
348
|
+
workspaceId: W1,
|
|
349
|
+
prisma: db,
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("on the mdx adapter, keeps reading the shared mdx source — no workspace argument", async () => {
|
|
354
|
+
process.env.CONTENT_ADAPTER = "mdx";
|
|
355
|
+
const { request } = requestWith({ "x-ailk-spoke-key": KEY_S1 });
|
|
356
|
+
|
|
357
|
+
await resolvePublicContentAdapter(request);
|
|
358
|
+
expect(getContentAdapter).toHaveBeenCalledTimes(1);
|
|
359
|
+
expect(getContentAdapter).toHaveBeenCalledWith();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it.each<[string, Record<string, string>, boolean]>([
|
|
363
|
+
["an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN }, false],
|
|
364
|
+
["a malformed key", { "x-ailk-spoke-key": "" }, false],
|
|
365
|
+
["a header-less request in strict mode", { host: "w1.example" }, true],
|
|
366
|
+
])("%s resolves to null — no adapter at all (AC-3)", async (_label, headers, strict) => {
|
|
367
|
+
setStrictMode(strict);
|
|
368
|
+
const { request, warn } = requestWith(headers);
|
|
369
|
+
|
|
370
|
+
await expect(resolvePublicContentAdapter(request)).resolves.toBeNull();
|
|
371
|
+
expect(getContentAdapter).not.toHaveBeenCalled();
|
|
372
|
+
expectNoSecretIn(warn);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
* keeps the two paths from drifting.
|
|
18
18
|
*
|
|
19
19
|
* PRECEDENCE, NOT PARALLELISM. When the multi-tenant module is present,
|
|
20
|
-
* `resolveActiveTenant` (the slice) is the entry point and
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
* `resolveActiveTenant` (the slice) is the entry point and, on its
|
|
21
|
+
* null-binding branch only (the session carries no active workspace), calls
|
|
22
|
+
* `resolveDefaultTenantForOperator` — the same pinned, verified-operator entry
|
|
23
|
+
* point the base guard uses (#7523). Its explicit-binding branch never reaches
|
|
24
|
+
* this file. Without the module, `middleware/default-tenant.ts` calls
|
|
25
|
+
* `resolveDefaultTenantForOperator` directly. There is one resolution, behind
|
|
26
|
+
* one pin check, reached two ways — never two resolutions.
|
|
26
27
|
*
|
|
27
28
|
* DEFAULT-DENY IS THE FLOOR. `resolveDefaultTenant` returns `null` — never a
|
|
28
29
|
* grant — the instant the deployment is not unambiguously single-tenant
|
|
@@ -145,14 +146,14 @@ async function bindOperatorMembership(userId: Id<"user">): Promise<void> {
|
|
|
145
146
|
* whose `User.email` matches `AILK_OPERATOR_EMAIL`, denying everyone when the
|
|
146
147
|
* pin is unset (#5693 security review, High finding).
|
|
147
148
|
*
|
|
148
|
-
* WHY
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
149
|
+
* WHY BOTH PATHS NEED THIS. Registering the review surface on a module-less
|
|
150
|
+
* build made two things reachable together that were previously unreachable
|
|
151
|
+
* there: this resolution's TOFU operator bind, and
|
|
152
|
+
* `services/waitlist-operator-reads.ts`'s `isPlatformOperator`, which grants
|
|
152
153
|
* `{kind: "all"}` — every site's rows, unfiltered — to an `owner` of the
|
|
153
|
-
* sentinel default Organization
|
|
154
|
-
*
|
|
155
|
-
*
|
|
154
|
+
* sentinel default Organization. Until #7523 that predicate never compared the
|
|
155
|
+
* caller to the pin; the pin was an on/off switch for it. It now goes through
|
|
156
|
+
* `isPinnedOperator` below too, but the bind is the half this function closes.
|
|
156
157
|
*
|
|
157
158
|
* Unpinned TOFU plus that predicate is an escalation. The site's auth
|
|
158
159
|
* auto-provisions a `User` on first login, so on a deployment with the pin
|
|
@@ -170,21 +171,29 @@ async function bindOperatorMembership(userId: Id<"user">): Promise<void> {
|
|
|
170
171
|
* but only at bind time and only for the bind; this is the one that gates the
|
|
171
172
|
* grant.
|
|
172
173
|
*
|
|
173
|
-
* The multi-tenant module
|
|
174
|
-
* `resolveDefaultTenant` directly, on
|
|
175
|
-
*
|
|
176
|
-
*
|
|
174
|
+
* The multi-tenant module reached the same race the same way: its null-binding
|
|
175
|
+
* branch called `resolveDefaultTenant` directly, so on a one-workspace
|
|
176
|
+
* multi-tenant deployment with the pin unset the first signed-in caller to
|
|
177
|
+
* touch a tenant route from `apps/web` (whose sessions never carry an active
|
|
178
|
+
* workspace) was seated as owner. Since #7523 that branch calls this wrapper
|
|
179
|
+
* too (docs/specs/7523-a-multi-tenant-session-with-spec.md D1), so a session
|
|
180
|
+
* with no active workspace is granted the default tenant only when its
|
|
181
|
+
* caller is the pinned, verified operator. Sessions that DO carry an active
|
|
182
|
+
* workspace — every Admin-app request — never reach this function.
|
|
177
183
|
*
|
|
178
184
|
* `docs/specs/single-tenant-owner-pin-spec.md` D5 already recommended pinning;
|
|
179
|
-
* on
|
|
185
|
+
* on both paths it is now mandatory rather than advisory, and unset fails
|
|
186
|
+
* closed.
|
|
180
187
|
*
|
|
181
188
|
* ONE KNOWN IMPRECISION, deliberate. `User.email` is `@unique` in Postgres,
|
|
182
189
|
* which is byte-exact, while this comparison lowercases both sides — so two
|
|
183
190
|
* distinct rows (`Operator@x.com` and `operator@x.com`) could both satisfy one
|
|
184
|
-
* pin.
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
191
|
+
* pin. `apps/web/auth.ts` no longer configures social providers only: it also
|
|
192
|
+
* enables email+password and magic-link sign-in, so the address can be
|
|
193
|
+
* caller-chosen. Only a VERIFIED row is compared (`findVerifiedEmail`), and
|
|
194
|
+
* verifying it needs a mailbox proof, so a second row for the pin's address
|
|
195
|
+
* in a different case is reachable only by someone who controls that
|
|
196
|
+
* mailbox. The durable fix is still case-insensitive email uniqueness in the
|
|
188
197
|
* schema (citext, or lowercase-at-write) so "matches the pin" and "is unique"
|
|
189
198
|
* agree. Matching case-sensitively here instead would be worse: it would lock
|
|
190
199
|
* out an operator who typed their own address with different capitalisation.
|
|
@@ -192,9 +201,26 @@ async function bindOperatorMembership(userId: Id<"user">): Promise<void> {
|
|
|
192
201
|
export async function resolveDefaultTenantForOperator(
|
|
193
202
|
userId: Id<"user">,
|
|
194
203
|
): Promise<TenantContext | null> {
|
|
204
|
+
return (await isPinnedOperator(userId)) ? resolveDefaultTenant(userId) : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The one pin-match predicate in `apps/api` (#7523 D2): `true` only when
|
|
209
|
+
* `AILK_OPERATOR_EMAIL` is set and the caller's VERIFIED `User.email` equals
|
|
210
|
+
* it. Both sides are trimmed and lowercased; a blank pin counts as unset and
|
|
211
|
+
* refuses before any read, so it can never "match" a blank `User.email`.
|
|
212
|
+
*
|
|
213
|
+
* Shared by the two tenant guards (through `resolveDefaultTenantForOperator`)
|
|
214
|
+
* and by `services/waitlist-operator-reads.ts`'s `isPlatformOperator`. It
|
|
215
|
+
* lives in this base-build file because that operator-read file rides more
|
|
216
|
+
* than one module slice and may import only files every build ships.
|
|
217
|
+
* `bindOperatorMembership` keeps its own inline check: it must allow a bind
|
|
218
|
+
* when the pin is unset, which this predicate never does.
|
|
219
|
+
*/
|
|
220
|
+
export async function isPinnedOperator(userId: Id<"user">): Promise<boolean> {
|
|
195
221
|
const pinnedEmail = process.env.AILK_OPERATOR_EMAIL?.trim().toLowerCase();
|
|
196
222
|
if (!pinnedEmail) {
|
|
197
|
-
return
|
|
223
|
+
return false;
|
|
198
224
|
}
|
|
199
225
|
// The verified-email read is `services/verified-email.ts` (#5701 D4) — the
|
|
200
226
|
// one this and the four other email-keyed paths share. It returns the
|
|
@@ -202,12 +228,9 @@ export async function resolveDefaultTenantForOperator(
|
|
|
202
228
|
// against the pin a proven claim rather than an asserted one.
|
|
203
229
|
const verified = await findVerifiedEmail(userId);
|
|
204
230
|
if (!verified.ok) {
|
|
205
|
-
return
|
|
206
|
-
}
|
|
207
|
-
if (verified.value.email.trim().toLowerCase() !== pinnedEmail) {
|
|
208
|
-
return null;
|
|
231
|
+
return false;
|
|
209
232
|
}
|
|
210
|
-
return
|
|
233
|
+
return verified.value.email.trim().toLowerCase() === pinnedEmail;
|
|
211
234
|
}
|
|
212
235
|
|
|
213
236
|
/**
|