create-ailk 0.11.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.json +36 -3
- package/component-catalog.md +10 -7
- 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/__tests__/content-write-production-boot.test.ts +104 -0
- package/templates/apps/api/src/__tests__/root-modules.test.ts +175 -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/config/modules.ts +38 -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 +56 -1
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +147 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +19 -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/revalidation.ts +8 -1
- 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 +49 -15
- 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 +31 -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 +1 -1
- package/versions.json +34 -34
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file hub-require-spoke-key.test.ts
|
|
3
|
+
* @description `AILK_HUB_REQUIRE_SPOKE_KEY` — the hub's strict-mode flag
|
|
4
|
+
* (#7532, spec docs/specs/7532-a-spoke-s-public-reads-spec.md D7, AC-5).
|
|
5
|
+
*
|
|
6
|
+
* `true`/`1` turn it on; unset, empty, `false` and `0` leave it off —
|
|
7
|
+
* case-insensitively, after trimming. Any other value refuses the boot with a
|
|
8
|
+
* message naming the variable, never the value. On with no `DATABASE_URL`
|
|
9
|
+
* refuses the boot too: strict mode with no registry would refuse every call.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Mock dotenv so a developer's local .env never leaks into the config module.
|
|
13
|
+
jest.mock('dotenv', () => ({
|
|
14
|
+
config: jest.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const mockLoggerError = jest.fn();
|
|
18
|
+
const mockLoggerWarn = jest.fn();
|
|
19
|
+
jest.mock('@working-theory/observability', () => ({
|
|
20
|
+
logger: {
|
|
21
|
+
error: (...args: unknown[]) => mockLoggerError(...args),
|
|
22
|
+
warn: (...args: unknown[]) => mockLoggerWarn(...args),
|
|
23
|
+
info: jest.fn(),
|
|
24
|
+
debug: jest.fn(),
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
|
|
29
|
+
const originalEnv = process.env;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
jest.resetModules();
|
|
33
|
+
process.env = { ...originalEnv };
|
|
34
|
+
delete process.env.AILK_HUB_REQUIRE_SPOKE_KEY;
|
|
35
|
+
process.env.DATABASE_URL = 'postgresql://test:test@localhost/test';
|
|
36
|
+
jest.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
process.env = originalEnv;
|
|
41
|
+
jest.restoreAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it.each(['true', 'TRUE', '1', ' True '])('%j parses on', (raw) => {
|
|
45
|
+
process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
|
|
46
|
+
const { config } = require('../index');
|
|
47
|
+
expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each([undefined, '', ' ', 'false', 'FALSE', '0'])('%j parses off', (raw) => {
|
|
51
|
+
if (raw !== undefined) process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
|
|
52
|
+
const { config } = require('../index');
|
|
53
|
+
expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('any other value refuses the boot, naming the variable and never the value', () => {
|
|
57
|
+
process.env.AILK_HUB_REQUIRE_SPOKE_KEY = 'yes-sentinel-value';
|
|
58
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
59
|
+
throw new Error('Process exit called');
|
|
60
|
+
});
|
|
61
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
62
|
+
|
|
63
|
+
expect(() => require('../index')).toThrow('Process exit called');
|
|
64
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
65
|
+
|
|
66
|
+
const logged = JSON.stringify([mockLoggerError.mock.calls, consoleSpy.mock.calls]);
|
|
67
|
+
expect(logged).toContain('AILK_HUB_REQUIRE_SPOKE_KEY');
|
|
68
|
+
expect(logged).not.toContain('yes-sentinel-value');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('on with DATABASE_URL unset refuses the boot', () => {
|
|
72
|
+
process.env.AILK_HUB_REQUIRE_SPOKE_KEY = 'true';
|
|
73
|
+
delete process.env.DATABASE_URL;
|
|
74
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
75
|
+
throw new Error('Process exit called');
|
|
76
|
+
});
|
|
77
|
+
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
78
|
+
|
|
79
|
+
expect(() => require('../index')).toThrow('Process exit called');
|
|
80
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
81
|
+
expect(JSON.stringify(mockLoggerError.mock.calls)).toContain(
|
|
82
|
+
'AILK_HUB_REQUIRE_SPOKE_KEY',
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('off with DATABASE_URL unset still boots database-less, as today', () => {
|
|
87
|
+
delete process.env.DATABASE_URL;
|
|
88
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
89
|
+
throw new Error('Process exit called');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const { config } = require('../index');
|
|
93
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
94
|
+
expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -102,6 +102,23 @@ export interface Config {
|
|
|
102
102
|
// nothing (§ D2 row 1) — the same fail-closed posture STRIPE_WEBHOOK_SECRET
|
|
103
103
|
// unset already has.
|
|
104
104
|
CAL_WEBHOOK_SECRET?: string;
|
|
105
|
+
|
|
106
|
+
// Per-workspace scheduling connections (#7533 D2) — the AES-256-GCM key
|
|
107
|
+
// services/scheduling/connections.ts encrypts each connection's provider
|
|
108
|
+
// credential and webhook secret under. Canonical standard base64 of exactly
|
|
109
|
+
// 32 random bytes (`openssl rand -base64 32`), checked at startup by
|
|
110
|
+
// resolveSchedulingCredentialKey() below; a refused value stops the API.
|
|
111
|
+
// Optional: unset (or empty) means connections cannot be written, and the
|
|
112
|
+
// env-configured CAL_API_KEY path works exactly as before. Held decoded,
|
|
113
|
+
// never as the raw string, and never logged.
|
|
114
|
+
SCHEDULING_CREDENTIAL_KEY?: Buffer;
|
|
115
|
+
// Hub strict mode (#7532 D7). A hub serving several spokes sets it so a
|
|
116
|
+
// request to a spoke-aware public surface (meeting types, the three lead
|
|
117
|
+
// writes, booking, the public content reads) that carries no
|
|
118
|
+
// `x-ailk-spoke-key` resolves to nothing. Off (the default) keeps today's
|
|
119
|
+
// header-less resolution, byte for byte. Parsed by parseHubRequireSpokeKey()
|
|
120
|
+
// below; read by services/public-site-resolver.ts.
|
|
121
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: boolean;
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
/** Why an AILK_TRUSTED_ORIGINS entry was refused (#7395 D2). */
|
|
@@ -201,6 +218,47 @@ export function resolveTrustedOrigins(env: {
|
|
|
201
218
|
return { origins: Object.freeze([...new Set(origins)]), refused, primaryUnusable };
|
|
202
219
|
}
|
|
203
220
|
|
|
221
|
+
/** Why a SCHEDULING_CREDENTIAL_KEY value was refused (#7533 D2). */
|
|
222
|
+
export type SchedulingCredentialKeyRefusal = 'not-canonical-base64' | 'length';
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The scheduling-connection key (#7533 D2, SEC-7533-6). Pure;
|
|
226
|
+
* `validateConfig()` calls it once.
|
|
227
|
+
*
|
|
228
|
+
* Accepts only CANONICAL standard base64 — `Buffer.from(v, 'base64')
|
|
229
|
+
* .toString('base64') === v` — of exactly 32 bytes. Base64 decoding silently
|
|
230
|
+
* skips characters outside its alphabet, so without the round-trip a
|
|
231
|
+
* base64url value, or a passphrase that happens to decode to 32 bytes, would
|
|
232
|
+
* be taken as a key. `''` and unset are both `unset`. A refusal carries the
|
|
233
|
+
* reason kind only, never any part of the value.
|
|
234
|
+
*/
|
|
235
|
+
export function resolveSchedulingCredentialKey(
|
|
236
|
+
raw: string | undefined,
|
|
237
|
+
):
|
|
238
|
+
| { kind: 'unset' }
|
|
239
|
+
| { kind: 'ok'; key: Buffer }
|
|
240
|
+
| { kind: 'refused'; reason: SchedulingCredentialKeyRefusal } {
|
|
241
|
+
if (raw === undefined || raw === '') return { kind: 'unset' };
|
|
242
|
+
const key = Buffer.from(raw, 'base64');
|
|
243
|
+
if (key.toString('base64') !== raw) {
|
|
244
|
+
return { kind: 'refused', reason: 'not-canonical-base64' };
|
|
245
|
+
}
|
|
246
|
+
if (key.length !== 32) return { kind: 'refused', reason: 'length' };
|
|
247
|
+
return { kind: 'ok', key };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7): `true`/`1` → on; unset, empty,
|
|
252
|
+
* `false`, `0` → off — case-insensitive, after trimming. `null` for any other
|
|
253
|
+
* value, which `validateConfig()` refuses at boot.
|
|
254
|
+
*/
|
|
255
|
+
export function parseHubRequireSpokeKey(raw: string | undefined): boolean | null {
|
|
256
|
+
const value = (raw ?? '').trim().toLowerCase();
|
|
257
|
+
if (value === 'true' || value === '1') return true;
|
|
258
|
+
if (value === '' || value === 'false' || value === '0') return false;
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
204
262
|
/** Enabled modules whose state lives in the database (see validateConfig). */
|
|
205
263
|
const DATABASE_MODULES = [
|
|
206
264
|
'commerce',
|
|
@@ -267,6 +325,40 @@ function validateConfig(): Config {
|
|
|
267
325
|
);
|
|
268
326
|
}
|
|
269
327
|
|
|
328
|
+
// #7533 D2 — a set-but-refused SCHEDULING_CREDENTIAL_KEY stops the API in
|
|
329
|
+
// every environment, as a refused AILK_TRUSTED_ORIGINS entry does: a key
|
|
330
|
+
// taken in some other shape would encrypt every connection under a value
|
|
331
|
+
// the operator did not mean. The reason kind only, never the value.
|
|
332
|
+
const schedulingKey = resolveSchedulingCredentialKey(process.env.SCHEDULING_CREDENTIAL_KEY);
|
|
333
|
+
if (schedulingKey.kind === 'refused') {
|
|
334
|
+
const message =
|
|
335
|
+
'❌ SCHEDULING_CREDENTIAL_KEY must be canonical standard base64 of exactly 32 bytes (openssl rand -base64 32)';
|
|
336
|
+
logger.error({ reason: schedulingKey.reason }, message);
|
|
337
|
+
console.error(message, JSON.stringify({ reason: schedulingKey.reason }));
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// #7532 D7 — the hub strict-mode flag. An unparseable value stops the API:
|
|
342
|
+
// the variable is opt-in, and a typo read as "off" would silently leave the
|
|
343
|
+
// hub open to header-less callers. The message names the variable, never
|
|
344
|
+
// its value. On with no DATABASE_URL stops it too: strict mode with no
|
|
345
|
+
// registry to look a key up in would refuse every call.
|
|
346
|
+
const hubRequireSpokeKey = parseHubRequireSpokeKey(process.env.AILK_HUB_REQUIRE_SPOKE_KEY);
|
|
347
|
+
if (hubRequireSpokeKey === null) {
|
|
348
|
+
const message =
|
|
349
|
+
'❌ AILK_HUB_REQUIRE_SPOKE_KEY must be true, 1, false, 0 or unset';
|
|
350
|
+
logger.error(message);
|
|
351
|
+
console.error(message);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
if (hubRequireSpokeKey && !process.env.DATABASE_URL) {
|
|
355
|
+
const message =
|
|
356
|
+
'❌ AILK_HUB_REQUIRE_SPOKE_KEY is on but DATABASE_URL is not set — the spoke registry lives in the database';
|
|
357
|
+
logger.error(message);
|
|
358
|
+
console.error(message);
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
|
|
270
362
|
return {
|
|
271
363
|
NODE_ENV: nodeEnv,
|
|
272
364
|
PORT: parseInt(process.env.PORT || '3002', 10),
|
|
@@ -334,6 +426,11 @@ function validateConfig(): Config {
|
|
|
334
426
|
...(process.env.CAL_WEBHOOK_SECRET !== undefined && {
|
|
335
427
|
CAL_WEBHOOK_SECRET: process.env.CAL_WEBHOOK_SECRET,
|
|
336
428
|
}),
|
|
429
|
+
|
|
430
|
+
...(schedulingKey.kind === 'ok' && {
|
|
431
|
+
SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
|
|
432
|
+
}),
|
|
433
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey === true,
|
|
337
434
|
};
|
|
338
435
|
}
|
|
339
436
|
|
|
@@ -43,3 +43,41 @@ function enabledModules(): Set<string> {
|
|
|
43
43
|
export function isModuleEnabled(moduleName: string): boolean {
|
|
44
44
|
return enabledModules().has(moduleName);
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The recognized optional-module keys, sorted — the vocabulary the root index
|
|
49
|
+
* may report (#7501). Filtering to this list is what keeps the anonymous
|
|
50
|
+
* `GET /` from echoing whatever text an operator typed into
|
|
51
|
+
* `AILK_ENABLED_MODULES`. `docs` is deliberately absent: no apps/api route
|
|
52
|
+
* registers under it, so reporting it would break "modules this process
|
|
53
|
+
* registered routes for".
|
|
54
|
+
*/
|
|
55
|
+
export const OPTIONAL_MODULES = [
|
|
56
|
+
"commerce",
|
|
57
|
+
"content-write",
|
|
58
|
+
"multi-tenant",
|
|
59
|
+
"scheduling",
|
|
60
|
+
"signal-collectors",
|
|
61
|
+
"workspace",
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The optional modules whose routes this process registers, sorted. Called
|
|
66
|
+
* once at build time by `server.ts`, so the answer matches what registered
|
|
67
|
+
* even if the env changes afterwards.
|
|
68
|
+
*
|
|
69
|
+
* `content-write` counts only alongside a tenancy module (`workspace` or
|
|
70
|
+
* `multi-tenant`) — the exact predicate `server.ts` registers the editor's
|
|
71
|
+
* content routes under. That gate stays a literal there (the
|
|
72
|
+
* slice-registration lint reads `isModuleEnabled("X")` guards by text), so the
|
|
73
|
+
* predicate is repeated here and `root-modules.test.ts` asserts they agree.
|
|
74
|
+
*/
|
|
75
|
+
export function servedModules(): string[] {
|
|
76
|
+
return OPTIONAL_MODULES.filter((name) => {
|
|
77
|
+
if (!isModuleEnabled(name)) return false;
|
|
78
|
+
if (name === "content-write") {
|
|
79
|
+
return isModuleEnabled("workspace") || isModuleEnabled("multi-tenant");
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -161,6 +161,11 @@ export const prisma = {
|
|
|
161
161
|
upsert: jest.fn(),
|
|
162
162
|
deleteMany: jest.fn(),
|
|
163
163
|
},
|
|
164
|
+
// `findUnique` — the public meetings read (`lib/public-meetings-config.ts`),
|
|
165
|
+
// which a spoke's meeting-types read and booking gate reach (#7532 D3/D4).
|
|
166
|
+
workspaceMeetings: {
|
|
167
|
+
findUnique: jest.fn(),
|
|
168
|
+
},
|
|
164
169
|
// ── Inbox namespace (Lead, Subscriber, Callback) ─────────────────────────
|
|
165
170
|
lead: {
|
|
166
171
|
create: jest.fn(),
|
|
@@ -355,6 +360,33 @@ export const prisma = {
|
|
|
355
360
|
findMany: jest.fn(),
|
|
356
361
|
updateMany: jest.fn(),
|
|
357
362
|
},
|
|
363
|
+
// ── Config namespace (SchedulingConnection — #7533) ─────────────────────
|
|
364
|
+
// Every call lives in services/scheduling/connections.ts: `findUnique` by
|
|
365
|
+
// `workspaceId` (the provider read + the summary) or by `webhookToken` (the
|
|
366
|
+
// per-connection webhook); `upsert` keyed on the tenant's workspace (the
|
|
367
|
+
// connect/replace write); `deleteMany` keyed the same way (the revoke).
|
|
368
|
+
schedulingConnection: {
|
|
369
|
+
findUnique: jest.fn(),
|
|
370
|
+
upsert: jest.fn(),
|
|
371
|
+
deleteMany: jest.fn(),
|
|
372
|
+
},
|
|
373
|
+
// ── #7532 — the hub's spoke registry (auth) and the booking origin (inbox) ─
|
|
374
|
+
// `spoke.findUnique` — the resolver's one lookup by credential digest;
|
|
375
|
+
// `create`/`updateMany`/`findMany` — the operator CLI's register, revoke and
|
|
376
|
+
// list (`lib/spoke-store.ts`). `appointmentOrigin.createMany` — the bind's
|
|
377
|
+
// ON CONFLICT DO NOTHING insert; `findUnique` — the origin read the bind and
|
|
378
|
+
// the webhook make (`services/scheduling/appointments.ts`).
|
|
379
|
+
spoke: {
|
|
380
|
+
findUnique: jest.fn(),
|
|
381
|
+
findFirst: jest.fn(),
|
|
382
|
+
findMany: jest.fn(),
|
|
383
|
+
create: jest.fn(),
|
|
384
|
+
updateMany: jest.fn(),
|
|
385
|
+
},
|
|
386
|
+
appointmentOrigin: {
|
|
387
|
+
findUnique: jest.fn(),
|
|
388
|
+
createMany: jest.fn(),
|
|
389
|
+
},
|
|
358
390
|
// ── Content namespace (UsageEvent, UsagePeriod — FEAT-047 P1.S1/#3709) ──
|
|
359
391
|
usageEvent: {
|
|
360
392
|
create: jest.fn(),
|
|
@@ -385,6 +417,9 @@ export const prisma = {
|
|
|
385
417
|
// `mockTransaction` helper models commit/rollback so atomicity is asserted,
|
|
386
418
|
// not assumed.
|
|
387
419
|
$transaction: jest.fn(),
|
|
420
|
+
// `$executeRaw` — the per-booking `pg_advisory_xact_lock` (#7532 D5),
|
|
421
|
+
// taken inside `$transaction`. Suites on that path arm both.
|
|
422
|
+
$executeRaw: jest.fn(),
|
|
388
423
|
$connect: jest.fn(),
|
|
389
424
|
$disconnect: jest.fn(),
|
|
390
425
|
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file spoke-store.test.ts
|
|
3
|
+
*
|
|
4
|
+
* The hub's spoke registry boundary (#7532, spec
|
|
5
|
+
* docs/specs/7532-a-spoke-s-public-reads-spec.md D2, D7, AC-5): minting a key,
|
|
6
|
+
* its digest, the one lookup the resolver makes, and the three operator
|
|
7
|
+
* functions the `spokes` CLI calls.
|
|
8
|
+
*
|
|
9
|
+
* External dependency boundary mocked:
|
|
10
|
+
* - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// 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.
|
|
14
|
+
jest.mock("../prisma.js");
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
import { prisma } from "../prisma.js";
|
|
19
|
+
import {
|
|
20
|
+
SPOKE_KEY_PREFIX,
|
|
21
|
+
findSpokeByKeyHash,
|
|
22
|
+
hashSpokeKey,
|
|
23
|
+
listSpokes,
|
|
24
|
+
mintSpokeKey,
|
|
25
|
+
registerSpoke,
|
|
26
|
+
revokeSpoke,
|
|
27
|
+
} from "../spoke-store.js";
|
|
28
|
+
|
|
29
|
+
const db = jest.mocked(prisma);
|
|
30
|
+
|
|
31
|
+
const W1 = "wksp_00000000-0000-7000-8000-0000000000b1";
|
|
32
|
+
const S1 = "spoke_00000000-0000-7000-8000-0000000000c1";
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
jest.resetAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("mintSpokeKey / hashSpokeKey (D2)", () => {
|
|
39
|
+
it("mints `ailk_spk_` + 32 random bytes in base64url — 52 characters", () => {
|
|
40
|
+
const key = mintSpokeKey();
|
|
41
|
+
expect(key.startsWith(SPOKE_KEY_PREFIX)).toBe(true);
|
|
42
|
+
expect(key).toHaveLength(52);
|
|
43
|
+
expect(key.slice(SPOKE_KEY_PREFIX.length)).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("never mints the same key twice", () => {
|
|
47
|
+
const keys = new Set(Array.from({ length: 50 }, () => mintSpokeKey()));
|
|
48
|
+
expect(keys.size).toBe(50);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("digests the TRIMMED key to lowercase hex SHA-256", () => {
|
|
52
|
+
const key = mintSpokeKey();
|
|
53
|
+
const expected = createHash("sha256").update(key).digest("hex");
|
|
54
|
+
expect(hashSpokeKey(key)).toBe(expected);
|
|
55
|
+
expect(hashSpokeKey(` ${key}\t`)).toBe(expected);
|
|
56
|
+
expect(hashSpokeKey(key)).toMatch(/^[0-9a-f]{64}$/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("findSpokeByKeyHash (D3 step 5)", () => {
|
|
61
|
+
it("is one findUnique by digest, selecting only what the resolver needs", async () => {
|
|
62
|
+
const row = { id: S1, workspaceId: W1, revokedAt: null };
|
|
63
|
+
db.spoke.findUnique.mockResolvedValue(row as never);
|
|
64
|
+
const digest = "a".repeat(64);
|
|
65
|
+
|
|
66
|
+
await expect(findSpokeByKeyHash(digest)).resolves.toEqual(row);
|
|
67
|
+
expect(db.spoke.findUnique).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(db.spoke.findUnique).toHaveBeenCalledWith({
|
|
69
|
+
where: { credentialHash: digest },
|
|
70
|
+
select: { id: true, workspaceId: true, revokedAt: true },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("answers null when no row carries the digest", async () => {
|
|
75
|
+
db.spoke.findUnique.mockResolvedValue(null);
|
|
76
|
+
await expect(findSpokeByKeyHash("b".repeat(64))).resolves.toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("registerSpoke (D7, AC-5)", () => {
|
|
81
|
+
it("stores the key's digest and a 17-character prefix, never the key", async () => {
|
|
82
|
+
db.workspace.findUnique.mockResolvedValue({ id: W1 } as never);
|
|
83
|
+
db.spoke.create.mockImplementation((async (args: {
|
|
84
|
+
data: Record<string, unknown>;
|
|
85
|
+
}) => ({ id: S1, createdAt: new Date(), ...args.data })) as never);
|
|
86
|
+
|
|
87
|
+
const result = await registerSpoke({ workspaceId: W1, name: "acme" });
|
|
88
|
+
|
|
89
|
+
expect(result.ok).toBe(true);
|
|
90
|
+
if (!result.ok) return;
|
|
91
|
+
const createArg = db.spoke.create.mock.calls[0]![0] as {
|
|
92
|
+
data: Record<string, unknown>;
|
|
93
|
+
};
|
|
94
|
+
expect(createArg.data["credentialHash"]).toMatch(/^[0-9a-f]{64}$/);
|
|
95
|
+
expect(createArg.data["credentialHash"]).toBe(hashSpokeKey(result.key));
|
|
96
|
+
expect(createArg.data["credentialPrefix"]).toBe(result.key.slice(0, 17));
|
|
97
|
+
expect(createArg.data["workspaceId"]).toBe(W1);
|
|
98
|
+
expect(createArg.data["name"]).toBe("acme");
|
|
99
|
+
// No create argument equals the returned key.
|
|
100
|
+
expect(JSON.stringify(createArg)).not.toContain(result.key);
|
|
101
|
+
expect(result.spoke.id).toBe(S1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("refuses a workspace that does not exist, creating nothing", async () => {
|
|
105
|
+
db.workspace.findUnique.mockResolvedValue(null);
|
|
106
|
+
|
|
107
|
+
await expect(
|
|
108
|
+
registerSpoke({ workspaceId: W1, name: "acme" }),
|
|
109
|
+
).resolves.toEqual({ ok: false, reason: "workspace-not-found" });
|
|
110
|
+
expect(db.spoke.create).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("revokeSpoke (D7, AC-5)", () => {
|
|
115
|
+
it("sets revokedAt on that one unrevoked row only", async () => {
|
|
116
|
+
db.spoke.updateMany.mockResolvedValue({ count: 1 });
|
|
117
|
+
|
|
118
|
+
await expect(revokeSpoke(S1)).resolves.toBe(true);
|
|
119
|
+
const arg = db.spoke.updateMany.mock.calls[0]![0] as {
|
|
120
|
+
where: unknown;
|
|
121
|
+
data: { revokedAt: unknown };
|
|
122
|
+
};
|
|
123
|
+
expect(arg.where).toEqual({ id: S1, revokedAt: null });
|
|
124
|
+
expect(arg.data.revokedAt).toBeInstanceOf(Date);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("reports false when no unrevoked row matched", async () => {
|
|
128
|
+
db.spoke.updateMany.mockResolvedValue({ count: 0 });
|
|
129
|
+
await expect(revokeSpoke(S1)).resolves.toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("listSpokes (D7, AC-5)", () => {
|
|
134
|
+
it("never selects the credential digest", async () => {
|
|
135
|
+
db.spoke.findMany.mockResolvedValue([]);
|
|
136
|
+
|
|
137
|
+
await listSpokes();
|
|
138
|
+
|
|
139
|
+
const arg = db.spoke.findMany.mock.calls[0]![0] as {
|
|
140
|
+
select: Record<string, boolean>;
|
|
141
|
+
};
|
|
142
|
+
expect(arg.select).not.toHaveProperty("credentialHash");
|
|
143
|
+
expect(arg.select).toEqual({
|
|
144
|
+
id: true,
|
|
145
|
+
name: true,
|
|
146
|
+
workspaceId: true,
|
|
147
|
+
credentialPrefix: true,
|
|
148
|
+
createdAt: true,
|
|
149
|
+
revokedAt: true,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's spoke registry — its ONE data-access boundary (#7532, spec
|
|
3
|
+
* docs/specs/7532-a-spoke-s-public-reads-spec.md D1, D2, D7).
|
|
4
|
+
*
|
|
5
|
+
* A spoke is a separately deployed site that calls this API server-to-server,
|
|
6
|
+
* bound to exactly one workspace. It authenticates with a hub-minted key sent
|
|
7
|
+
* in `x-ailk-spoke-key`. This file is the only one that names `prisma.spoke`.
|
|
8
|
+
*
|
|
9
|
+
* THE KEY IS NEVER STORED. The row keeps the lowercase hex SHA-256 of the
|
|
10
|
+
* trimmed key (`credentialHash`) and its first 17 characters
|
|
11
|
+
* (`credentialPrefix`: `ailk_spk_` plus 8 random ones) so an operator can
|
|
12
|
+
* recognise a key in a list. A fast digest is enough: the key carries 256
|
|
13
|
+
* bits of entropy, so a lookup by its unique digest leaks nothing usable.
|
|
14
|
+
*
|
|
15
|
+
* WHAT A KEY PROVES. It identifies a deployed SITE, not a person: every
|
|
16
|
+
* anonymous visitor of that site, and every agent using its `apps/mcp`, acts
|
|
17
|
+
* through it. It grants what the site's anonymous visitors can already do on
|
|
18
|
+
* the hub — read that workspace's enabled meeting types and public content,
|
|
19
|
+
* and write leads and bookings into it.
|
|
20
|
+
*
|
|
21
|
+
* Two kinds of caller, kept apart:
|
|
22
|
+
* - the public resolver (`services/public-site-resolver.ts`) calls
|
|
23
|
+
* `hashSpokeKey` and `findSpokeByKeyHash` only;
|
|
24
|
+
* - the operator CLI (`bin/spokes.ts`) calls `registerSpoke`, `revokeSpoke`
|
|
25
|
+
* and `listSpokes`. No route imports them.
|
|
26
|
+
*
|
|
27
|
+
* Nothing caches a row: a revocation takes effect on the next request (NFR4).
|
|
28
|
+
*/
|
|
29
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
30
|
+
|
|
31
|
+
import { prisma } from "./prisma.js";
|
|
32
|
+
|
|
33
|
+
/** Every spoke key starts with this, so a leaked one is recognisable. */
|
|
34
|
+
export const SPOKE_KEY_PREFIX = "ailk_spk_";
|
|
35
|
+
|
|
36
|
+
/** How much of the key the row keeps for recognition: the prefix + 8 characters. */
|
|
37
|
+
const CREDENTIAL_PREFIX_LENGTH = SPOKE_KEY_PREFIX.length + 8;
|
|
38
|
+
|
|
39
|
+
/** A new key: `ailk_spk_` + 32 random bytes in base64url (52 characters). */
|
|
40
|
+
export function mintSpokeKey(): string {
|
|
41
|
+
return `${SPOKE_KEY_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The stored digest of a key: lowercase hex SHA-256 of the TRIMMED value. */
|
|
45
|
+
export function hashSpokeKey(key: string): string {
|
|
46
|
+
return createHash("sha256").update(key.trim()).digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SpokeLookupRow {
|
|
50
|
+
id: string;
|
|
51
|
+
workspaceId: string;
|
|
52
|
+
revokedAt: Date | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The one read the resolver makes: a `findUnique` by digest. */
|
|
56
|
+
export async function findSpokeByKeyHash(
|
|
57
|
+
credentialHash: string,
|
|
58
|
+
): Promise<SpokeLookupRow | null> {
|
|
59
|
+
return prisma.spoke.findUnique({
|
|
60
|
+
where: { credentialHash },
|
|
61
|
+
select: { id: true, workspaceId: true, revokedAt: true },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Operator functions — `bin/spokes.ts` only ────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export interface SpokeListRow {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
workspaceId: string;
|
|
71
|
+
credentialPrefix: string;
|
|
72
|
+
createdAt: Date;
|
|
73
|
+
revokedAt: Date | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SPOKE_LIST_SELECT = {
|
|
77
|
+
id: true,
|
|
78
|
+
name: true,
|
|
79
|
+
workspaceId: true,
|
|
80
|
+
credentialPrefix: true,
|
|
81
|
+
createdAt: true,
|
|
82
|
+
revokedAt: true,
|
|
83
|
+
} as const;
|
|
84
|
+
|
|
85
|
+
export type RegisterSpokeResult =
|
|
86
|
+
| { ok: true; spoke: SpokeListRow; key: string }
|
|
87
|
+
| { ok: false; reason: "workspace-not-found" };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register a spoke bound to one existing workspace. The returned `key` is the
|
|
91
|
+
* only copy that will ever exist: the caller prints it once.
|
|
92
|
+
*/
|
|
93
|
+
export async function registerSpoke(params: {
|
|
94
|
+
workspaceId: string;
|
|
95
|
+
name: string;
|
|
96
|
+
}): Promise<RegisterSpokeResult> {
|
|
97
|
+
const workspace = await prisma.workspace.findUnique({
|
|
98
|
+
where: { id: params.workspaceId },
|
|
99
|
+
select: { id: true },
|
|
100
|
+
});
|
|
101
|
+
if (!workspace) return { ok: false, reason: "workspace-not-found" };
|
|
102
|
+
|
|
103
|
+
const key = mintSpokeKey();
|
|
104
|
+
const spoke = await prisma.spoke.create({
|
|
105
|
+
data: {
|
|
106
|
+
workspaceId: params.workspaceId,
|
|
107
|
+
name: params.name,
|
|
108
|
+
credentialHash: hashSpokeKey(key),
|
|
109
|
+
credentialPrefix: key.slice(0, CREDENTIAL_PREFIX_LENGTH),
|
|
110
|
+
},
|
|
111
|
+
select: SPOKE_LIST_SELECT,
|
|
112
|
+
});
|
|
113
|
+
return { ok: true, spoke, key };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Revoke one spoke. `false` when no unrevoked spoke has that id. */
|
|
117
|
+
export async function revokeSpoke(id: string): Promise<boolean> {
|
|
118
|
+
const { count } = await prisma.spoke.updateMany({
|
|
119
|
+
where: { id, revokedAt: null },
|
|
120
|
+
data: { revokedAt: new Date() },
|
|
121
|
+
});
|
|
122
|
+
return count === 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every spoke, newest first. Never selects the credential digest. */
|
|
126
|
+
export async function listSpokes(): Promise<SpokeListRow[]> {
|
|
127
|
+
return prisma.spoke.findMany({
|
|
128
|
+
select: SPOKE_LIST_SELECT,
|
|
129
|
+
orderBy: { createdAt: "desc" },
|
|
130
|
+
});
|
|
131
|
+
}
|