create-ailk 0.13.0 → 0.15.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 +250 -36
- package/component-catalog.md +10 -6
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +34 -2
- package/dist/add-route.js +18 -2
- package/dist/apply-design.d.ts +32 -2
- package/dist/apply-design.js +155 -22
- package/dist/cli.js +24 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- 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 +224 -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 +2 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/design-checks.d.ts +12 -1
- package/dist/lib/design-checks.js +38 -2
- package/dist/lib/extract-module-bundle.d.ts +11 -0
- package/dist/lib/extract-module-bundle.js +36 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-license-gate.d.ts +9 -0
- package/dist/lib/module-license-gate.js +18 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +6 -0
- package/dist/parse-args.d.ts +22 -0
- package/dist/parse-args.js +73 -0
- package/dist/programmatic.d.ts +20 -0
- package/dist/programmatic.js +180 -35
- package/dist/site-chrome.d.ts +42 -0
- package/dist/site-chrome.js +140 -2
- package/dist/site-fonts.d.ts +21 -0
- package/dist/site-fonts.js +54 -0
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- 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/sync-routes.js +11 -2
- package/dist/team-profiles.d.ts +29 -0
- package/dist/team-profiles.js +43 -0
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -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 +166 -36
- 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/README.md +2 -0
- package/templates/apps/api/CLAUDE.md +6 -1
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +18 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/lead-routing.ts +148 -29
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/package.json +1 -1
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/CLAUDE.md +12 -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]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -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__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -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 +212 -57
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- 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]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- 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]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- 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/openapi.json/__tests__/route.test.ts +89 -1
- package/templates/apps/web/app/openapi.json/route.ts +69 -1
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -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/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/profile-back-link.ts +51 -0
- package/templates/apps/web/lib/relay.ts +206 -2
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +15 -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/lib/topology.generated.ts +23 -0
- package/templates/apps/web/messages/en.json +7 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +32 -0
- package/templates/database/__tests__/agents.db.test.ts +65 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/auth/schema.prisma +23 -0
- package/templates/database/inbox/schema.prisma +13 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -34
- 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
|
@@ -28,9 +28,18 @@
|
|
|
28
28
|
* - AN ALLOW-LIST OF HEADERS (D3). Nothing else from the incoming request is
|
|
29
29
|
* forwarded: no `authorization`, no client-sent `x-ailk-*`, no `host`, and
|
|
30
30
|
* a `cookie` only on the one session route.
|
|
31
|
+
* - A PER-VISITOR BUDGET (#7707, D1-D7). Each visitor gets 8 calls per 10
|
|
32
|
+
* minutes across the five visitor routes, and 30 per 10 minutes on the
|
|
33
|
+
* session route, counted in this instance's own in-memory store; the
|
|
34
|
+
* ninth (31st) gets `429 rate_limited` before any hub call. Per instance,
|
|
35
|
+
* and only as strong as the platform: it holds only where the site's edge
|
|
36
|
+
* overwrites `x-forwarded-for` and `x-real-ip` (Vercel does; a bare
|
|
37
|
+
* self-hosted Next server does not).
|
|
31
38
|
*
|
|
32
39
|
* NO SECRET IS LOGGED. A failure logs `{ path, outcome }` and nothing else.
|
|
33
40
|
*/
|
|
41
|
+
import { isIP } from "node:net";
|
|
42
|
+
|
|
34
43
|
import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
|
|
35
44
|
|
|
36
45
|
type RelayMethod = "GET" | "POST";
|
|
@@ -78,11 +87,195 @@ const BASE_RESPONSE_HEADERS = {
|
|
|
78
87
|
"x-content-type-options": "nosniff",
|
|
79
88
|
} as const;
|
|
80
89
|
|
|
81
|
-
function refusal(
|
|
90
|
+
function refusal(
|
|
91
|
+
status: number,
|
|
92
|
+
code: string,
|
|
93
|
+
message: string,
|
|
94
|
+
extraHeaders?: Record<string, string>,
|
|
95
|
+
): Response {
|
|
82
96
|
return Response.json(
|
|
83
97
|
{ ok: false, error: { code, message } },
|
|
84
|
-
{ status, headers: BASE_RESPONSE_HEADERS },
|
|
98
|
+
{ status, headers: { ...BASE_RESPONSE_HEADERS, ...extraHeaders } },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The per-visitor budget (#7707, D1-D2): 8 calls per 10 minutes shared across
|
|
104
|
+
* the five visitor routes, 30 per 10 minutes on the session route alone, an
|
|
105
|
+
* in-process store capped at 10,000 entries. No env var tunes these (AC-8).
|
|
106
|
+
*/
|
|
107
|
+
export const RELAY_RATE_LIMIT = Object.freeze({
|
|
108
|
+
limit: 8,
|
|
109
|
+
sessionLimit: 30,
|
|
110
|
+
windowMs: 600_000,
|
|
111
|
+
maxBuckets: 10_000,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
type RateLimitBucket = {
|
|
115
|
+
count: number;
|
|
116
|
+
windowStartMs: number;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Module-scoped store, shared by the visitor (`visitor|<identity>`) and
|
|
120
|
+
// session (`session|<identity>`) budgets (D7: same identity, window and
|
|
121
|
+
// store). Ported from the hub's own limiter
|
|
122
|
+
// (`apps/api/src/middleware/rate-limit.ts`), not imported — `apps/web`
|
|
123
|
+
// cannot import `apps/api`.
|
|
124
|
+
const rateLimitBuckets = new Map<string, RateLimitBucket>();
|
|
125
|
+
let requestsSinceSweep = 0;
|
|
126
|
+
const SWEEP_INTERVAL_REQUESTS = 100;
|
|
127
|
+
|
|
128
|
+
function evictExpiredBuckets(now: number): void {
|
|
129
|
+
for (const [key, bucket] of rateLimitBuckets) {
|
|
130
|
+
if (now - bucket.windowStartMs >= RELAY_RATE_LIMIT.windowMs) {
|
|
131
|
+
rateLimitBuckets.delete(key);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Test-only reset (D8). Not reachable over HTTP. */
|
|
137
|
+
export function __resetRelayRateLimit(): void {
|
|
138
|
+
rateLimitBuckets.clear();
|
|
139
|
+
requestsSinceSweep = 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Test-only accessor for the current distinct-bucket count (D8). */
|
|
143
|
+
export function __relayRateLimitBucketCount(): number {
|
|
144
|
+
return rateLimitBuckets.size;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Count one request against `key`, opening a new window when needed (D6). A
|
|
149
|
+
* genuinely new identity at capacity evicts the single oldest-inserted entry
|
|
150
|
+
* first (`Map` iteration order is insertion order) — the hub's own `hit()`.
|
|
151
|
+
*/
|
|
152
|
+
function hit(key: string, now: number): RateLimitBucket {
|
|
153
|
+
let bucket = rateLimitBuckets.get(key);
|
|
154
|
+
if (!bucket || now - bucket.windowStartMs >= RELAY_RATE_LIMIT.windowMs) {
|
|
155
|
+
if (
|
|
156
|
+
!rateLimitBuckets.has(key) &&
|
|
157
|
+
rateLimitBuckets.size >= RELAY_RATE_LIMIT.maxBuckets
|
|
158
|
+
) {
|
|
159
|
+
const oldestKey = rateLimitBuckets.keys().next().value;
|
|
160
|
+
if (oldestKey !== undefined) rateLimitBuckets.delete(oldestKey);
|
|
161
|
+
}
|
|
162
|
+
bucket = { count: 0, windowStartMs: now };
|
|
163
|
+
rateLimitBuckets.set(key, bucket);
|
|
164
|
+
}
|
|
165
|
+
bucket.count += 1;
|
|
166
|
+
return bucket;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The eight hextets of a valid IPv6 address, or `null`. Ported from the
|
|
171
|
+
* hub's own `expandIpv6` (`apps/api/src/middleware/rate-limit.ts`).
|
|
172
|
+
*/
|
|
173
|
+
function expandIpv6(address: string): number[] | null {
|
|
174
|
+
let text = address;
|
|
175
|
+
const tail: number[] = [];
|
|
176
|
+
const lastColon = text.lastIndexOf(":");
|
|
177
|
+
const last = text.slice(lastColon + 1);
|
|
178
|
+
if (last.includes(".")) {
|
|
179
|
+
const octets = last.split(".").map(Number);
|
|
180
|
+
tail.push(((octets[0] ?? 0) << 8) | (octets[1] ?? 0));
|
|
181
|
+
tail.push(((octets[2] ?? 0) << 8) | (octets[3] ?? 0));
|
|
182
|
+
text = `${text.slice(0, lastColon + 1)}0`;
|
|
183
|
+
}
|
|
184
|
+
const halves = text.split("::");
|
|
185
|
+
const parse = (part: string | undefined): number[] =>
|
|
186
|
+
part ? part.split(":").map((hextet) => parseInt(hextet, 16)) : [];
|
|
187
|
+
const head = parse(halves[0]);
|
|
188
|
+
const rest = parse(halves[1]);
|
|
189
|
+
if (tail.length > 0) {
|
|
190
|
+
(halves.length > 1 ? rest : head).pop();
|
|
191
|
+
}
|
|
192
|
+
const fill = 8 - head.length - rest.length - tail.length;
|
|
193
|
+
if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
|
|
194
|
+
const hextets = [
|
|
195
|
+
...head,
|
|
196
|
+
...new Array<number>(fill).fill(0),
|
|
197
|
+
...rest,
|
|
198
|
+
...tail,
|
|
199
|
+
];
|
|
200
|
+
return hextets.every((h) => Number.isInteger(h) && h >= 0 && h <= 0xffff)
|
|
201
|
+
? hextets
|
|
202
|
+
: null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const MAX_VISITOR_IP_LENGTH = 45;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Validate and normalise a visitor address (D3): IPv4 as is, an IPv4-mapped
|
|
209
|
+
* IPv6 address as its IPv4 form, any other IPv6 address as its /64 prefix.
|
|
210
|
+
* `null` for anything else. Ported from the hub's own `normaliseVisitorIp`.
|
|
211
|
+
*/
|
|
212
|
+
function normaliseVisitorIp(raw: string): string | null {
|
|
213
|
+
const value = raw.trim().toLowerCase();
|
|
214
|
+
if (value.length > MAX_VISITOR_IP_LENGTH || value.includes("%")) return null;
|
|
215
|
+
const family = isIP(value);
|
|
216
|
+
if (family === 4) return value;
|
|
217
|
+
if (family !== 6) return null;
|
|
218
|
+
const hextets = expandIpv6(value);
|
|
219
|
+
if (!hextets) return null;
|
|
220
|
+
const isV4Mapped =
|
|
221
|
+
hextets.slice(0, 5).every((h) => h === 0) && hextets[5] === 0xffff;
|
|
222
|
+
if (isV4Mapped) {
|
|
223
|
+
const high = hextets[6] ?? 0;
|
|
224
|
+
const low = hextets[7] ?? 0;
|
|
225
|
+
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
|
226
|
+
}
|
|
227
|
+
const prefix = hextets
|
|
228
|
+
.slice(0, 4)
|
|
229
|
+
.map((h) => h.toString(16).padStart(4, "0"))
|
|
230
|
+
.join(":");
|
|
231
|
+
return `${prefix}::/64`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The visitor's identity (D3): the first comma-separated value of
|
|
236
|
+
* `x-forwarded-for` when present (a present-but-empty value does not fall
|
|
237
|
+
* back to `x-real-ip`), else `x-real-ip`. Anything that does not normalise —
|
|
238
|
+
* no header, an empty value, or one `normaliseVisitorIp` rejects — shares the
|
|
239
|
+
* single identity `"unknown"`, never a fresh bucket of its own.
|
|
240
|
+
*/
|
|
241
|
+
function visitorIdentity(request: Request): string {
|
|
242
|
+
const raw =
|
|
243
|
+
request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip");
|
|
244
|
+
const first = raw?.split(",")[0]?.trim().toLowerCase();
|
|
245
|
+
if (!first) return "unknown";
|
|
246
|
+
return normaliseVisitorIp(first) ?? "unknown";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Count one request against its budget (D1-D7): the visitor budget for every
|
|
251
|
+
* entry but the session route, which gets its own, wider, independent
|
|
252
|
+
* budget. Returns whether the call is admitted and the whole seconds (at
|
|
253
|
+
* least 1) until the tripped bucket's window ends.
|
|
254
|
+
*/
|
|
255
|
+
function checkRateLimit(
|
|
256
|
+
request: Request,
|
|
257
|
+
entry: RelayRoute,
|
|
258
|
+
now: number,
|
|
259
|
+
): { allowed: boolean; retryAfterSeconds: number } {
|
|
260
|
+
requestsSinceSweep += 1;
|
|
261
|
+
if (requestsSinceSweep >= SWEEP_INTERVAL_REQUESTS) {
|
|
262
|
+
evictExpiredBuckets(now);
|
|
263
|
+
requestsSinceSweep = 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const identity = visitorIdentity(request);
|
|
267
|
+
const isSession = entry === SESSION_ROUTE;
|
|
268
|
+
const key = `${isSession ? "session" : "visitor"}|${identity}`;
|
|
269
|
+
const limit = isSession
|
|
270
|
+
? RELAY_RATE_LIMIT.sessionLimit
|
|
271
|
+
: RELAY_RATE_LIMIT.limit;
|
|
272
|
+
|
|
273
|
+
const bucket = hit(key, now);
|
|
274
|
+
const retryAfterSeconds = Math.max(
|
|
275
|
+
1,
|
|
276
|
+
Math.ceil((bucket.windowStartMs + RELAY_RATE_LIMIT.windowMs - now) / 1000),
|
|
85
277
|
);
|
|
278
|
+
return { allowed: bucket.count <= limit, retryAfterSeconds };
|
|
86
279
|
}
|
|
87
280
|
|
|
88
281
|
/**
|
|
@@ -220,6 +413,17 @@ export async function handleRelay(request: Request): Promise<Response> {
|
|
|
220
413
|
return refusal(403, "cross_site", "Cross-site request refused");
|
|
221
414
|
}
|
|
222
415
|
|
|
416
|
+
const { allowed, retryAfterSeconds } = checkRateLimit(
|
|
417
|
+
request,
|
|
418
|
+
entry,
|
|
419
|
+
Date.now(),
|
|
420
|
+
);
|
|
421
|
+
if (!allowed) {
|
|
422
|
+
return refusal(429, "rate_limited", "Too many requests", {
|
|
423
|
+
"retry-after": String(retryAfterSeconds),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
223
427
|
let body: string | undefined;
|
|
224
428
|
if (entry.method === "POST") {
|
|
225
429
|
if (!isJson(request.headers.get("content-type"))) {
|
|
@@ -47,6 +47,8 @@ interface SiteFrontmatter {
|
|
|
47
47
|
baseUrl?: string | undefined;
|
|
48
48
|
/** `brand.logomark`, raw — validated by `siteLogo()` when it renders. */
|
|
49
49
|
logomark?: unknown;
|
|
50
|
+
/** `siteIdentity.legalName`, when declared as a non-empty string. */
|
|
51
|
+
legalName?: string | undefined;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -84,8 +86,19 @@ function readSiteFrontmatter(): SiteFrontmatter | null {
|
|
|
84
86
|
const parsed: unknown = parseYaml(match[1] ?? "");
|
|
85
87
|
if (!parsed || typeof parsed !== "object") return null;
|
|
86
88
|
|
|
87
|
-
const { siteName, baseUrl, brand } = parsed as Record<
|
|
89
|
+
const { siteName, baseUrl, brand, siteIdentity } = parsed as Record<
|
|
90
|
+
string,
|
|
91
|
+
unknown
|
|
92
|
+
>;
|
|
93
|
+
const legalName =
|
|
94
|
+
siteIdentity && typeof siteIdentity === "object"
|
|
95
|
+
? (siteIdentity as Record<string, unknown>).legalName
|
|
96
|
+
: undefined;
|
|
88
97
|
return {
|
|
98
|
+
legalName:
|
|
99
|
+
typeof legalName === "string" && legalName.trim().length > 0
|
|
100
|
+
? legalName.trim()
|
|
101
|
+
: undefined,
|
|
89
102
|
siteName: typeof siteName === "string" ? siteName : undefined,
|
|
90
103
|
baseUrl: typeof baseUrl === "string" ? baseUrl : undefined,
|
|
91
104
|
logomark:
|
|
@@ -112,6 +125,14 @@ export const SITE_NAME =
|
|
|
112
125
|
export const SITE_BASE_URL =
|
|
113
126
|
process.env.NEXT_PUBLIC_BASE_URL ?? siteFrontmatter?.baseUrl ?? FALLBACK_BASE_URL;
|
|
114
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The footer copyright holder (#7796): `siteIdentity.legalName` when the site
|
|
130
|
+
* declares one (schema.org's `Organization.legalName` — the owning entity,
|
|
131
|
+
* which often differs from the display name), else `SITE_NAME`. A site that
|
|
132
|
+
* declares no `legalName` resolves exactly as it did before this existed.
|
|
133
|
+
*/
|
|
134
|
+
export const COPYRIGHT_HOLDER = siteFrontmatter?.legalName ?? SITE_NAME;
|
|
135
|
+
|
|
115
136
|
/**
|
|
116
137
|
* The raw `brand.logomark` from `_site.mdx`, or `undefined` when none is
|
|
117
138
|
* declared. Unvalidated: `siteLogo()` (`site-logo.tsx`) parses it on the
|
|
@@ -13,12 +13,27 @@
|
|
|
13
13
|
// - home: "deck" → the locale-root route mounts MarketingShell with
|
|
14
14
|
// snap="mandatory" + deckHero (spec 5426 D7); every other route is
|
|
15
15
|
// unaffected.
|
|
16
|
+
// - innerPages: "bare" → every non-home page renders the bare header and
|
|
17
|
+
// the one-bar footer.
|
|
18
|
+
// - deckPresentation → the deck home presents slide by slide: fitted slides, slide anchors, the mark follows the ground
|
|
19
|
+
// - colorMode: "light" → the site is pinned to light and shows no theme
|
|
20
|
+
// switch; dark appears only as a deck ground.
|
|
21
|
+
// - chromeStyle: "minimal" → the deck's closing slide is the wordmark,
|
|
22
|
+
// contact lines and one legal line; the consent bar is a hairline bar on
|
|
23
|
+
// the page ground; the 404 renders in the site's theme.
|
|
24
|
+
// - closingFooterPaths → each listed inner page ends on the closing footer
|
|
25
|
+
// (the wordmark, contact lines and legal line) instead of its usual footer.
|
|
16
26
|
|
|
17
27
|
export interface SiteChrome {
|
|
18
28
|
nav: "full" | "mark-only";
|
|
19
29
|
footer: "columns" | "centered";
|
|
20
30
|
announcementBanner: boolean;
|
|
21
31
|
home: "default" | "deck";
|
|
32
|
+
innerPages?: "bare";
|
|
33
|
+
deckPresentation?: true;
|
|
34
|
+
colorMode?: "light";
|
|
35
|
+
chromeStyle?: "minimal";
|
|
36
|
+
closingFooterPaths?: readonly string[];
|
|
22
37
|
}
|
|
23
38
|
|
|
24
39
|
export const siteChrome: SiteChrome = {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// site-fonts.ts — the web faces the site's design sources.
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by create-ailk from the design's `typography.<role>.fontSource`
|
|
4
|
+
// (#7720). A design that sources no face gets the empty default.
|
|
5
|
+
// `apps/web/app/[locale]/layout.tsx` passes this to `applyDesignFonts`
|
|
6
|
+
// (@working-theory/theme): `families` fills --font-display / --font-body
|
|
7
|
+
// unless the site's own `brand.fonts` sets them, and `className` (the
|
|
8
|
+
// next/font variable classes) goes on <html>.
|
|
9
|
+
|
|
10
|
+
export interface SiteFonts {
|
|
11
|
+
className: string;
|
|
12
|
+
families: { display?: string | undefined; body?: string | undefined };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const siteFonts: SiteFonts = { className: "", families: {} };
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Split from `site-brand.tsx`, which ten route and lib modules import for
|
|
6
6
|
* `SITE_NAME` / `SITE_BASE_URL`: the mark needs `@working-theory/ui`'s
|
|
7
7
|
* `Image` and site-config's schema, and those stay out of that wider graph
|
|
8
|
-
* (spec #6820 § 5.4). The two layouts are the only callers.
|
|
8
|
+
* (spec #6820 § 5.4). The two layouts and the home page are the only callers.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { BrandLogomarkSchema } from "@working-theory/site-config/schema";
|
|
@@ -14,6 +14,44 @@ import type { ReactElement } from "react";
|
|
|
14
14
|
|
|
15
15
|
import { SITE_LOGOMARK, SITE_NAME } from "./site-brand";
|
|
16
16
|
|
|
17
|
+
/** The light-mode mark as a hero `lockup`: square, so the Hero shows it as a brand mark. */
|
|
18
|
+
export interface SiteLogomarkAsset {
|
|
19
|
+
src: string;
|
|
20
|
+
alt: string;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The same mark `siteLogo()` renders, as data (#7711): the deck home passes it
|
|
27
|
+
* to its landing hero, which carries the brand while the header is bare
|
|
28
|
+
* (#5697). It passes the light mark only, with no `darkSrc`, as #7711 shipped it.
|
|
29
|
+
*/
|
|
30
|
+
export function siteLogomarkAsset(): SiteLogomarkAsset {
|
|
31
|
+
if (SITE_LOGOMARK === undefined) {
|
|
32
|
+
return { src: "/logomark.svg", alt: SITE_NAME, width: 28, height: 28 };
|
|
33
|
+
}
|
|
34
|
+
const { light, alt } = BrandLogomarkSchema.parse(SITE_LOGOMARK);
|
|
35
|
+
return { src: light, alt, width: 28, height: 28 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The header's wordmark pair as a hero `lockup` (#7733): the deck home's
|
|
40
|
+
* landing puts it in the headline's place. The same files and size the
|
|
41
|
+
* layout's header wordmark uses; wide, so the Hero reads it as a wordmark,
|
|
42
|
+
* and `alt` is the site name, so the landing's one `h1` is named for the site.
|
|
43
|
+
* `darkSrc` is the light-ink wordmark the Hero swaps in on a dark ground.
|
|
44
|
+
*/
|
|
45
|
+
export function siteWordmarkAsset(): SiteLogomarkAsset & { darkSrc: string } {
|
|
46
|
+
return {
|
|
47
|
+
src: "/wordmark.svg",
|
|
48
|
+
darkSrc: "/wordmark-light.svg",
|
|
49
|
+
alt: SITE_NAME,
|
|
50
|
+
width: 140,
|
|
51
|
+
height: 16,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
17
55
|
/**
|
|
18
56
|
* Shared brand mark. The Nav pairs it with `/wordmark.svg`. RSC-safe (no
|
|
19
57
|
* hooks, no client state).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's theme head (#7742 D6): the inline CSS the locale layout puts in
|
|
3
|
+
* its `<style data-ailk="theme-vars">`, and the class its `<html>` carries
|
|
4
|
+
* for the design's web faces. One helper so the locale layout and the
|
|
5
|
+
* `chromeStyle: minimal` 404 (`app/not-found.tsx`), which renders its own
|
|
6
|
+
* `<html>`, can never drift apart.
|
|
7
|
+
*
|
|
8
|
+
* The site's own declared colours (`_site.mdx` `theme:` + `brand:`) become
|
|
9
|
+
* the token overlay; the design's faces (#7720, the generated
|
|
10
|
+
* `site-fonts.ts`) lie beneath `brand.fonts`; both theme modes are emitted,
|
|
11
|
+
* the dark one gated by `[data-theme="dark"]` (#1532); the site's shape
|
|
12
|
+
* settings (`theme.corners`, `theme.eyebrowCase`) follow so they win.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { applyDesignFonts, themeToCssVarsBothModes } from "@working-theory/theme";
|
|
16
|
+
|
|
17
|
+
import { siteFonts } from "./site-fonts";
|
|
18
|
+
import { loadSiteBrandTokens, loadSiteShapeCss } from "./site-theme";
|
|
19
|
+
|
|
20
|
+
export interface SiteThemeHead {
|
|
21
|
+
/** The `<style data-ailk="theme-vars">` text. */
|
|
22
|
+
css: string;
|
|
23
|
+
/** The `<html>` class for the design's faces; absent when there are none. */
|
|
24
|
+
fontClassName?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function siteThemeHead(): SiteThemeHead {
|
|
28
|
+
const designFonts = applyDesignFonts(loadSiteBrandTokens(), siteFonts);
|
|
29
|
+
const css =
|
|
30
|
+
themeToCssVarsBothModes(designFonts.tokens ? { brand: designFonts.tokens } : {}) +
|
|
31
|
+
loadSiteShapeCss();
|
|
32
|
+
return designFonts.className ? { css, fontClassName: designFonts.className } : { css };
|
|
33
|
+
}
|
|
@@ -30,6 +30,7 @@ import { join } from "node:path";
|
|
|
30
30
|
import { BrandSchema, SiteThemeSchema } from "@working-theory/site-config/schema";
|
|
31
31
|
import {
|
|
32
32
|
siteBrandToBrandTokens,
|
|
33
|
+
siteThemeScopedDarkCss,
|
|
33
34
|
siteThemeShapeCss,
|
|
34
35
|
type BrandTokenOverrides,
|
|
35
36
|
} from "@working-theory/theme";
|
|
@@ -97,9 +98,20 @@ export function loadSiteBrandTokens(): BrandTokenOverrides | undefined {
|
|
|
97
98
|
/**
|
|
98
99
|
* The site's shape settings (`theme.corners`, `theme.eyebrowCase`) as a CSS
|
|
99
100
|
* override block for `:root`. "" when the site sets neither.
|
|
101
|
+
*
|
|
102
|
+
* #7719 D6 — a theme with `darkBands: "site"` also gets its own dark palette
|
|
103
|
+
* on every dark-scoped band below the root (`siteThemeScopedDarkCss`), built
|
|
104
|
+
* from the overlay the root rule carries (the `brand:` triad included). Only
|
|
105
|
+
* mode-variant tokens are re-declared, so the shape settings above still
|
|
106
|
+
* reach inside the band.
|
|
107
|
+
* Without it the string is today's.
|
|
100
108
|
*/
|
|
101
109
|
export function loadSiteShapeCss(): string {
|
|
102
110
|
const { theme } = readSiteBlocks();
|
|
103
111
|
if (theme === undefined) return "";
|
|
104
|
-
|
|
112
|
+
const parsed = SiteThemeSchema.parse(theme);
|
|
113
|
+
const shape = siteThemeShapeCss(parsed);
|
|
114
|
+
if (parsed.darkBands !== "site") return shape;
|
|
115
|
+
const brand = loadSiteBrandTokens();
|
|
116
|
+
return brand ? shape + siteThemeScopedDarkCss(parsed, brand) : shape;
|
|
105
117
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// topology.generated.ts — how this site reads a session and registers its stores.
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by create-ailk for this site's topology (#7426). Do not edit:
|
|
4
|
+
// every scaffold rewrites it. A hub reads the session through Better Auth;
|
|
5
|
+
// a spoke (`--topology spoke`) has no sign-in and no database of its own,
|
|
6
|
+
// so its render reads no session and imports nothing.
|
|
7
|
+
import { auth } from "../auth";
|
|
8
|
+
|
|
9
|
+
export const TOPOLOGY: "hub" | "spoke" = "hub";
|
|
10
|
+
export const SIGN_IN_ENABLED: boolean = true;
|
|
11
|
+
|
|
12
|
+
export type SiteSession = {
|
|
13
|
+
user: { name?: string | null; email: string };
|
|
14
|
+
} | null;
|
|
15
|
+
|
|
16
|
+
export async function readSession(headers: Headers): Promise<SiteSession> {
|
|
17
|
+
return auth.api.getSession({ headers });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function registerSiteStores(): Promise<void> {
|
|
21
|
+
const { registerLicenseKeyStore } = await import("./license-key-store");
|
|
22
|
+
await registerLicenseKeyStore();
|
|
23
|
+
}
|
|
@@ -51,7 +51,9 @@
|
|
|
51
51
|
"customer3": "'{Customer 3}'",
|
|
52
52
|
"service1": "'{Service 1}'",
|
|
53
53
|
"service2": "'{Service 2}'",
|
|
54
|
-
"service3": "'{Service 3}'"
|
|
54
|
+
"service3": "'{Service 3}'",
|
|
55
|
+
"nextSlide": "Next slide",
|
|
56
|
+
"backToTop": "Back to top"
|
|
55
57
|
},
|
|
56
58
|
"consent": {
|
|
57
59
|
"title": "Cookie Preferences",
|
|
@@ -96,6 +98,10 @@
|
|
|
96
98
|
"heading": "Portfolio",
|
|
97
99
|
"emptyState": "Portfolio coming soon."
|
|
98
100
|
},
|
|
101
|
+
"team": {
|
|
102
|
+
"heading": "Team",
|
|
103
|
+
"emptyState": "Team listing coming soon."
|
|
104
|
+
},
|
|
99
105
|
"courses": {
|
|
100
106
|
"heading": "Courses",
|
|
101
107
|
"emptyState": "Courses listing coming soon."
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @working-theory/database
|
|
2
2
|
|
|
3
|
+
## 0.37.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#7821](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7821) [`878ffc6`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/878ffc61e3c0baad68889e77d4686fdbc88a85c2) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Sign-in lands in the user's last workspace ([#7803](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7803)), the schema half.
|
|
8
|
+
|
|
9
|
+
- `@working-theory/database` — `User` gains `lastWorkspaceId` (`users.last_workspace_id`), a nullable reference to `Workspace` with `ON DELETE SET NULL`, an index and the ADR-0031 `wksp_` format CHECK; `Workspace` gains the back-relation `lastActiveUsers`. The migration `20261008120000_user_last_workspace` is purely additive: one nullable column with no default, so no backfill runs and no existing row changes. Deploy it before the API that reads the column.
|
|
10
|
+
|
|
11
|
+
- [#7901](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7901) [`6efec95`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6efec954914e40acbf42db1e0132569e983c3915) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Agent key grants ([#7864](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7864)): an owner or admin can grant an agent any of five
|
|
12
|
+
permissions — content-edit, content-publish, assets-write, magnets-manage and
|
|
13
|
+
meeting-types-manage. `@working-theory/validation` adds
|
|
14
|
+
`AGENT_GRANTABLE_PERMISSIONS`, `isAgentGrantable`, `allows` and
|
|
15
|
+
`setAgentGrantsBodySchema`, and the agent wire row gains `grants`;
|
|
16
|
+
`@working-theory/database` adds the `agents.grants` column, held to those five
|
|
17
|
+
by a CHECK. An agent with no grants behaves exactly as before.
|
|
18
|
+
|
|
19
|
+
- [#7895](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7895) [`90451af`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/90451afcc7c264a0c5de7d239edcfbdefcf28f64) Thanks [@samuelhenry](https://github.com/samuelhenry)! - A lead write can be retried safely ([#7869](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7869)), the schema half.
|
|
20
|
+
|
|
21
|
+
- `@working-theory/database` — `Lead` gains `idempotencyKey` (`leads.idempotency_key`), a nullable text column with a unique index over `(workspace_id, idempotency_key)`. The migration `20261008130000_lead_idempotency_key` is purely additive: one nullable column with no default and one unique index, so no backfill runs and no existing row changes (NULLs are distinct under the index). Deploy it before the API that writes the column.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [[`d3f8ac6`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d3f8ac6bd41959f23a1f5f591e3cccce42895cd0), [`c979e93`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/c979e93a593606bfe47e815f29eef9e17be7e329), [`6efec95`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6efec954914e40acbf42db1e0132569e983c3915)]:
|
|
26
|
+
- @working-theory/validation@0.37.0
|
|
27
|
+
|
|
28
|
+
## 0.36.0
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- Updated dependencies [[`14119fa`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/14119fac22bf0c26973d9286a74bced761ed1218), [`2475b60`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/2475b608d65880d9986d94628494509faedd0cd9), [`7fdf60d`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/7fdf60de18d86d878f5e567d6df5c2e0d64d97eb), [`47eeb95`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/47eeb958390f2eadd7b0c43a47c46fbbd1309872), [`4609e76`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/4609e7640798c53c730b7b9f0e723df1300ca379), [`91c5f56`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/91c5f56121d0efe475f918c493ed4245834d9e7f), [`994094f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/994094f77619b8b0ff79688b24d7befb4b1b29fa)]:
|
|
33
|
+
- @working-theory/validation@0.36.0
|
|
34
|
+
|
|
3
35
|
## 0.35.0
|
|
4
36
|
|
|
5
37
|
### Minor Changes
|
|
@@ -18,11 +18,41 @@
|
|
|
18
18
|
* 4. Deleting the membership of the person last shown the key sets
|
|
19
19
|
* `key_issued_to_membership_id` null and keeps the agent.
|
|
20
20
|
* 5. Deleting the creator's user row leaves the agent (no relation).
|
|
21
|
+
* 6. `grants` (#7864 D2, AC-2) defaults to empty, takes any of the five
|
|
22
|
+
* grantable permissions, and the `agents_grants_allowed` CHECK refuses
|
|
23
|
+
* anything else.
|
|
24
|
+
*
|
|
25
|
+
* One case needs no database and always runs: the migration's CHECK names
|
|
26
|
+
* exactly `@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, so the
|
|
27
|
+
* SQL and the TypeScript spelling of the list cannot drift (SEC-7864-G).
|
|
21
28
|
*/
|
|
29
|
+
import { readFileSync } from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
|
|
32
|
+
import { AGENT_GRANTABLE_PERMISSIONS } from "@working-theory/validation";
|
|
22
33
|
import { Client } from "pg";
|
|
23
34
|
|
|
24
35
|
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
25
36
|
|
|
37
|
+
const GRANTS_MIGRATION = join(
|
|
38
|
+
__dirname,
|
|
39
|
+
"..",
|
|
40
|
+
"migrations",
|
|
41
|
+
"20261009120000_agent_grants",
|
|
42
|
+
"migration.sql",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
describe("the agents_grants_allowed CHECK (#7864 D2, AC-2)", () => {
|
|
46
|
+
it("names exactly AGENT_GRANTABLE_PERMISSIONS", () => {
|
|
47
|
+
const sql = readFileSync(GRANTS_MIGRATION, "utf8");
|
|
48
|
+
const check = /CHECK \("grants" <@ ARRAY\[([^\]]*)\]::TEXT\[\]\)/.exec(sql);
|
|
49
|
+
expect(check).not.toBeNull();
|
|
50
|
+
const listed = [...check![1]!.matchAll(/'([^']*)'/g)].map((m) => m[1]);
|
|
51
|
+
expect(new Set(listed)).toEqual(new Set(AGENT_GRANTABLE_PERMISSIONS));
|
|
52
|
+
expect(listed).toHaveLength(AGENT_GRANTABLE_PERMISSIONS.length);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
26
56
|
const AGENT_ID = /^agent_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
27
57
|
|
|
28
58
|
describeDb("the agents table (#3591, live DB)", () => {
|
|
@@ -172,6 +202,41 @@ describeDb("the agents table (#3591, live DB)", () => {
|
|
|
172
202
|
});
|
|
173
203
|
});
|
|
174
204
|
|
|
205
|
+
describe("grants (#7864 D2, AC-2)", () => {
|
|
206
|
+
async function grantsOf(id: string): Promise<string[]> {
|
|
207
|
+
return (
|
|
208
|
+
await db.query<{ grants: string[] }>(
|
|
209
|
+
`SELECT "grants" FROM "agents" WHERE "id" = $1`,
|
|
210
|
+
[id],
|
|
211
|
+
)
|
|
212
|
+
).rows[0]!.grants;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it("a new agent has no grants", async () => {
|
|
216
|
+
expect(await grantsOf(await insertAgent())).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("takes grantable permissions", async () => {
|
|
220
|
+
const id = await insertAgent();
|
|
221
|
+
await db.query(
|
|
222
|
+
`UPDATE "agents" SET "grants" = ARRAY['content-edit','magnets-manage']::TEXT[] WHERE "id" = $1`,
|
|
223
|
+
[id],
|
|
224
|
+
);
|
|
225
|
+
expect(await grantsOf(id)).toEqual(["content-edit", "magnets-manage"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("refuses any other permission", async () => {
|
|
229
|
+
const id = await insertAgent();
|
|
230
|
+
await expect(
|
|
231
|
+
db.query(
|
|
232
|
+
`UPDATE "agents" SET "grants" = ARRAY['content-edit','agents-manage']::TEXT[] WHERE "id" = $1`,
|
|
233
|
+
[id],
|
|
234
|
+
),
|
|
235
|
+
).rejects.toThrow(/agents_grants_allowed/);
|
|
236
|
+
expect(await grantsOf(id)).toEqual([]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
175
240
|
it("a credential digest is unique", async () => {
|
|
176
241
|
const hash = nextHash();
|
|
177
242
|
await insertAgent({ credential_hash: hash });
|