create-ailk 0.18.0 → 0.19.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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +60 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +24 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file review-surface-registration.test.ts
|
|
3
|
-
* @description The `multi-tenant || signal-collectors` registration matrix for
|
|
4
|
-
* the review surface (#5677).
|
|
5
|
-
*
|
|
6
|
-
* This is the TWO-SIDED check the unit turns on. `signal-collectors` (Pro) is
|
|
7
|
-
* the module that sells the instruments — flows, deliverables, project
|
|
8
|
-
* listings, waitlist experiments — so a Pro customer could collect submissions
|
|
9
|
-
* and not read them, because the review routes registered only inside the
|
|
10
|
-
* multi-tenant scope. Widening the gate has to make the Pro side work WITHOUT
|
|
11
|
-
* moving the Pro Plus side, and only exercising both builds shows that.
|
|
12
|
-
*
|
|
13
|
-
* It cannot fold into `module-exclusion.test.ts`: that suite's shape is
|
|
14
|
-
* "excluding module X 404s every route X owns", and these routes register
|
|
15
|
-
* under EITHER module, so excluding one while the other is on leaves them
|
|
16
|
-
* registered by design — the same reason `workspace-module-registration.test.ts`
|
|
17
|
-
* is its own file, whose structure this mirrors.
|
|
18
|
-
*
|
|
19
|
-
* "Resolves" is measured as "not Fastify's own 404". An unauthenticated
|
|
20
|
-
* request to a registered tenant route answers 401, never 404, so the two
|
|
21
|
-
* states are unambiguous.
|
|
22
|
-
*
|
|
23
|
-
* Mirrors `src/__tests__/server.test.ts`'s external-boundary mocks (CORS,
|
|
24
|
-
* rate-limit, email) — see that file's header for why each is mocked.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
jest.mock('@fastify/cors', () => {
|
|
28
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
jest.mock('@fastify/rate-limit', () => {
|
|
32
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
jest.mock('@working-theory/email', () => ({
|
|
36
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
37
|
-
}));
|
|
38
|
-
|
|
39
|
-
import { build } from '../server';
|
|
40
|
-
|
|
41
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
|
|
42
|
-
type App = any;
|
|
43
|
-
|
|
44
|
-
const EXPERIMENT = 'wlx_00000000-0000-7000-8000-0000000000aa';
|
|
45
|
-
|
|
46
|
-
/** The review surface — registers under EITHER module since #5677. */
|
|
47
|
-
const REVIEW_ROUTES = [
|
|
48
|
-
{ method: 'GET' as const, url: '/v1/waitlist-experiments' },
|
|
49
|
-
{ method: 'GET' as const, url: `/v1/waitlist-experiments/${EXPERIMENT}` },
|
|
50
|
-
{
|
|
51
|
-
method: 'GET' as const,
|
|
52
|
-
url: `/v1/waitlist-experiments/${EXPERIMENT}/signups`,
|
|
53
|
-
},
|
|
54
|
-
{ method: 'GET' as const, url: '/v1/project-listings' },
|
|
55
|
-
];
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Organization/Workspace ADMINISTRATION — deliberately left alone by #5677,
|
|
59
|
-
* because it is not a site's own submissions. These stay multi-tenant-only.
|
|
60
|
-
*/
|
|
61
|
-
const MULTI_TENANT_ONLY_ROUTES = [
|
|
62
|
-
{ method: 'GET' as const, url: '/v1/workspaces' },
|
|
63
|
-
{ method: 'GET' as const, url: '/v1/billing' },
|
|
64
|
-
];
|
|
65
|
-
|
|
66
|
-
async function withEnabledModules<T>(
|
|
67
|
-
enabled: string,
|
|
68
|
-
fn: () => Promise<T>,
|
|
69
|
-
): Promise<T> {
|
|
70
|
-
const prior = process.env.AILK_ENABLED_MODULES;
|
|
71
|
-
process.env.AILK_ENABLED_MODULES = enabled;
|
|
72
|
-
try {
|
|
73
|
-
return await fn();
|
|
74
|
-
} finally {
|
|
75
|
-
if (prior === undefined) {
|
|
76
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
77
|
-
} else {
|
|
78
|
-
process.env.AILK_ENABLED_MODULES = prior;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Every (method, path) pair Fastify's router holds, one per line. */
|
|
84
|
-
function routeLines(app: App): string[] {
|
|
85
|
-
return (app.printRoutes({ commonPrefix: false }) as string)
|
|
86
|
-
.split('\n')
|
|
87
|
-
.map((line: string) => line.trim())
|
|
88
|
-
.filter(Boolean);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
describe('review-surface registration matrix (#5677)', () => {
|
|
92
|
-
let app: App;
|
|
93
|
-
|
|
94
|
-
afterEach(async () => {
|
|
95
|
-
if (app) {
|
|
96
|
-
await app.close();
|
|
97
|
-
app = undefined;
|
|
98
|
-
}
|
|
99
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("with 'signal-collectors' alone (a Pro build), every review route resolves behind the base tenant guard", async () => {
|
|
103
|
-
app = await withEnabledModules('signal-collectors', () => build());
|
|
104
|
-
|
|
105
|
-
for (const { method, url } of REVIEW_ROUTES) {
|
|
106
|
-
const res = await app.inject({ method, url });
|
|
107
|
-
expect({ url, status: res.statusCode }).toEqual({ url, status: 401 });
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("with 'signal-collectors' alone, Organization/Workspace administration stays unregistered", async () => {
|
|
112
|
-
app = await withEnabledModules('signal-collectors', () => build());
|
|
113
|
-
|
|
114
|
-
for (const { method, url } of MULTI_TENANT_ONLY_ROUTES) {
|
|
115
|
-
const res = await app.inject({ method, url });
|
|
116
|
-
expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("with 'multi-tenant' alone (a Pro Plus build), every review route resolves exactly as before — the regression floor", async () => {
|
|
121
|
-
app = await withEnabledModules('multi-tenant', () => build());
|
|
122
|
-
|
|
123
|
-
for (const { method, url } of [
|
|
124
|
-
...REVIEW_ROUTES,
|
|
125
|
-
...MULTI_TENANT_ONLY_ROUTES,
|
|
126
|
-
]) {
|
|
127
|
-
const res = await app.inject({ method, url });
|
|
128
|
-
expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('with BOTH enabled, each review route registers exactly once', async () => {
|
|
133
|
-
// build() itself is the first assertion: Fastify throws
|
|
134
|
-
// FST_ERR_DUPLICATED_ROUTE at registration time, so a double-registration
|
|
135
|
-
// never reaches the router tree to be counted.
|
|
136
|
-
app = await withEnabledModules('multi-tenant,signal-collectors', () =>
|
|
137
|
-
build(),
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
const lines = routeLines(app);
|
|
141
|
-
const experimentRoutes = lines.filter((line) =>
|
|
142
|
-
line.includes('waitlist-experiments'),
|
|
143
|
-
);
|
|
144
|
-
expect(experimentRoutes.length).toBeGreaterThan(0);
|
|
145
|
-
expect(new Set(experimentRoutes).size).toBe(experimentRoutes.length);
|
|
146
|
-
|
|
147
|
-
for (const { method, url } of [
|
|
148
|
-
...REVIEW_ROUTES,
|
|
149
|
-
...MULTI_TENANT_ONLY_ROUTES,
|
|
150
|
-
]) {
|
|
151
|
-
const res = await app.inject({ method, url });
|
|
152
|
-
expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('with NEITHER enabled (a Free build), no review route registers — Free is unaffected', async () => {
|
|
157
|
-
app = await withEnabledModules('', () => build());
|
|
158
|
-
|
|
159
|
-
for (const { method, url } of [
|
|
160
|
-
...REVIEW_ROUTES,
|
|
161
|
-
...MULTI_TENANT_ONLY_ROUTES,
|
|
162
|
-
]) {
|
|
163
|
-
const res = await app.inject({ method, url });
|
|
164
|
-
expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
});
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file root-modules.test.ts
|
|
3
|
-
* @description `GET /` reports the optional modules this process registered
|
|
4
|
-
* routes for (#7501), so the Admin can hide what a build does not serve.
|
|
5
|
-
*
|
|
6
|
-
* Builds the REAL server under several `AILK_ENABLED_MODULES` values and reads
|
|
7
|
-
* the root index over `inject`. `modules` must agree with the route table
|
|
8
|
-
* Fastify actually registered — the content and meetings reads the Admin makes
|
|
9
|
-
* are present exactly when their module is listed.
|
|
10
|
-
*
|
|
11
|
-
* Mirrors `src/openapi/__tests__/surface-drift.test.ts`'s external-boundary
|
|
12
|
-
* mocks (CORS, rate-limit, email) — see that file's header for why each is
|
|
13
|
-
* mocked.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
jest.mock("@fastify/cors", () => {
|
|
17
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
jest.mock("@fastify/rate-limit", () => {
|
|
21
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
jest.mock("@working-theory/email", () => ({
|
|
25
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
26
|
-
}));
|
|
27
|
-
|
|
28
|
-
import { execFileSync } from "node:child_process";
|
|
29
|
-
import path from "node:path";
|
|
30
|
-
import { build } from "../server";
|
|
31
|
-
import { readRegisteredRoutes } from "../openapi/spec";
|
|
32
|
-
import { routeKey } from "../openapi/surface-drift";
|
|
33
|
-
|
|
34
|
-
const CONTENT_READ = routeKey("GET", "/v1/workspaces/:workspaceId/content");
|
|
35
|
-
const MEETINGS_READ = routeKey("GET", "/v1/workspaces/:workspaceId/meetings");
|
|
36
|
-
|
|
37
|
-
/** Every route the Admin reads under `multi-tenant` alone (spec §1 fact 4). */
|
|
38
|
-
const ADMIN_MULTI_TENANT_READS = [
|
|
39
|
-
routeKey("GET", "/v1/workspaces"),
|
|
40
|
-
routeKey("GET", "/v1/billing"),
|
|
41
|
-
routeKey("GET", "/v1/project-listings"),
|
|
42
|
-
routeKey("GET", "/v1/workspaces/:workspaceId/messages"),
|
|
43
|
-
routeKey("GET", "/v1/workspaces/:workspaceId/magnets"),
|
|
44
|
-
routeKey("GET", "/v1/workspaces/:workspaceId/assets"),
|
|
45
|
-
routeKey("GET", "/v1/workspaces/:workspaceId/sessions"),
|
|
46
|
-
routeKey("GET", "/v1/sessions/:sessionId/artifacts"),
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
const CONFIGS: Array<[string, string | undefined, string[]]> = [
|
|
50
|
-
["unset", undefined, []],
|
|
51
|
-
["multi-tenant", "multi-tenant", ["multi-tenant"]],
|
|
52
|
-
[
|
|
53
|
-
"scheduling, content-write,multi-tenant",
|
|
54
|
-
"scheduling, content-write,multi-tenant",
|
|
55
|
-
["content-write", "multi-tenant", "scheduling"],
|
|
56
|
-
],
|
|
57
|
-
["content-write alone", "content-write", []],
|
|
58
|
-
[
|
|
59
|
-
"multi-tenant,bogus,signal-collectors",
|
|
60
|
-
"multi-tenant,bogus,signal-collectors",
|
|
61
|
-
["multi-tenant", "signal-collectors"],
|
|
62
|
-
],
|
|
63
|
-
// `docs` registers no apps/api route, so it is never reported as served.
|
|
64
|
-
["docs,multi-tenant", "docs,multi-tenant", ["multi-tenant"]],
|
|
65
|
-
];
|
|
66
|
-
|
|
67
|
-
function setEnv(value: string | undefined): string | undefined {
|
|
68
|
-
const prior = process.env.AILK_ENABLED_MODULES;
|
|
69
|
-
if (value === undefined) {
|
|
70
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
71
|
-
} else {
|
|
72
|
-
process.env.AILK_ENABLED_MODULES = value;
|
|
73
|
-
}
|
|
74
|
-
return prior;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
describe("GET / modules (#7501)", () => {
|
|
78
|
-
let prior: string | undefined;
|
|
79
|
-
|
|
80
|
-
beforeEach(() => {
|
|
81
|
-
prior = process.env.AILK_ENABLED_MODULES;
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
afterEach(() => {
|
|
85
|
-
setEnv(prior);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe.each(CONFIGS)("AILK_ENABLED_MODULES = %s", (_label, env, expected) => {
|
|
89
|
-
let modules: unknown;
|
|
90
|
-
let routes: Set<string>;
|
|
91
|
-
|
|
92
|
-
beforeAll(async () => {
|
|
93
|
-
const before = setEnv(env);
|
|
94
|
-
try {
|
|
95
|
-
const app = await build();
|
|
96
|
-
await app.ready();
|
|
97
|
-
const res = await app.inject({ method: "GET", url: "/" });
|
|
98
|
-
routes = new Set(
|
|
99
|
-
readRegisteredRoutes(app).map((r) => routeKey(r.method, r.url)),
|
|
100
|
-
);
|
|
101
|
-
await app.close();
|
|
102
|
-
expect(res.statusCode).toBe(200);
|
|
103
|
-
modules = JSON.parse(res.body).modules;
|
|
104
|
-
} finally {
|
|
105
|
-
setEnv(before);
|
|
106
|
-
}
|
|
107
|
-
}, 60000);
|
|
108
|
-
|
|
109
|
-
it("AC-1: lists exactly the served optional modules, sorted", () => {
|
|
110
|
-
expect(modules).toEqual(expected);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("AC-2: content-write is listed exactly when the content read registered", () => {
|
|
114
|
-
expect((modules as string[]).includes("content-write")).toBe(
|
|
115
|
-
routes.has(CONTENT_READ),
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("AC-2: scheduling is listed exactly when the meetings read registered", () => {
|
|
120
|
-
expect((modules as string[]).includes("scheduling")).toBe(
|
|
121
|
-
routes.has(MEETINGS_READ),
|
|
122
|
-
);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("AC-1: a built server keeps its module list after the env changes", async () => {
|
|
127
|
-
const before = setEnv("multi-tenant,content-write");
|
|
128
|
-
let app: Awaited<ReturnType<typeof build>>;
|
|
129
|
-
try {
|
|
130
|
-
app = await build();
|
|
131
|
-
await app.ready();
|
|
132
|
-
} finally {
|
|
133
|
-
setEnv(before);
|
|
134
|
-
}
|
|
135
|
-
process.env.AILK_ENABLED_MODULES = "";
|
|
136
|
-
try {
|
|
137
|
-
const res = await app.inject({ method: "GET", url: "/" });
|
|
138
|
-
expect(JSON.parse(res.body).modules).toEqual([
|
|
139
|
-
"content-write",
|
|
140
|
-
"multi-tenant",
|
|
141
|
-
]);
|
|
142
|
-
} finally {
|
|
143
|
-
await app.close();
|
|
144
|
-
}
|
|
145
|
-
}, 60000);
|
|
146
|
-
|
|
147
|
-
it("AC-2: multi-tenant alone registers every route the Admin reads, bar content and meetings", async () => {
|
|
148
|
-
const before = setEnv("multi-tenant");
|
|
149
|
-
try {
|
|
150
|
-
const app = await build();
|
|
151
|
-
await app.ready();
|
|
152
|
-
const routes = new Set(
|
|
153
|
-
readRegisteredRoutes(app).map((r) => routeKey(r.method, r.url)),
|
|
154
|
-
);
|
|
155
|
-
await app.close();
|
|
156
|
-
for (const read of ADMIN_MULTI_TENANT_READS) {
|
|
157
|
-
expect(routes).toContain(read);
|
|
158
|
-
}
|
|
159
|
-
expect(routes).not.toContain(CONTENT_READ);
|
|
160
|
-
expect(routes).not.toContain(MEETINGS_READ);
|
|
161
|
-
} finally {
|
|
162
|
-
setEnv(before);
|
|
163
|
-
}
|
|
164
|
-
}, 60000);
|
|
165
|
-
|
|
166
|
-
it("AC-2: pnpm lint:slice-registration exits 0", () => {
|
|
167
|
-
const repoRoot = path.resolve(__dirname, "../../../..");
|
|
168
|
-
expect(() =>
|
|
169
|
-
execFileSync("pnpm", ["lint:slice-registration"], {
|
|
170
|
-
cwd: repoRoot,
|
|
171
|
-
stdio: "pipe",
|
|
172
|
-
}),
|
|
173
|
-
).not.toThrow();
|
|
174
|
-
}, 120000);
|
|
175
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file schedule-forwarded-rate-limit.test.ts
|
|
3
|
-
* @description #7633 (conductor amendment, the relay's security review) — a
|
|
4
|
-
* site's relayed schedule calls are limited per visitor end to end, through
|
|
5
|
-
* the real `build()`, the schedule scope's own limiter and the REAL
|
|
6
|
-
* `@fastify/rate-limit` global ceiling: the schedule scope's twin of
|
|
7
|
-
* `leads-forwarded-rate-limit.test.ts` (#7394 AC-7).
|
|
8
|
-
*
|
|
9
|
-
* Once browsers post through a site's same-origin relay (#7644), every
|
|
10
|
-
* visitor's `GET /v1/schedule/availability` and `POST /v1/schedule/book`
|
|
11
|
-
* reaches the hub from the site's server. With the hub-issued forwarding key,
|
|
12
|
-
* each visitor gets the scope's own per-visitor budget, and the key as a
|
|
13
|
-
* whole a ceiling of 60 a minute — well below the lead routes' 300, because a
|
|
14
|
-
* booking is a real booking.
|
|
15
|
-
*
|
|
16
|
-
* `DATABASE_URL` is unset and the `scheduling` module is enabled for the
|
|
17
|
-
* file's duration. Whatever each route answers past the limiter (a 400 or a
|
|
18
|
-
* 503) is not this file's subject: the limiter runs on `onRequest`, before
|
|
19
|
-
* any handler.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { __resetBuckets } from "../middleware/rate-limit";
|
|
23
|
-
import { build } from "../server";
|
|
24
|
-
|
|
25
|
-
jest.mock("@fastify/cors", () => {
|
|
26
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
jest.mock("@working-theory/email", () => ({
|
|
30
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
|
-
const KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
|
|
34
|
-
const SPOKE_HOST = "198.51.100.7";
|
|
35
|
-
|
|
36
|
-
/** A distinct, valid visitor IP per index. */
|
|
37
|
-
function visitor(i: number): string {
|
|
38
|
-
return `203.0.${Math.floor(i / 250)}.${(i % 250) + 1}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function headersFor(visitorIp: string, withKey: boolean): Record<string, string> {
|
|
42
|
-
return {
|
|
43
|
-
"x-forwarded-for": SPOKE_HOST,
|
|
44
|
-
"x-ailk-visitor-ip": visitorIp,
|
|
45
|
-
...(withKey && { "x-ailk-visitor-forward-key": KEY }),
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const ROUTES = [
|
|
50
|
-
{ method: "GET", url: "/v1/schedule/availability?calLink=acme%2Fintro" },
|
|
51
|
-
{ method: "POST", url: "/v1/schedule/book", payload: {} },
|
|
52
|
-
] as const;
|
|
53
|
-
|
|
54
|
-
describe.each(ROUTES)("$method $url for a keyed site, against the real limiters (#7633)", (route) => {
|
|
55
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any matches server.test.ts
|
|
56
|
-
let app: any;
|
|
57
|
-
const prior = {
|
|
58
|
-
keys: process.env.AILK_VISITOR_FORWARD_KEYS,
|
|
59
|
-
modules: process.env.AILK_ENABLED_MODULES,
|
|
60
|
-
databaseUrl: process.env.DATABASE_URL,
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
async function statuses(count: number, visitorFor: (i: number) => string, withKey: boolean): Promise<number[]> {
|
|
64
|
-
const codes: number[] = [];
|
|
65
|
-
for (let i = 0; i < count; i++) {
|
|
66
|
-
const res = await app.inject({ ...route, headers: headersFor(visitorFor(i), withKey) });
|
|
67
|
-
codes.push(res.statusCode);
|
|
68
|
-
}
|
|
69
|
-
return codes;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
beforeEach(async () => {
|
|
73
|
-
__resetBuckets();
|
|
74
|
-
process.env.AILK_VISITOR_FORWARD_KEYS = KEY;
|
|
75
|
-
process.env.AILK_ENABLED_MODULES = "scheduling";
|
|
76
|
-
delete process.env.DATABASE_URL;
|
|
77
|
-
app = await build();
|
|
78
|
-
await app.ready();
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
afterEach(async () => {
|
|
82
|
-
if (app) await app.close();
|
|
83
|
-
app = undefined;
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
afterAll(() => {
|
|
87
|
-
for (const [name, value] of [
|
|
88
|
-
["AILK_VISITOR_FORWARD_KEYS", prior.keys],
|
|
89
|
-
["AILK_ENABLED_MODULES", prior.modules],
|
|
90
|
-
["DATABASE_URL", prior.databaseUrl],
|
|
91
|
-
] as const) {
|
|
92
|
-
if (value === undefined) delete process.env[name];
|
|
93
|
-
else process.env[name] = value;
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("two visitors behind one forwarding key are counted separately: the first's eleventh call is 429, the second's first is not", async () => {
|
|
98
|
-
const first = await statuses(11, () => "203.0.113.1", true);
|
|
99
|
-
expect(first.slice(0, 10).every((code) => code !== 429)).toBe(true);
|
|
100
|
-
expect(first[10]).toBe(429);
|
|
101
|
-
|
|
102
|
-
const second = await statuses(1, () => "203.0.113.2", true);
|
|
103
|
-
expect(second[0]).not.toBe(429);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("without the key, distinct visitors behind one host share the host's budget: the eleventh call is 429", async () => {
|
|
107
|
-
const codes = await statuses(11, visitor, false);
|
|
108
|
-
expect(codes.slice(0, 10).every((code) => code !== 429)).toBe(true);
|
|
109
|
-
expect(codes[10]).toBe(429);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("the key's own ceiling is 60 a minute across all of its visitors: the sixty-first is 429", async () => {
|
|
113
|
-
const codes = await statuses(61, visitor, true);
|
|
114
|
-
expect(codes.slice(0, 60).every((code) => code !== 429)).toBe(true);
|
|
115
|
-
expect(codes[60]).toBe(429);
|
|
116
|
-
});
|
|
117
|
-
});
|