create-ailk 0.12.1 → 0.14.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/README.md +12 -4
- package/component-catalog.json +247 -33
- package/component-catalog.md +10 -6
- package/dist/apply-design.d.ts +21 -2
- package/dist/apply-design.js +120 -19
- package/dist/cli.js +1 -0
- package/dist/design-compile.d.ts +70 -2
- package/dist/design-compile.js +97 -1
- package/dist/design-home.d.ts +74 -0
- package/dist/design-home.js +256 -0
- package/dist/design-package.d.ts +39 -2
- package/dist/design-package.js +223 -9
- package/dist/design-presets.d.ts +17 -1
- package/dist/design-presets.js +17 -5
- package/dist/design-scaffold.d.ts +6 -25
- package/dist/design-scaffold.js +26 -74
- package/dist/index.d.ts +1 -0
- package/dist/lib/design-checks.d.ts +12 -1
- package/dist/lib/design-checks.js +38 -2
- package/dist/lib/module-license-gate.d.ts +9 -0
- package/dist/lib/module-license-gate.js +18 -0
- package/dist/module-architecture.js +27 -0
- package/dist/programmatic.js +70 -26
- package/dist/site-chrome.d.ts +38 -0
- package/dist/site-chrome.js +109 -2
- package/dist/site-fonts.d.ts +21 -0
- package/dist/site-fonts.js +54 -0
- package/dist/site-roster.d.ts +16 -0
- package/dist/site-roster.js +81 -0
- package/dist/starter-grid.js +15 -0
- package/dist/surfaces.js +12 -0
- package/dist/team-profiles.d.ts +29 -0
- package/dist/team-profiles.js +43 -0
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
- package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
- package/site-templates/designs/minimalism/DESIGN.md +164 -34
- package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
- package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
- package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
- package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/templates/.env.example +23 -9
- package/templates/README.md +2 -0
- package/templates/apps/api/CLAUDE.md +24 -17
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
- package/templates/apps/api/src/config/index.ts +35 -20
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
- package/templates/apps/api/src/lib/agent-store.ts +521 -0
- package/templates/apps/api/src/lib/spoke-store.ts +13 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
- package/templates/apps/api/src/middleware/auth.ts +219 -5
- package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
- package/templates/apps/api/src/routes/leads/index.ts +14 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
- package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
- package/templates/apps/api/src/server.ts +27 -0
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
- package/templates/apps/api/src/services/audit-log.ts +278 -47
- package/templates/apps/api/src/services/default-tenant.ts +55 -5
- package/templates/apps/api/src/services/leads-inbox.ts +12 -21
- package/templates/apps/api/src/services/project-listings.ts +1 -21
- package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
- package/templates/apps/mcp/CLAUDE.md +44 -5
- package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
- package/templates/apps/mcp/package.json +1 -1
- package/templates/apps/mcp/src/http-transport.ts +107 -1
- package/templates/apps/mcp/src/workspace-server.ts +193 -0
- package/templates/apps/web/.env.example +64 -37
- package/templates/apps/web/CLAUDE.md +11 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +69 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +64 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/layout.tsx +156 -53
- package/templates/apps/web/app/[locale]/page.tsx +37 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
- package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/not-found.tsx +47 -0
- package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
- package/templates/apps/web/lib/profile-back-link.ts +51 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/lib/site-chrome.ts +12 -0
- package/templates/apps/web/lib/site-fonts.ts +15 -0
- package/templates/apps/web/lib/site-logo.tsx +39 -1
- package/templates/apps/web/lib/site-theme-head.ts +33 -0
- package/templates/apps/web/lib/site-theme.ts +13 -1
- package/templates/apps/web/messages/en.json +7 -1
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +22 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -35
- package/site-templates/designs/minimalism/sample/README.md +0 -48
- package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file agent-caller.ts
|
|
3
|
+
*
|
|
4
|
+
* An agent caller for route suites that mock `lib/prisma.js` inline (#3591
|
|
5
|
+
* D8): a key minted at run time — never a key-shaped literal in a fixture —
|
|
6
|
+
* plus the two reads the auth preHandler and the multi-tenant guard make for
|
|
7
|
+
* it, so the suite's existing `agent` and `membership` `findUnique` mocks can
|
|
8
|
+
* answer an agent without changing what they answer anyone else.
|
|
9
|
+
*
|
|
10
|
+
* Usage, inside a suite whose inline prisma mock declares
|
|
11
|
+
* `agent: { findUnique: jest.fn(), update: jest.fn() }`: build a caller with
|
|
12
|
+
* `buildAgentCaller({ organizationId, workspaceId })`, give the `agent`
|
|
13
|
+
* delegate's `findUnique` the `agentKeyLookup(caller)` implementation and the
|
|
14
|
+
* `membership` delegate's `findUnique` the `agentMembershipLookup(caller,
|
|
15
|
+
* existingImplementation)` one, then send the request with `caller.headers`.
|
|
16
|
+
* (This docblock names the delegates in prose: the tenant-scope lint in
|
|
17
|
+
* `architecture.yaml` scans comments too.)
|
|
18
|
+
*/
|
|
19
|
+
import { hashAgentKey, mintAgentKey } from "../../lib/agent-store.js";
|
|
20
|
+
|
|
21
|
+
export interface AgentCaller {
|
|
22
|
+
agentId: string;
|
|
23
|
+
/** The responsible person's user id — the agent acts on their behalf. */
|
|
24
|
+
userId: string;
|
|
25
|
+
membershipId: string;
|
|
26
|
+
organizationId: string;
|
|
27
|
+
workspaceId: string;
|
|
28
|
+
/** The person's own role; the guard clamps the agent to `member`. */
|
|
29
|
+
role: "owner" | "admin" | "member";
|
|
30
|
+
headers: { authorization: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildAgentCaller(
|
|
34
|
+
scope: { organizationId: string; workspaceId: string },
|
|
35
|
+
overrides: Partial<
|
|
36
|
+
Pick<AgentCaller, "agentId" | "userId" | "membershipId" | "role">
|
|
37
|
+
> = {},
|
|
38
|
+
): AgentCaller & { key: string } {
|
|
39
|
+
const key = mintAgentKey();
|
|
40
|
+
return {
|
|
41
|
+
agentId: "agent_00000000-0000-7000-8000-00000000a9e1",
|
|
42
|
+
userId: "user_00000000-0000-7000-8000-00000000a9e2",
|
|
43
|
+
membershipId: "member_00000000-0000-7000-8000-00000000a9e3",
|
|
44
|
+
role: "member",
|
|
45
|
+
...scope,
|
|
46
|
+
...overrides,
|
|
47
|
+
key,
|
|
48
|
+
headers: { authorization: `Bearer ${key}` },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The `agent` delegate's `findUnique` for the auth preHandler: the caller's row by its key's digest. */
|
|
53
|
+
export function agentKeyLookup(
|
|
54
|
+
caller: AgentCaller & { key: string },
|
|
55
|
+
): (args: {
|
|
56
|
+
where: { credentialHash?: string };
|
|
57
|
+
}) => Promise<Record<string, unknown> | null> {
|
|
58
|
+
const credentialHash = hashAgentKey(caller.key);
|
|
59
|
+
return ({ where }) =>
|
|
60
|
+
Promise.resolve(
|
|
61
|
+
where.credentialHash === credentialHash
|
|
62
|
+
? {
|
|
63
|
+
id: caller.agentId,
|
|
64
|
+
workspaceId: caller.workspaceId,
|
|
65
|
+
organizationId: caller.organizationId,
|
|
66
|
+
permissionLevel: "member",
|
|
67
|
+
enabled: true,
|
|
68
|
+
keyIssuedToMembershipId: caller.membershipId,
|
|
69
|
+
lastActiveAt: null,
|
|
70
|
+
responsible: { id: caller.membershipId, userId: caller.userId },
|
|
71
|
+
}
|
|
72
|
+
: null,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type MembershipArgs = {
|
|
77
|
+
where: { organizationId_userId?: { organizationId: string; userId: string } };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The `membership` delegate's `findUnique`, answering the agent's person's membership
|
|
82
|
+
* and deferring every other read to `fallback` (the suite's own mock).
|
|
83
|
+
*/
|
|
84
|
+
export function agentMembershipLookup(
|
|
85
|
+
caller: AgentCaller,
|
|
86
|
+
fallback: (args: MembershipArgs) => unknown,
|
|
87
|
+
): (args: MembershipArgs) => unknown {
|
|
88
|
+
return (args) => {
|
|
89
|
+
const key = args.where.organizationId_userId;
|
|
90
|
+
if (
|
|
91
|
+
key?.organizationId === caller.organizationId &&
|
|
92
|
+
key.userId === caller.userId
|
|
93
|
+
) {
|
|
94
|
+
return Promise.resolve({ id: caller.membershipId, role: caller.role });
|
|
95
|
+
}
|
|
96
|
+
return fallback(args);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file in-memory-prisma.ts
|
|
3
|
+
*
|
|
4
|
+
* An in-memory, where-clause-honouring Prisma double, shared by the route
|
|
5
|
+
* suites that register every tenant-guarded route plugin behind the real auth
|
|
6
|
+
* and tenant preHandlers (`routes/workspaces/__tests__/member-operates.test.ts`,
|
|
7
|
+
* #7636, and `agent-guard-parity.test.ts`, #3591).
|
|
8
|
+
*
|
|
9
|
+
* Every delegate call is recorded on `__calls` and the store is exposed on
|
|
10
|
+
* `__db`, so a refusal can be shown to read nothing. A relation filter the
|
|
11
|
+
* double does not model throws, so scoping that rides on one can never pass
|
|
12
|
+
* for the wrong reason.
|
|
13
|
+
*
|
|
14
|
+
* Usage — the factory must run inside the hoisted `jest.mock` callback:
|
|
15
|
+
*
|
|
16
|
+
* jest.mock("../../../lib/prisma.js", () =>
|
|
17
|
+
* jest
|
|
18
|
+
* .requireActual<typeof import("../../../__tests__/factories/in-memory-prisma")>(
|
|
19
|
+
* "../../../__tests__/factories/in-memory-prisma",
|
|
20
|
+
* )
|
|
21
|
+
* .createInMemoryPrisma(),
|
|
22
|
+
* );
|
|
23
|
+
*/
|
|
24
|
+
export function createInMemoryPrisma(): { prisma: Record<string, unknown> } {
|
|
25
|
+
type Row = Record<string, unknown>;
|
|
26
|
+
type Where = Record<string, unknown>;
|
|
27
|
+
const db: Record<string, Row[]> = {};
|
|
28
|
+
const calls: Array<{ model: string; op: string }> = [];
|
|
29
|
+
const OPS = new Set([
|
|
30
|
+
"in",
|
|
31
|
+
"notIn",
|
|
32
|
+
"lt",
|
|
33
|
+
"lte",
|
|
34
|
+
"gt",
|
|
35
|
+
"gte",
|
|
36
|
+
"not",
|
|
37
|
+
"equals",
|
|
38
|
+
"startsWith",
|
|
39
|
+
"endsWith",
|
|
40
|
+
"contains",
|
|
41
|
+
"has",
|
|
42
|
+
"hasSome",
|
|
43
|
+
"mode",
|
|
44
|
+
"path",
|
|
45
|
+
]);
|
|
46
|
+
const isPlain = (v: unknown): v is Record<string, unknown> =>
|
|
47
|
+
v !== null &&
|
|
48
|
+
typeof v === "object" &&
|
|
49
|
+
!Array.isArray(v) &&
|
|
50
|
+
!(v instanceof Date);
|
|
51
|
+
const norm = (v: unknown) => (v instanceof Date ? v.getTime() : (v ?? null));
|
|
52
|
+
const eq = (a: unknown, b: unknown) => norm(a) === norm(b);
|
|
53
|
+
const cmp = (a: unknown, b: unknown) => {
|
|
54
|
+
const x = norm(a) as number;
|
|
55
|
+
const y = norm(b) as number;
|
|
56
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
57
|
+
};
|
|
58
|
+
const table = (model: string) => (db[model] ??= []);
|
|
59
|
+
|
|
60
|
+
function matchCond(value: unknown, cond: unknown): boolean {
|
|
61
|
+
if (cond === undefined) return true;
|
|
62
|
+
if (!isPlain(cond)) return eq(value, cond);
|
|
63
|
+
const keys = Object.keys(cond);
|
|
64
|
+
if (!keys.every((k) => OPS.has(k))) {
|
|
65
|
+
// A nested filter is matched against a nested value. A relation filter
|
|
66
|
+
// this double does not model fails loudly, so scoping that rides on
|
|
67
|
+
// one can never pass for the wrong reason.
|
|
68
|
+
if (
|
|
69
|
+
keys.some((k) =>
|
|
70
|
+
["some", "none", "every", "is", "isNot"].includes(k),
|
|
71
|
+
) ||
|
|
72
|
+
!isPlain(value)
|
|
73
|
+
) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`in-memory prisma: unmodelled filter ${JSON.stringify(keys)}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return matches(value, cond);
|
|
79
|
+
}
|
|
80
|
+
if ("path" in cond) {
|
|
81
|
+
const path = cond.path as string[];
|
|
82
|
+
let nested: unknown = value;
|
|
83
|
+
for (const p of path) nested = isPlain(nested) ? nested[p] : undefined;
|
|
84
|
+
const { path: _p, ...rest } = cond;
|
|
85
|
+
return matchCond(nested, rest);
|
|
86
|
+
}
|
|
87
|
+
for (const [op, arg] of Object.entries(cond)) {
|
|
88
|
+
switch (op) {
|
|
89
|
+
case "in":
|
|
90
|
+
if (!(arg as unknown[]).some((v) => eq(value, v))) return false;
|
|
91
|
+
break;
|
|
92
|
+
case "notIn":
|
|
93
|
+
if ((arg as unknown[]).some((v) => eq(value, v))) return false;
|
|
94
|
+
break;
|
|
95
|
+
case "lt":
|
|
96
|
+
if (value == null || cmp(value, arg) >= 0) return false;
|
|
97
|
+
break;
|
|
98
|
+
case "lte":
|
|
99
|
+
if (value == null || cmp(value, arg) > 0) return false;
|
|
100
|
+
break;
|
|
101
|
+
case "gt":
|
|
102
|
+
if (value == null || cmp(value, arg) <= 0) return false;
|
|
103
|
+
break;
|
|
104
|
+
case "gte":
|
|
105
|
+
if (value == null || cmp(value, arg) < 0) return false;
|
|
106
|
+
break;
|
|
107
|
+
case "not":
|
|
108
|
+
if (matchCond(value, arg)) return false;
|
|
109
|
+
break;
|
|
110
|
+
case "equals":
|
|
111
|
+
if (!eq(value, arg)) return false;
|
|
112
|
+
break;
|
|
113
|
+
case "startsWith":
|
|
114
|
+
if (typeof value !== "string" || !value.startsWith(arg as string))
|
|
115
|
+
return false;
|
|
116
|
+
break;
|
|
117
|
+
case "endsWith":
|
|
118
|
+
if (typeof value !== "string" || !value.endsWith(arg as string))
|
|
119
|
+
return false;
|
|
120
|
+
break;
|
|
121
|
+
case "contains":
|
|
122
|
+
if (typeof value !== "string" || !value.includes(arg as string))
|
|
123
|
+
return false;
|
|
124
|
+
break;
|
|
125
|
+
case "has":
|
|
126
|
+
if (!Array.isArray(value) || !value.includes(arg)) return false;
|
|
127
|
+
break;
|
|
128
|
+
case "hasSome":
|
|
129
|
+
if (
|
|
130
|
+
!Array.isArray(value) ||
|
|
131
|
+
!(arg as unknown[]).some((v) => value.includes(v))
|
|
132
|
+
) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function matches(row: Row, where: Where | undefined): boolean {
|
|
144
|
+
for (const [key, cond] of Object.entries(where ?? {})) {
|
|
145
|
+
if (key === "AND") {
|
|
146
|
+
const all = Array.isArray(cond) ? cond : [cond];
|
|
147
|
+
if (!all.every((w) => matches(row, w as Where))) return false;
|
|
148
|
+
} else if (key === "OR") {
|
|
149
|
+
if (!(cond as Where[]).some((w) => matches(row, w))) return false;
|
|
150
|
+
} else if (key === "NOT") {
|
|
151
|
+
const all = Array.isArray(cond) ? cond : [cond];
|
|
152
|
+
if (all.some((w) => matches(row, w as Where))) return false;
|
|
153
|
+
} else if (
|
|
154
|
+
!(key in row) &&
|
|
155
|
+
isPlain(cond) &&
|
|
156
|
+
!Object.keys(cond).every((k) => OPS.has(k))
|
|
157
|
+
) {
|
|
158
|
+
if (!matches(row, cond)) return false;
|
|
159
|
+
} else if (!matchCond(row[key], cond)) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pick(value: unknown, select: unknown): unknown {
|
|
167
|
+
if (!isPlain(select) || value == null) return value;
|
|
168
|
+
if (Array.isArray(value)) return value.map((v) => pick(v, select));
|
|
169
|
+
if (!isPlain(value)) return value;
|
|
170
|
+
const out: Row = {};
|
|
171
|
+
for (const [key, spec] of Object.entries(select)) {
|
|
172
|
+
if (spec === true) out[key] = value[key];
|
|
173
|
+
else if (isPlain(spec) && key in value) {
|
|
174
|
+
out[key] = pick(value[key], (spec as Row).select ?? spec);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sorted(rows: Row[], orderBy: unknown): Row[] {
|
|
181
|
+
if (!orderBy) return rows;
|
|
182
|
+
const orders = (Array.isArray(orderBy) ? orderBy : [orderBy]) as Array<
|
|
183
|
+
Record<string, string>
|
|
184
|
+
>;
|
|
185
|
+
return [...rows].sort((a, b) => {
|
|
186
|
+
for (const ord of orders) {
|
|
187
|
+
const [field, dir] = Object.entries(ord)[0] as [string, string];
|
|
188
|
+
const c = cmp(a[field], b[field]);
|
|
189
|
+
if (c !== 0) return dir === "desc" ? -c : c;
|
|
190
|
+
}
|
|
191
|
+
return 0;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let seq = 0;
|
|
196
|
+
function apply(row: Row, data: Row): Row {
|
|
197
|
+
for (const [key, value] of Object.entries(data)) {
|
|
198
|
+
if (isPlain(value) && "increment" in value) {
|
|
199
|
+
row[key] = ((row[key] as number) ?? 0) + (value.increment as number);
|
|
200
|
+
} else if (isPlain(value) && "set" in value) {
|
|
201
|
+
row[key] = value.set;
|
|
202
|
+
} else {
|
|
203
|
+
row[key] = value;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
row["updatedAt"] = new Date();
|
|
207
|
+
return row;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function delegate(model: string) {
|
|
211
|
+
const log = (op: string) => calls.push({ model, op });
|
|
212
|
+
const findMany = (args: Row = {}) => {
|
|
213
|
+
let rows = sorted(
|
|
214
|
+
table(model).filter((r) => matches(r, args.where as Where)),
|
|
215
|
+
args.orderBy,
|
|
216
|
+
);
|
|
217
|
+
if (typeof args.skip === "number") rows = rows.slice(args.skip);
|
|
218
|
+
if (typeof args.take === "number") rows = rows.slice(0, args.take);
|
|
219
|
+
return rows.map(
|
|
220
|
+
(r) => pick({ ...r }, args.select ?? args.include) as Row,
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
const create = (args: Row) => {
|
|
224
|
+
const data = args.data as Row;
|
|
225
|
+
const row: Row = {
|
|
226
|
+
// A registry-shaped id, so an `isId` boundary check accepts it.
|
|
227
|
+
id: `${model === "user" ? "user" : model}_00000000-0000-7000-8000-${String(++seq).padStart(12, "0")}`,
|
|
228
|
+
createdAt: new Date(),
|
|
229
|
+
updatedAt: new Date(),
|
|
230
|
+
...data,
|
|
231
|
+
};
|
|
232
|
+
table(model).push(row);
|
|
233
|
+
return pick({ ...row }, args.select);
|
|
234
|
+
};
|
|
235
|
+
return {
|
|
236
|
+
findMany: jest.fn(
|
|
237
|
+
async (args: Row = {}) => (log("findMany"), findMany(args)),
|
|
238
|
+
),
|
|
239
|
+
findFirst: jest.fn(
|
|
240
|
+
async (args: Row = {}) => (log("findFirst"), findMany(args)[0] ?? null),
|
|
241
|
+
),
|
|
242
|
+
findUnique: jest.fn(
|
|
243
|
+
async (args: Row = {}) => (
|
|
244
|
+
log("findUnique"),
|
|
245
|
+
findMany(args)[0] ?? null
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
findUniqueOrThrow: jest.fn(async (args: Row = {}) => {
|
|
249
|
+
log("findUniqueOrThrow");
|
|
250
|
+
const row = findMany(args)[0];
|
|
251
|
+
if (!row) throw new Error(`${model} not found`);
|
|
252
|
+
return row;
|
|
253
|
+
}),
|
|
254
|
+
count: jest.fn(
|
|
255
|
+
async (args: Row = {}) => (
|
|
256
|
+
log("count"),
|
|
257
|
+
findMany({ where: args.where }).length
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
create: jest.fn(async (args: Row) => (log("create"), create(args))),
|
|
261
|
+
update: jest.fn(async (args: Row) => {
|
|
262
|
+
log("update");
|
|
263
|
+
const row = table(model).find((r) => matches(r, args.where as Where));
|
|
264
|
+
if (!row) throw new Error(`${model} not found`);
|
|
265
|
+
return pick({ ...apply(row, args.data as Row) }, args.select);
|
|
266
|
+
}),
|
|
267
|
+
updateMany: jest.fn(async (args: Row) => {
|
|
268
|
+
log("updateMany");
|
|
269
|
+
const rows = table(model).filter((r) =>
|
|
270
|
+
matches(r, args.where as Where),
|
|
271
|
+
);
|
|
272
|
+
for (const row of rows) apply(row, args.data as Row);
|
|
273
|
+
return { count: rows.length };
|
|
274
|
+
}),
|
|
275
|
+
upsert: jest.fn(async (args: Row) => {
|
|
276
|
+
log("upsert");
|
|
277
|
+
const row = table(model).find((r) => matches(r, args.where as Where));
|
|
278
|
+
if (row)
|
|
279
|
+
return pick({ ...apply(row, args.update as Row) }, args.select);
|
|
280
|
+
return create({ data: args.create, select: args.select });
|
|
281
|
+
}),
|
|
282
|
+
deleteMany: jest.fn(async (args: Row = {}) => {
|
|
283
|
+
log("deleteMany");
|
|
284
|
+
const before = table(model).length;
|
|
285
|
+
db[model] = table(model).filter(
|
|
286
|
+
(r) => !matches(r, args.where as Where),
|
|
287
|
+
);
|
|
288
|
+
return { count: before - table(model).length };
|
|
289
|
+
}),
|
|
290
|
+
groupBy: jest.fn(async () => (log("groupBy"), [])),
|
|
291
|
+
aggregate: jest.fn(
|
|
292
|
+
async () => (
|
|
293
|
+
log("aggregate"),
|
|
294
|
+
{ _count: {}, _sum: {}, _max: {}, _min: {} }
|
|
295
|
+
),
|
|
296
|
+
),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const delegates: Record<string, ReturnType<typeof delegate>> = {};
|
|
301
|
+
const prisma: Record<string, unknown> = new Proxy(
|
|
302
|
+
{},
|
|
303
|
+
{
|
|
304
|
+
get(_target, prop) {
|
|
305
|
+
if (prop === "__db") return db;
|
|
306
|
+
if (prop === "__calls") return calls;
|
|
307
|
+
if (prop === "$transaction") {
|
|
308
|
+
return async (arg: unknown) =>
|
|
309
|
+
typeof arg === "function"
|
|
310
|
+
? (arg as (tx: unknown) => unknown)(prisma)
|
|
311
|
+
: Promise.all(arg as Promise<unknown>[]);
|
|
312
|
+
}
|
|
313
|
+
if (prop === "$queryRaw" || prop === "$executeRaw")
|
|
314
|
+
return async () => [];
|
|
315
|
+
if (typeof prop !== "string" || prop === "then") return undefined;
|
|
316
|
+
return (delegates[prop] ??= delegate(prop));
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
return { prisma };
|
|
321
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
});
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file hub-require-spoke-key.test.ts
|
|
3
|
-
* @description `AILK_HUB_REQUIRE_SPOKE_KEY` — the hub's
|
|
4
|
-
* (#7532
|
|
3
|
+
* @description `AILK_HUB_REQUIRE_SPOKE_KEY` — the hub's spoke-key mode
|
|
4
|
+
* (#7532 D7; three-state since #7633, spec
|
|
5
|
+
* docs/specs/7633-expected-a-keyless-public-read-spec.md D2, AC-5).
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* refuses the boot
|
|
7
|
+
* Unset, empty or blank is `"auto"` (refuse a keyless call once the registry
|
|
8
|
+
* holds a row); `true`/`1` is `"on"`; `false`/`0` is `"off"`, the emergency
|
|
9
|
+
* opt-out, which warns at boot — case-insensitively, after trimming. Any other
|
|
10
|
+
* value refuses the boot with a message naming the variable, never the value.
|
|
11
|
+
* `"on"` with no `DATABASE_URL` refuses the boot too: it would refuse every
|
|
12
|
+
* call. `"auto"` and `"off"` boot without a database.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
// Mock dotenv so a developer's local .env never leaks into the config module.
|
|
@@ -25,7 +28,7 @@ jest.mock('@working-theory/observability', () => ({
|
|
|
25
28
|
},
|
|
26
29
|
}));
|
|
27
30
|
|
|
28
|
-
describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
|
|
31
|
+
describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7, #7633 D2)', () => {
|
|
29
32
|
const originalEnv = process.env;
|
|
30
33
|
|
|
31
34
|
beforeEach(() => {
|
|
@@ -41,16 +44,34 @@ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
|
|
|
41
44
|
jest.restoreAllMocks();
|
|
42
45
|
});
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
/** The boot warnings that name this variable. */
|
|
48
|
+
function hubWarnings(): unknown[][] {
|
|
49
|
+
return mockLoggerWarn.mock.calls.filter((call) =>
|
|
50
|
+
JSON.stringify(call).includes('AILK_HUB_REQUIRE_SPOKE_KEY'),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it.each([undefined, '', ' '])('%j parses "auto" — the default — and warns nothing', (raw) => {
|
|
55
|
+
if (raw !== undefined) process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
|
|
56
|
+
const { config } = require('../index');
|
|
57
|
+
expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: 'auto' });
|
|
58
|
+
expect(hubWarnings()).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it.each(['true', 'TRUE', '1', ' True '])('%j parses "on" and warns nothing', (raw) => {
|
|
45
62
|
process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
|
|
46
63
|
const { config } = require('../index');
|
|
47
|
-
expect(config
|
|
64
|
+
expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: 'on' });
|
|
65
|
+
expect(hubWarnings()).toHaveLength(0);
|
|
48
66
|
});
|
|
49
67
|
|
|
50
|
-
it.each([
|
|
51
|
-
|
|
68
|
+
it.each(['false', 'FALSE', '0'])('%j parses "off" — the emergency opt-out — and warns once at boot', (raw) => {
|
|
69
|
+
process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
|
|
52
70
|
const { config } = require('../index');
|
|
53
|
-
expect(config
|
|
71
|
+
expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: 'off' });
|
|
72
|
+
const warnings = hubWarnings();
|
|
73
|
+
expect(warnings).toHaveLength(1);
|
|
74
|
+
expect(JSON.stringify(warnings)).toContain('AILK_HUB_REQUIRE_SPOKE_KEY=false');
|
|
54
75
|
});
|
|
55
76
|
|
|
56
77
|
it('any other value refuses the boot, naming the variable and never the value', () => {
|
|
@@ -83,7 +104,11 @@ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
|
|
|
83
104
|
);
|
|
84
105
|
});
|
|
85
106
|
|
|
86
|
-
it(
|
|
107
|
+
it.each([
|
|
108
|
+
['auto', undefined],
|
|
109
|
+
['off', 'false'],
|
|
110
|
+
])('"%s" with DATABASE_URL unset still boots database-less', (mode, raw) => {
|
|
111
|
+
if (raw !== undefined) process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
|
|
87
112
|
delete process.env.DATABASE_URL;
|
|
88
113
|
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
89
114
|
throw new Error('Process exit called');
|
|
@@ -91,6 +116,6 @@ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
|
|
|
91
116
|
|
|
92
117
|
const { config } = require('../index');
|
|
93
118
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
94
|
-
expect(config
|
|
119
|
+
expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: mode });
|
|
95
120
|
});
|
|
96
121
|
});
|