create-ailk 0.11.5 → 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 +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 +24 -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,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
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file spoke.test.ts
|
|
3
|
+
*
|
|
4
|
+
* `POST /v1/leads/{submit,subscribe,callback}` called by a registered spoke of
|
|
5
|
+
* a multi-workspace hub (#7532, spec
|
|
6
|
+
* docs/specs/7532-a-spoke-s-public-reads-spec.md D3, D4; AC-2, AC-3, AC-5).
|
|
7
|
+
* The three real lead routes run through the real resolver and the real
|
|
8
|
+
* lead-routing service; only the database and the email provider are doubles.
|
|
9
|
+
*
|
|
10
|
+
* Fixture (spec § 5): three workspaces — the sentinel default `F` (the site
|
|
11
|
+
* alias), `W1` and `W2`; spokes `S1` → `W1`, `S1b` → `W1`, `S2` → `W2`.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { Writable } from "node:stream";
|
|
15
|
+
|
|
16
|
+
import Fastify from "fastify";
|
|
17
|
+
import type { FastifyInstance } from "fastify";
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
|
|
20
|
+
jest.mock("../../../lib/prisma.js");
|
|
21
|
+
|
|
22
|
+
jest.mock("@working-theory/email", () => ({
|
|
23
|
+
sendLeadNotification: jest.fn(),
|
|
24
|
+
sendRequestConfirmEmail: jest.fn(),
|
|
25
|
+
sendRequestPaidLinkEmail: jest.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// @working-theory/license: consumed transitively by enrichment-shim; mocked to
|
|
29
|
+
// avoid needing a license key in tests (the leads index suite's own reason).
|
|
30
|
+
jest.mock("@working-theory/license", () => ({
|
|
31
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
32
|
+
tier: "free",
|
|
33
|
+
plan: null,
|
|
34
|
+
features: [],
|
|
35
|
+
expiresAt: null,
|
|
36
|
+
revoked: false,
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// The real request-confirm service, with its link sender observable: a
|
|
41
|
+
// spoke's callback must never reach it (spec D4, SEC-7532-2). The call itself
|
|
42
|
+
// is what AC-2 asserts, so the double wraps the real function rather than
|
|
43
|
+
// replacing it.
|
|
44
|
+
// eslint-disable-next-line no-restricted-syntax -- AC-2 asserts this internal function is never called; requireActual keeps its behaviour
|
|
45
|
+
jest.mock("../../../services/request-confirm.js", () => {
|
|
46
|
+
const actual = jest.requireActual("../../../services/request-confirm.js");
|
|
47
|
+
return {
|
|
48
|
+
...actual,
|
|
49
|
+
sendRequestConfirmLink: jest.fn(actual.sendRequestConfirmLink),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
import * as configModule from "../../../config/index.js";
|
|
54
|
+
import {
|
|
55
|
+
SENTINEL_WORKSPACE_ID,
|
|
56
|
+
mockSentinelWorkspaceExists,
|
|
57
|
+
} from "../../../lib/__mocks__/prisma.js";
|
|
58
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
59
|
+
import { __resetSiteWorkspaceCacheForTests } from "../../../lib/site.js";
|
|
60
|
+
import { sendRequestConfirmLink } from "../../../services/request-confirm.js";
|
|
61
|
+
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
62
|
+
import { leadCallbackRoute, leadSubmitRoute, leadSubscribeRoute } from "../index";
|
|
63
|
+
|
|
64
|
+
const db = jest.mocked(prisma);
|
|
65
|
+
const email = jest.requireMock("@working-theory/email") as {
|
|
66
|
+
sendLeadNotification: jest.Mock;
|
|
67
|
+
sendRequestConfirmEmail: jest.Mock;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const F = SENTINEL_WORKSPACE_ID;
|
|
71
|
+
const W1 = "wksp_01920000-0000-7000-8000-0000000000b1";
|
|
72
|
+
const W2 = "wksp_01920000-0000-7000-8000-0000000000b2";
|
|
73
|
+
const S1 = "spoke_01920000-0000-7000-8000-0000000000c1";
|
|
74
|
+
const S1B = "spoke_01920000-0000-7000-8000-0000000000c2";
|
|
75
|
+
const S2 = "spoke_01920000-0000-7000-8000-0000000000c3";
|
|
76
|
+
|
|
77
|
+
function spokeKey(tag: string): string {
|
|
78
|
+
return `ailk_spk_${tag}${"x".repeat(43 - tag.length)}`;
|
|
79
|
+
}
|
|
80
|
+
const KEY_S1 = spokeKey("S1");
|
|
81
|
+
const KEY_S1B = spokeKey("S1b");
|
|
82
|
+
const KEY_S2 = spokeKey("S2");
|
|
83
|
+
const KEY_UNKNOWN = spokeKey("nobody");
|
|
84
|
+
const ALL_KEYS = [KEY_S1, KEY_S1B, KEY_S2, KEY_UNKNOWN];
|
|
85
|
+
|
|
86
|
+
const digest = (key: string) => createHash("sha256").update(key).digest("hex");
|
|
87
|
+
|
|
88
|
+
function seedRegistry(revoked: readonly string[] = []): void {
|
|
89
|
+
const rows = new Map([
|
|
90
|
+
[digest(KEY_S1), { id: S1, workspaceId: W1, revokedAt: null as Date | null }],
|
|
91
|
+
[digest(KEY_S1B), { id: S1B, workspaceId: W1, revokedAt: null as Date | null }],
|
|
92
|
+
[digest(KEY_S2), { id: S2, workspaceId: W2, revokedAt: null as Date | null }],
|
|
93
|
+
]);
|
|
94
|
+
for (const row of rows.values()) {
|
|
95
|
+
if (revoked.includes(row.id)) row.revokedAt = new Date();
|
|
96
|
+
}
|
|
97
|
+
db.spoke.findUnique.mockImplementation((async ({
|
|
98
|
+
where,
|
|
99
|
+
}: {
|
|
100
|
+
where: { credentialHash: string };
|
|
101
|
+
}) => rows.get(where.credentialHash) ?? null) as never);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setStrictMode(on: boolean): void {
|
|
105
|
+
jest.replaceProperty(configModule, "config", {
|
|
106
|
+
...configModule.config,
|
|
107
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: on,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type Kind = "submit" | "subscribe" | "callback";
|
|
112
|
+
|
|
113
|
+
/** Each kind's body — every one names `siteId: W2`, which must be ignored. */
|
|
114
|
+
const BODIES: Record<Kind, Record<string, unknown>> = {
|
|
115
|
+
submit: {
|
|
116
|
+
siteId: W2,
|
|
117
|
+
source: "contact",
|
|
118
|
+
email: "prospect@example.com",
|
|
119
|
+
payload: { message: "hello" },
|
|
120
|
+
},
|
|
121
|
+
subscribe: { siteId: W2, source: "footer", email: "reader@example.com" },
|
|
122
|
+
callback: { siteId: W2, name: "Ada Lovelace", phone: "+44 7700 900000", payload: {} },
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** The create delegate each kind writes through. */
|
|
126
|
+
function createDelegate(kind: Kind) {
|
|
127
|
+
if (kind === "submit") return db.lead.create;
|
|
128
|
+
if (kind === "subscribe") return db.subscriber.create;
|
|
129
|
+
return db.callback.create;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function expectNoWrite(): void {
|
|
133
|
+
expect(db.lead.create).not.toHaveBeenCalled();
|
|
134
|
+
expect(db.subscriber.create).not.toHaveBeenCalled();
|
|
135
|
+
expect(db.callback.create).not.toHaveBeenCalled();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const REQUEST_CONFIRM_ENV = [
|
|
139
|
+
"AILK_REQUEST_CONFIRM_ENABLED",
|
|
140
|
+
"AILK_REQUEST_CONFIRM_TO",
|
|
141
|
+
"AILK_REQUEST_CONFIRM_FROM",
|
|
142
|
+
"AILK_PUBLIC_API_URL",
|
|
143
|
+
] as const;
|
|
144
|
+
|
|
145
|
+
describe("a spoke calling the lead routes on a multi-workspace hub", () => {
|
|
146
|
+
setupLeadFileEnv("spoke");
|
|
147
|
+
|
|
148
|
+
let app: FastifyInstance;
|
|
149
|
+
let logLines: string[];
|
|
150
|
+
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
// clearAllMocks (not reset): the license double's resolved value must
|
|
153
|
+
// survive between cases, as in the leads index suite.
|
|
154
|
+
jest.clearAllMocks();
|
|
155
|
+
__resetSiteWorkspaceCacheForTests();
|
|
156
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
157
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
158
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
159
|
+
setStrictMode(false);
|
|
160
|
+
seedRegistry();
|
|
161
|
+
mockSentinelWorkspaceExists(db, W1, W2);
|
|
162
|
+
db.workspace.count.mockResolvedValue(3);
|
|
163
|
+
db.lead.create.mockResolvedValue({ id: "lead_01920000-0000-7000-8000-0000000000d1" } as never);
|
|
164
|
+
db.subscriber.create.mockResolvedValue({ id: "subscr_01920000-0000-7000-8000-0000000000d2" } as never);
|
|
165
|
+
db.callback.create.mockResolvedValue({ id: "callbk_01920000-0000-7000-8000-0000000000d3" } as never);
|
|
166
|
+
db.callback.updateMany.mockResolvedValue({ count: 1 });
|
|
167
|
+
email.sendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-test" });
|
|
168
|
+
email.sendRequestConfirmEmail.mockResolvedValue({ ok: true, messageId: "msg-confirm" });
|
|
169
|
+
|
|
170
|
+
logLines = [];
|
|
171
|
+
const stream = new Writable({
|
|
172
|
+
write(chunk, _encoding, done) {
|
|
173
|
+
logLines.push(String(chunk));
|
|
174
|
+
done();
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
app = Fastify({ logger: { level: "info", stream } });
|
|
178
|
+
await app.register(leadSubmitRoute);
|
|
179
|
+
await app.register(leadSubscribeRoute);
|
|
180
|
+
await app.register(leadCallbackRoute);
|
|
181
|
+
await app.ready();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(async () => {
|
|
185
|
+
await app.close();
|
|
186
|
+
// No log line written by any case carries a key, its digest or its prefix (AC-3).
|
|
187
|
+
const written = logLines.join("\n");
|
|
188
|
+
for (const key of ALL_KEYS) {
|
|
189
|
+
expect(written).not.toContain(key);
|
|
190
|
+
expect(written).not.toContain(digest(key));
|
|
191
|
+
expect(written).not.toContain(key.slice(0, 17));
|
|
192
|
+
}
|
|
193
|
+
jest.restoreAllMocks();
|
|
194
|
+
delete process.env.DATABASE_URL;
|
|
195
|
+
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
196
|
+
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
197
|
+
for (const key of REQUEST_CONFIRM_ENV) delete process.env[key];
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
function post(kind: Kind, headers: Record<string, string> = {}, body = BODIES[kind]) {
|
|
201
|
+
return app.inject({ method: "POST", url: `/v1/leads/${kind}`, headers, payload: body });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── AC-2 ────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
describe.each<Kind>(["submit", "subscribe", "callback"])("POST /v1/leads/%s (AC-2)", (kind) => {
|
|
207
|
+
it("a spoke's lead lands in its own workspace, whatever siteId the body names", async () => {
|
|
208
|
+
const res = await post(kind, { "x-ailk-spoke-key": KEY_S1 });
|
|
209
|
+
|
|
210
|
+
expect(res.statusCode).toBe(201);
|
|
211
|
+
expect(res.json().persisted).toBe("db");
|
|
212
|
+
const create = createDelegate(kind);
|
|
213
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
214
|
+
const { data } = create.mock.calls[0]![0] as {
|
|
215
|
+
data: { siteId: string; workspaceId: string };
|
|
216
|
+
};
|
|
217
|
+
expect(data.siteId).toBe(W1);
|
|
218
|
+
expect(data.workspaceId).toBe(W1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("another spoke's lead lands in its own workspace", async () => {
|
|
222
|
+
const res = await post(kind, { "x-ailk-spoke-key": KEY_S2 }, {
|
|
223
|
+
...BODIES[kind],
|
|
224
|
+
siteId: W1,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(res.statusCode).toBe(201);
|
|
228
|
+
const { data } = createDelegate(kind).mock.calls[0]![0] as {
|
|
229
|
+
data: { siteId: string; workspaceId: string };
|
|
230
|
+
};
|
|
231
|
+
expect(data.siteId).toBe(W2);
|
|
232
|
+
expect(data.workspaceId).toBe(W2);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("a spoke's callback never takes request-and-confirm: the generic notification goes, no link is minted (AC-2, SEC-7532-2)", async () => {
|
|
237
|
+
process.env.AILK_REQUEST_CONFIRM_ENABLED = "true";
|
|
238
|
+
process.env.AILK_REQUEST_CONFIRM_TO = "owner@example.com";
|
|
239
|
+
process.env.AILK_REQUEST_CONFIRM_FROM = "hello@example.com";
|
|
240
|
+
process.env.AILK_PUBLIC_API_URL = "https://api.example.com";
|
|
241
|
+
|
|
242
|
+
const res = await post("callback", { "x-ailk-spoke-key": KEY_S1 });
|
|
243
|
+
expect(res.statusCode).toBe(201);
|
|
244
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
245
|
+
|
|
246
|
+
expect(sendRequestConfirmLink).not.toHaveBeenCalled();
|
|
247
|
+
expect(email.sendRequestConfirmEmail).not.toHaveBeenCalled();
|
|
248
|
+
expect(email.sendLeadNotification).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(db.callback.updateMany).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── AC-3 ────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
describe.each<[string, Record<string, string>, boolean]>([
|
|
255
|
+
["(i) an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN }, false],
|
|
256
|
+
["(ii) a duplicated header", { "x-ailk-spoke-key": `${KEY_S1}, ${KEY_S2}` }, false],
|
|
257
|
+
["(iii) an empty key", { "x-ailk-spoke-key": "" }, false],
|
|
258
|
+
[
|
|
259
|
+
"(v) strict mode, a header-less request naming W1 every other way",
|
|
260
|
+
{
|
|
261
|
+
host: "w1.example",
|
|
262
|
+
"x-forwarded-host": "w1.example",
|
|
263
|
+
origin: "https://w1.example",
|
|
264
|
+
"x-ailk-workspace-id": W1,
|
|
265
|
+
},
|
|
266
|
+
true,
|
|
267
|
+
],
|
|
268
|
+
])("%s (AC-3)", (_label, headers, strict) => {
|
|
269
|
+
it.each<Kind>(["submit", "subscribe", "callback"])(
|
|
270
|
+
"POST /v1/leads/%s answers 503 tenant_unresolved before any write",
|
|
271
|
+
async (kind) => {
|
|
272
|
+
setStrictMode(strict);
|
|
273
|
+
const res = await post(kind, headers, { ...BODIES[kind], siteId: W1 });
|
|
274
|
+
|
|
275
|
+
expect(res.statusCode).toBe(503);
|
|
276
|
+
expect(res.json().error).toBe("tenant_unresolved");
|
|
277
|
+
expectNoWrite();
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("(iv) a valid key with DATABASE_URL unset answers 503 — never the zero-config alias", async () => {
|
|
283
|
+
delete process.env.DATABASE_URL;
|
|
284
|
+
const res = await post("submit", { "x-ailk-spoke-key": KEY_S1 });
|
|
285
|
+
|
|
286
|
+
expect(res.statusCode).toBe(503);
|
|
287
|
+
expect(res.json().error).toBe("tenant_unresolved");
|
|
288
|
+
expectNoWrite();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── AC-5 ────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe("with S1 revoked (AC-5)", () => {
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
seedRegistry([S1]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("S1's key gets 503 with no write; S1b (also W1) and S2 keep their own workspaces", async () => {
|
|
299
|
+
const revoked = await post("submit", { "x-ailk-spoke-key": KEY_S1 });
|
|
300
|
+
expect(revoked.statusCode).toBe(503);
|
|
301
|
+
expectNoWrite();
|
|
302
|
+
|
|
303
|
+
const sibling = await post("submit", { "x-ailk-spoke-key": KEY_S1B });
|
|
304
|
+
expect(sibling.statusCode).toBe(201);
|
|
305
|
+
const siblingData = (db.lead.create.mock.calls[0]![0] as { data: { workspaceId: string } }).data;
|
|
306
|
+
expect(siblingData.workspaceId).toBe(W1);
|
|
307
|
+
|
|
308
|
+
const other = await post("submit", { "x-ailk-spoke-key": KEY_S2 });
|
|
309
|
+
expect(other.statusCode).toBe(201);
|
|
310
|
+
const otherData = (db.lead.create.mock.calls[1]![0] as { data: { workspaceId: string } }).data;
|
|
311
|
+
expect(otherData.workspaceId).toBe(W2);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("in strict mode, S1's header-less retry is refused too", async () => {
|
|
315
|
+
setStrictMode(true);
|
|
316
|
+
const res = await post("submit");
|
|
317
|
+
expect(res.statusCode).toBe(503);
|
|
318
|
+
expectNoWrite();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("with strict mode off, S1's header-less retry is anonymous and lands under the alias F, never W1 (SEC-7532-3)", async () => {
|
|
322
|
+
const res = await post("submit");
|
|
323
|
+
expect(res.statusCode).toBe(201);
|
|
324
|
+
const { data } = db.lead.create.mock.calls[0]![0] as {
|
|
325
|
+
data: { siteId: string; workspaceId: string };
|
|
326
|
+
};
|
|
327
|
+
expect(data.siteId).toBe(F);
|
|
328
|
+
expect(data.workspaceId).toBe(F);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
* result (no DATABASE_URL, or the alias names no workspace this database
|
|
30
30
|
* holds) is a 503 before any write is attempted — never a guess. See
|
|
31
31
|
* `lead-routing.ts`'s own header for the persistence-side half.
|
|
32
|
+
*
|
|
33
|
+
* A REGISTERED SPOKE (#7532 D4) resolves FIRST: a hub serving several spokes
|
|
34
|
+
* reads the spoke's key from `x-ailk-spoke-key` through
|
|
35
|
+
* `resolveCallerSite()` (`services/public-site-resolver.ts`) and writes the
|
|
36
|
+
* lead into that spoke's own workspace. A refused key — malformed, unknown or
|
|
37
|
+
* revoked, or no key at all on a hub in strict mode — is the same 503
|
|
38
|
+
* `tenant_unresolved` before any write. A header-less request on a hub
|
|
39
|
+
* without strict mode takes the alias resolution above, unchanged. The body's
|
|
40
|
+
* `siteId` stays ignored on every branch.
|
|
32
41
|
*/
|
|
33
42
|
|
|
34
43
|
import { track } from "@working-theory/analytics/server";
|
|
@@ -40,7 +49,9 @@ import {
|
|
|
40
49
|
type LeadKind,
|
|
41
50
|
type RouteLeadInput,
|
|
42
51
|
routeLead,
|
|
52
|
+
type SourceMeta,
|
|
43
53
|
} from "../../services/lead-routing.js";
|
|
54
|
+
import { resolveCallerSite } from "../../services/public-site-resolver.js";
|
|
44
55
|
import { sendRequestConfirmLink } from "../../services/request-confirm.js";
|
|
45
56
|
|
|
46
57
|
/**
|
|
@@ -125,12 +136,22 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
125
136
|
// private @working-theory/lead-routing package (not present in this
|
|
126
137
|
// repo, not inspectable) is the one consumer this trace could not
|
|
127
138
|
// complete; see docs/specs/5112-leads-ip-trace-spec.md §8.
|
|
128
|
-
const sourceMeta = {
|
|
139
|
+
const sourceMeta: SourceMeta = {
|
|
129
140
|
ip: request.ip,
|
|
130
141
|
userAgent: request.headers["user-agent"],
|
|
131
142
|
receivedAt: new Date().toISOString(),
|
|
132
143
|
};
|
|
133
144
|
|
|
145
|
+
// #7532 D4 — a registered spoke resolves first, to its own workspace; a
|
|
146
|
+
// refused key never falls through to the alias below.
|
|
147
|
+
const caller = await resolveCallerSite(request);
|
|
148
|
+
if (caller.kind === "refused") {
|
|
149
|
+
return reply.status(503).send({
|
|
150
|
+
error: "tenant_unresolved",
|
|
151
|
+
message: "This site is not configured to accept submissions.",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
134
155
|
// #6203 — resolve the tenant SERVER-SIDE before any write is
|
|
135
156
|
// attempted. Two branches, deliberately different:
|
|
136
157
|
//
|
|
@@ -150,7 +171,10 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
150
171
|
// deleted after boot, a transient read), and it fails closed the
|
|
151
172
|
// same way: reject, never guess (spec AC-4).
|
|
152
173
|
let workspaceId: string;
|
|
153
|
-
if (
|
|
174
|
+
if (caller.kind === "spoke") {
|
|
175
|
+
workspaceId = caller.workspaceId;
|
|
176
|
+
sourceMeta.viaSpoke = true;
|
|
177
|
+
} else if (!process.env.DATABASE_URL) {
|
|
154
178
|
workspaceId = currentSiteId();
|
|
155
179
|
} else {
|
|
156
180
|
const resolved = await resolveSiteWorkspaceId();
|
|
@@ -239,7 +263,13 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
239
263
|
// would match nothing and the operator would be left with no email at
|
|
240
264
|
// all. On that path the generic notification is sent instead, which is
|
|
241
265
|
// what keeps "exactly one" true in both directions.
|
|
242
|
-
|
|
266
|
+
// A spoke's callback never takes this path (#7532 D4, SEC-7532-2): the
|
|
267
|
+
// link is minted on the deployment's one Stripe account.
|
|
268
|
+
if (
|
|
269
|
+
kind === "callback" &&
|
|
270
|
+
result.persisted === "db" &&
|
|
271
|
+
caller.kind !== "spoke"
|
|
272
|
+
) {
|
|
243
273
|
const callbackInput = buildInput(kind, body, workspaceId) as Extract<
|
|
244
274
|
RouteLeadInput,
|
|
245
275
|
{ kind: "callback" }
|