create-ailk 0.14.0 → 0.16.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 +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -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/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- 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/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -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/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- 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 +282 -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-flow-checkout.test.ts +105 -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 +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- 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/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/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -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]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -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/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -28,11 +28,22 @@
|
|
|
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
|
|
|
45
|
+
import { browserApiOrigin } from "./api-origin.generated";
|
|
46
|
+
|
|
36
47
|
type RelayMethod = "GET" | "POST";
|
|
37
48
|
|
|
38
49
|
/** One relayed (method, path) pair. */
|
|
@@ -78,13 +89,197 @@ const BASE_RESPONSE_HEADERS = {
|
|
|
78
89
|
"x-content-type-options": "nosniff",
|
|
79
90
|
} as const;
|
|
80
91
|
|
|
81
|
-
function refusal(
|
|
92
|
+
function refusal(
|
|
93
|
+
status: number,
|
|
94
|
+
code: string,
|
|
95
|
+
message: string,
|
|
96
|
+
extraHeaders?: Record<string, string>,
|
|
97
|
+
): Response {
|
|
82
98
|
return Response.json(
|
|
83
99
|
{ ok: false, error: { code, message } },
|
|
84
|
-
{ status, headers: BASE_RESPONSE_HEADERS },
|
|
100
|
+
{ status, headers: { ...BASE_RESPONSE_HEADERS, ...extraHeaders } },
|
|
85
101
|
);
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The per-visitor budget (#7707, D1-D2): 8 calls per 10 minutes shared across
|
|
106
|
+
* the five visitor routes, 30 per 10 minutes on the session route alone, an
|
|
107
|
+
* in-process store capped at 10,000 entries. No env var tunes these (AC-8).
|
|
108
|
+
*/
|
|
109
|
+
export const RELAY_RATE_LIMIT = Object.freeze({
|
|
110
|
+
limit: 8,
|
|
111
|
+
sessionLimit: 30,
|
|
112
|
+
windowMs: 600_000,
|
|
113
|
+
maxBuckets: 10_000,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
type RateLimitBucket = {
|
|
117
|
+
count: number;
|
|
118
|
+
windowStartMs: number;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Module-scoped store, shared by the visitor (`visitor|<identity>`) and
|
|
122
|
+
// session (`session|<identity>`) budgets (D7: same identity, window and
|
|
123
|
+
// store). Ported from the hub's own limiter
|
|
124
|
+
// (`apps/api/src/middleware/rate-limit.ts`), not imported — `apps/web`
|
|
125
|
+
// cannot import `apps/api`.
|
|
126
|
+
const rateLimitBuckets = new Map<string, RateLimitBucket>();
|
|
127
|
+
let requestsSinceSweep = 0;
|
|
128
|
+
const SWEEP_INTERVAL_REQUESTS = 100;
|
|
129
|
+
|
|
130
|
+
function evictExpiredBuckets(now: number): void {
|
|
131
|
+
for (const [key, bucket] of rateLimitBuckets) {
|
|
132
|
+
if (now - bucket.windowStartMs >= RELAY_RATE_LIMIT.windowMs) {
|
|
133
|
+
rateLimitBuckets.delete(key);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Test-only reset (D8). Not reachable over HTTP. */
|
|
139
|
+
export function __resetRelayRateLimit(): void {
|
|
140
|
+
rateLimitBuckets.clear();
|
|
141
|
+
requestsSinceSweep = 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Test-only accessor for the current distinct-bucket count (D8). */
|
|
145
|
+
export function __relayRateLimitBucketCount(): number {
|
|
146
|
+
return rateLimitBuckets.size;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Count one request against `key`, opening a new window when needed (D6). A
|
|
151
|
+
* genuinely new identity at capacity evicts the single oldest-inserted entry
|
|
152
|
+
* first (`Map` iteration order is insertion order) — the hub's own `hit()`.
|
|
153
|
+
*/
|
|
154
|
+
function hit(key: string, now: number): RateLimitBucket {
|
|
155
|
+
let bucket = rateLimitBuckets.get(key);
|
|
156
|
+
if (!bucket || now - bucket.windowStartMs >= RELAY_RATE_LIMIT.windowMs) {
|
|
157
|
+
if (
|
|
158
|
+
!rateLimitBuckets.has(key) &&
|
|
159
|
+
rateLimitBuckets.size >= RELAY_RATE_LIMIT.maxBuckets
|
|
160
|
+
) {
|
|
161
|
+
const oldestKey = rateLimitBuckets.keys().next().value;
|
|
162
|
+
if (oldestKey !== undefined) rateLimitBuckets.delete(oldestKey);
|
|
163
|
+
}
|
|
164
|
+
bucket = { count: 0, windowStartMs: now };
|
|
165
|
+
rateLimitBuckets.set(key, bucket);
|
|
166
|
+
}
|
|
167
|
+
bucket.count += 1;
|
|
168
|
+
return bucket;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The eight hextets of a valid IPv6 address, or `null`. Ported from the
|
|
173
|
+
* hub's own `expandIpv6` (`apps/api/src/middleware/rate-limit.ts`).
|
|
174
|
+
*/
|
|
175
|
+
function expandIpv6(address: string): number[] | null {
|
|
176
|
+
let text = address;
|
|
177
|
+
const tail: number[] = [];
|
|
178
|
+
const lastColon = text.lastIndexOf(":");
|
|
179
|
+
const last = text.slice(lastColon + 1);
|
|
180
|
+
if (last.includes(".")) {
|
|
181
|
+
const octets = last.split(".").map(Number);
|
|
182
|
+
tail.push(((octets[0] ?? 0) << 8) | (octets[1] ?? 0));
|
|
183
|
+
tail.push(((octets[2] ?? 0) << 8) | (octets[3] ?? 0));
|
|
184
|
+
text = `${text.slice(0, lastColon + 1)}0`;
|
|
185
|
+
}
|
|
186
|
+
const halves = text.split("::");
|
|
187
|
+
const parse = (part: string | undefined): number[] =>
|
|
188
|
+
part ? part.split(":").map((hextet) => parseInt(hextet, 16)) : [];
|
|
189
|
+
const head = parse(halves[0]);
|
|
190
|
+
const rest = parse(halves[1]);
|
|
191
|
+
if (tail.length > 0) {
|
|
192
|
+
(halves.length > 1 ? rest : head).pop();
|
|
193
|
+
}
|
|
194
|
+
const fill = 8 - head.length - rest.length - tail.length;
|
|
195
|
+
if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
|
|
196
|
+
const hextets = [
|
|
197
|
+
...head,
|
|
198
|
+
...new Array<number>(fill).fill(0),
|
|
199
|
+
...rest,
|
|
200
|
+
...tail,
|
|
201
|
+
];
|
|
202
|
+
return hextets.every((h) => Number.isInteger(h) && h >= 0 && h <= 0xffff)
|
|
203
|
+
? hextets
|
|
204
|
+
: null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const MAX_VISITOR_IP_LENGTH = 45;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate and normalise a visitor address (D3): IPv4 as is, an IPv4-mapped
|
|
211
|
+
* IPv6 address as its IPv4 form, any other IPv6 address as its /64 prefix.
|
|
212
|
+
* `null` for anything else. Ported from the hub's own `normaliseVisitorIp`.
|
|
213
|
+
*/
|
|
214
|
+
function normaliseVisitorIp(raw: string): string | null {
|
|
215
|
+
const value = raw.trim().toLowerCase();
|
|
216
|
+
if (value.length > MAX_VISITOR_IP_LENGTH || value.includes("%")) return null;
|
|
217
|
+
const family = isIP(value);
|
|
218
|
+
if (family === 4) return value;
|
|
219
|
+
if (family !== 6) return null;
|
|
220
|
+
const hextets = expandIpv6(value);
|
|
221
|
+
if (!hextets) return null;
|
|
222
|
+
const isV4Mapped =
|
|
223
|
+
hextets.slice(0, 5).every((h) => h === 0) && hextets[5] === 0xffff;
|
|
224
|
+
if (isV4Mapped) {
|
|
225
|
+
const high = hextets[6] ?? 0;
|
|
226
|
+
const low = hextets[7] ?? 0;
|
|
227
|
+
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
|
228
|
+
}
|
|
229
|
+
const prefix = hextets
|
|
230
|
+
.slice(0, 4)
|
|
231
|
+
.map((h) => h.toString(16).padStart(4, "0"))
|
|
232
|
+
.join(":");
|
|
233
|
+
return `${prefix}::/64`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The visitor's identity (D3): the first comma-separated value of
|
|
238
|
+
* `x-forwarded-for` when present (a present-but-empty value does not fall
|
|
239
|
+
* back to `x-real-ip`), else `x-real-ip`. Anything that does not normalise —
|
|
240
|
+
* no header, an empty value, or one `normaliseVisitorIp` rejects — shares the
|
|
241
|
+
* single identity `"unknown"`, never a fresh bucket of its own.
|
|
242
|
+
*/
|
|
243
|
+
function visitorIdentity(request: Request): string {
|
|
244
|
+
const raw =
|
|
245
|
+
request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip");
|
|
246
|
+
const first = raw?.split(",")[0]?.trim().toLowerCase();
|
|
247
|
+
if (!first) return "unknown";
|
|
248
|
+
return normaliseVisitorIp(first) ?? "unknown";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Count one request against its budget (D1-D7): the visitor budget for every
|
|
253
|
+
* entry but the session route, which gets its own, wider, independent
|
|
254
|
+
* budget. Returns whether the call is admitted and the whole seconds (at
|
|
255
|
+
* least 1) until the tripped bucket's window ends.
|
|
256
|
+
*/
|
|
257
|
+
function checkRateLimit(
|
|
258
|
+
request: Request,
|
|
259
|
+
entry: RelayRoute,
|
|
260
|
+
now: number,
|
|
261
|
+
): { allowed: boolean; retryAfterSeconds: number } {
|
|
262
|
+
requestsSinceSweep += 1;
|
|
263
|
+
if (requestsSinceSweep >= SWEEP_INTERVAL_REQUESTS) {
|
|
264
|
+
evictExpiredBuckets(now);
|
|
265
|
+
requestsSinceSweep = 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const identity = visitorIdentity(request);
|
|
269
|
+
const isSession = entry === SESSION_ROUTE;
|
|
270
|
+
const key = `${isSession ? "session" : "visitor"}|${identity}`;
|
|
271
|
+
const limit = isSession
|
|
272
|
+
? RELAY_RATE_LIMIT.sessionLimit
|
|
273
|
+
: RELAY_RATE_LIMIT.limit;
|
|
274
|
+
|
|
275
|
+
const bucket = hit(key, now);
|
|
276
|
+
const retryAfterSeconds = Math.max(
|
|
277
|
+
1,
|
|
278
|
+
Math.ceil((bucket.windowStartMs + RELAY_RATE_LIMIT.windowMs - now) / 1000),
|
|
279
|
+
);
|
|
280
|
+
return { allowed: bucket.count <= limit, retryAfterSeconds };
|
|
281
|
+
}
|
|
282
|
+
|
|
88
283
|
/**
|
|
89
284
|
* True when the site has an API address to forward to. `getApiBase()` falls
|
|
90
285
|
* back to `http://localhost:3001` for local development; a production site
|
|
@@ -94,7 +289,7 @@ function hubConfigured(): boolean {
|
|
|
94
289
|
if (process.env.NODE_ENV !== "production") return true;
|
|
95
290
|
return Boolean(
|
|
96
291
|
process.env.AILK_API_URL ||
|
|
97
|
-
|
|
292
|
+
browserApiOrigin() ||
|
|
98
293
|
process.env.NEXT_PUBLIC_API_URL,
|
|
99
294
|
);
|
|
100
295
|
}
|
|
@@ -220,6 +415,17 @@ export async function handleRelay(request: Request): Promise<Response> {
|
|
|
220
415
|
return refusal(403, "cross_site", "Cross-site request refused");
|
|
221
416
|
}
|
|
222
417
|
|
|
418
|
+
const { allowed, retryAfterSeconds } = checkRateLimit(
|
|
419
|
+
request,
|
|
420
|
+
entry,
|
|
421
|
+
Date.now(),
|
|
422
|
+
);
|
|
423
|
+
if (!allowed) {
|
|
424
|
+
return refusal(429, "rate_limited", "Too many requests", {
|
|
425
|
+
"retry-after": String(retryAfterSeconds),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
223
429
|
let body: string | undefined;
|
|
224
430
|
if (entry.method === "POST") {
|
|
225
431
|
if (!isJson(request.headers.get("content-type"))) {
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
|
|
25
25
|
import { cookies, headers } from "next/headers";
|
|
26
26
|
|
|
27
|
+
import { hubSessionInit } from "./hub-session/session";
|
|
28
|
+
import { SIGN_IN_PROVIDER } from "./topology.generated";
|
|
29
|
+
|
|
27
30
|
/**
|
|
28
31
|
* Structural mirror of `@working-theory/api-client`'s `ApiResult<T>`
|
|
29
32
|
* (`packages/api-client/src/http.ts`) — not imported, because that type is
|
|
@@ -75,10 +78,17 @@ interface ErrorBody {
|
|
|
75
78
|
* pass the check exactly as a browser-originated one would. RSC `GET`s are
|
|
76
79
|
* unaffected: `isOriginAllowed` short-circuits `true` for safe methods before
|
|
77
80
|
* it ever inspects the header.
|
|
81
|
+
*
|
|
82
|
+
* On a spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
|
|
83
|
+
* #7894 D12) this is the ONE request-init re-target: it returns the hub
|
|
84
|
+
* credential as `Authorization: Bearer` (`hubSessionInit`), and no header at
|
|
85
|
+
* all signed out — the hub has no cookie session for this site's visitor.
|
|
86
|
+
* Every caller passes the result straight to the api-client unchanged.
|
|
78
87
|
*/
|
|
79
88
|
export async function forwardedCookieInit(): Promise<{
|
|
80
|
-
headers:
|
|
89
|
+
headers: Record<string, string>;
|
|
81
90
|
}> {
|
|
91
|
+
if (SIGN_IN_PROVIDER === "hub") return hubSessionInit();
|
|
82
92
|
const cookieHeader = (await cookies()).toString();
|
|
83
93
|
const origin = (await headers()).get("origin");
|
|
84
94
|
return { headers: { cookie: cookieHeader, ...(origin ? { origin } : {}) } };
|
|
@@ -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
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// - chromeStyle: "minimal" → the deck's closing slide is the wordmark,
|
|
22
22
|
// contact lines and one legal line; the consent bar is a hairline bar on
|
|
23
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.
|
|
24
26
|
|
|
25
27
|
export interface SiteChrome {
|
|
26
28
|
nav: "full" | "mark-only";
|
|
@@ -31,6 +33,7 @@ export interface SiteChrome {
|
|
|
31
33
|
deckPresentation?: true;
|
|
32
34
|
colorMode?: "light";
|
|
33
35
|
chromeStyle?: "minimal";
|
|
36
|
+
closingFooterPaths?: readonly string[];
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export const siteChrome: SiteChrome = {
|
|
@@ -0,0 +1,33 @@
|
|
|
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 database of its own: by default it
|
|
6
|
+
// has no sign-in, so its render reads no session and imports nothing; with
|
|
7
|
+
// `--hub-sign-in` (#7894) it reads the sealed hub-session cookie.
|
|
8
|
+
|
|
9
|
+
export const TOPOLOGY: "hub" | "spoke" = "hub";
|
|
10
|
+
export const SIGN_IN_ENABLED: boolean = true;
|
|
11
|
+
export const SIGN_IN_PROVIDER: "site" | "hub" | "none" = "site";
|
|
12
|
+
export const SIGN_IN_PATH: string | null = "/sign-in";
|
|
13
|
+
export const SIGN_UP_PATH: string | null = "/sign-up";
|
|
14
|
+
export const SIGN_OUT_ENDPOINT: string | null = null;
|
|
15
|
+
|
|
16
|
+
export type SiteSession = {
|
|
17
|
+
user: {
|
|
18
|
+
name?: string | null;
|
|
19
|
+
email: string;
|
|
20
|
+
emailVerified: boolean;
|
|
21
|
+
createdAt?: Date | string | null;
|
|
22
|
+
};
|
|
23
|
+
} | null;
|
|
24
|
+
|
|
25
|
+
export async function readSession(headers: Headers): Promise<SiteSession> {
|
|
26
|
+
const { auth } = await import("../auth");
|
|
27
|
+
return auth.api.getSession({ headers });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function registerSiteStores(): Promise<void> {
|
|
31
|
+
const { registerLicenseKeyStore } = await import("./license-key-store");
|
|
32
|
+
await registerLicenseKeyStore();
|
|
33
|
+
}
|
|
@@ -303,6 +303,16 @@
|
|
|
303
303
|
"ctaLabel": "Back to services"
|
|
304
304
|
}
|
|
305
305
|
},
|
|
306
|
+
"checkoutThankYou": {
|
|
307
|
+
"title": "Thank you",
|
|
308
|
+
"body": "Your payment went through. We'll email you a receipt and the next steps."
|
|
309
|
+
},
|
|
310
|
+
"checkoutUnavailable": {
|
|
311
|
+
"title": "Checkout isn't available right now",
|
|
312
|
+
"body": "Nothing was charged. Please try again in a few minutes.",
|
|
313
|
+
"ctaHeadline": "Need a hand?",
|
|
314
|
+
"ctaLabel": "Contact us"
|
|
315
|
+
},
|
|
306
316
|
"resources": {
|
|
307
317
|
"heading": "Resources",
|
|
308
318
|
"emptyState": "Resources listing coming soon."
|
|
@@ -649,7 +659,8 @@
|
|
|
649
659
|
"unsaved": "Unsaved changes",
|
|
650
660
|
"clean": "No unsaved changes",
|
|
651
661
|
"nameRequired": "Enter your name.",
|
|
652
|
-
"saveError": "We couldn't save your name. Try again."
|
|
662
|
+
"saveError": "We couldn't save your name. Try again.",
|
|
663
|
+
"manageOnHub": "Manage your account on our main site"
|
|
653
664
|
},
|
|
654
665
|
"security": {
|
|
655
666
|
"title": "Security",
|
|
@@ -660,7 +671,8 @@
|
|
|
660
671
|
"magicLink": "Email sign-in link",
|
|
661
672
|
"password": "Password",
|
|
662
673
|
"available": "Available",
|
|
663
|
-
"none": "No sign-in method is configured."
|
|
674
|
+
"none": "No sign-in method is configured.",
|
|
675
|
+
"viaHub": "You sign in through our main site. Your sign-in methods are managed there."
|
|
664
676
|
},
|
|
665
677
|
"notifications": {
|
|
666
678
|
"title": "Notifications",
|
|
@@ -696,7 +708,10 @@
|
|
|
696
708
|
"emptyHeadline": "Sign-in isn't available yet",
|
|
697
709
|
"emptyDescription": "This site hasn't turned on sign-in for members yet. Check back soon, or contact the site owner.",
|
|
698
710
|
"signInLink": "Go to the sign-in page",
|
|
699
|
-
"homeLink": "Back to the site"
|
|
711
|
+
"homeLink": "Back to the site",
|
|
712
|
+
"hubHeadline": "Sign in to your account",
|
|
713
|
+
"hubDescription": "You sign in on our main site, then come straight back here.",
|
|
714
|
+
"hubSignIn": "Sign in"
|
|
700
715
|
},
|
|
701
716
|
"profile": {
|
|
702
717
|
"email": "Email",
|
|
@@ -718,6 +733,20 @@
|
|
|
718
733
|
"period": "Period",
|
|
719
734
|
"date": "Date"
|
|
720
735
|
},
|
|
736
|
+
"pieces": {
|
|
737
|
+
"heading": "My pieces",
|
|
738
|
+
"type": "Piece",
|
|
739
|
+
"stage": "Stage",
|
|
740
|
+
"createdAt": "Started",
|
|
741
|
+
"updatedAt": "Updated",
|
|
742
|
+
"needsClient": "This piece is waiting on you."
|
|
743
|
+
},
|
|
744
|
+
"messages": {
|
|
745
|
+
"heading": "My messages",
|
|
746
|
+
"sentAt": "Sent",
|
|
747
|
+
"file": "File",
|
|
748
|
+
"download": "Download {filename}"
|
|
749
|
+
},
|
|
721
750
|
"subscription": {
|
|
722
751
|
"headline": "Your payment didn't go through",
|
|
723
752
|
"action": "Update payment method"
|
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# @working-theory/database
|
|
2
2
|
|
|
3
|
+
## 0.38.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#8037](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8037) [`89eba8c`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/89eba8c2bab16766a68284f52c872aae86f59fde) Thanks [@samuelhenry](https://github.com/samuelhenry)! - The member area contract ([#7877](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7877)): a client's pieces, plan and messages.
|
|
8
|
+
|
|
9
|
+
- `@working-theory/database` — `Piece` and `Interaction` on Person (a composite `(person_id, workspace_id)` foreign key to `people`) and `WorkspacePieceStages`, a workspace's declared piece stages; migration `20261011120000_member_area`.
|
|
10
|
+
- `@working-theory/ids` — the `piece_`, `intx_` and `pstages_` prefixes; `zPieceId` and `zInteractionId`.
|
|
11
|
+
- `@working-theory/validation` — the member-area wire schemas (`pieces.ts`, `member.ts`), the Person timeline's `message` kind, the `people-write` permission (owner, admin; not agent-grantable) and the attachment helpers (`ATTACHABLE_CONTENT_TYPES`, `attachmentFilename`).
|
|
12
|
+
- `@working-theory/api-client` — `member.pieces`, `member.messages` and `member.messageFile` (with `getRaw`), the reads a spoke's server makes for its signed-in client. A spoke that re-serves a file sends its five response headers unchanged.
|
|
13
|
+
- `create-ailk` — the member-area files are co-owned by the `workspace` and `multi-tenant` slices.
|
|
14
|
+
|
|
15
|
+
- [#8049](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8049) [`6a2a120`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6a2a120765c423303716d5e3ebf216c9c10e118b) Thanks [@samuelhenry](https://github.com/samuelhenry)! - An agent key may be granted `member-pieces-manage` ([#7878](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7878)).
|
|
16
|
+
|
|
17
|
+
`@working-theory/validation`: the `member-pieces-manage` permission (owner, admin), the seventh entry of `AGENT_GRANTABLE_PERMISSIONS`. The member area's operator writes — a client's pieces, plan and outbound messages — check it through `allows`, so an agent granted it keeps them in its one workspace; `people-write` stays never-grantable. `@working-theory/database`: the `agents_grants_allowed` CHECK widened to admit it (additive).
|
|
18
|
+
|
|
19
|
+
- [#8004](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8004) [`d9ba65f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d9ba65f6334681b17bd77b64bb7ca9e069b1980f) Thanks [@claudio-planck](https://github.com/claudio-planck)! - Person — the per-workspace client record ([#852](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/852)).
|
|
20
|
+
|
|
21
|
+
`@working-theory/database`: a `Person` model (one client per normalized email per workspace), `personId` on `Lead` and `Appointment`, the schema and set-based backfill migrations, the post-cutover re-run script `scripts/person-backfill.sql`, the agent-grant CHECK widened to admit `people-read`, and the timeline's email-fallback SQL constants on the `tenancy` subpath. `@working-theory/ids`: the `person_` prefix and `zPersonId`. `@working-theory/validation`: the `people-read` permission (owner, admin; agent-grantable) and the People wire schemas. `@working-theory/api-client`: `people.list` and `people.get` for an agent key granted `people-read`.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [[`89eba8c`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/89eba8c2bab16766a68284f52c872aae86f59fde), [`6a2a120`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6a2a120765c423303716d5e3ebf216c9c10e118b), [`6d38b1a`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6d38b1a5362b36e56d6bce79fdf96c878020cb18), [`d9ba65f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d9ba65f6334681b17bd77b64bb7ca9e069b1980f)]:
|
|
26
|
+
- @working-theory/validation@0.38.0
|
|
27
|
+
- @working-theory/ids@0.11.0
|
|
28
|
+
|
|
29
|
+
## 0.37.0
|
|
30
|
+
|
|
31
|
+
### Minor Changes
|
|
32
|
+
|
|
33
|
+
- [#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.
|
|
34
|
+
|
|
35
|
+
- `@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.
|
|
36
|
+
|
|
37
|
+
- [#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
|
|
38
|
+
permissions — content-edit, content-publish, assets-write, magnets-manage and
|
|
39
|
+
meeting-types-manage. `@working-theory/validation` adds
|
|
40
|
+
`AGENT_GRANTABLE_PERMISSIONS`, `isAgentGrantable`, `allows` and
|
|
41
|
+
`setAgentGrantsBodySchema`, and the agent wire row gains `grants`;
|
|
42
|
+
`@working-theory/database` adds the `agents.grants` column, held to those five
|
|
43
|
+
by a CHECK. An agent with no grants behaves exactly as before.
|
|
44
|
+
|
|
45
|
+
- [#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.
|
|
46
|
+
|
|
47
|
+
- `@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.
|
|
48
|
+
|
|
49
|
+
### Patch Changes
|
|
50
|
+
|
|
51
|
+
- 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)]:
|
|
52
|
+
- @working-theory/validation@0.37.0
|
|
53
|
+
|
|
3
54
|
## 0.36.0
|
|
4
55
|
|
|
5
56
|
### Patch Changes
|
|
@@ -18,11 +18,43 @@
|
|
|
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 seven
|
|
22
|
+
* grantable permissions (`people-read` since #852 D9,
|
|
23
|
+
* `member-pieces-manage` since #7878), and the `agents_grants_allowed`
|
|
24
|
+
* CHECK refuses anything else.
|
|
25
|
+
*
|
|
26
|
+
* One case needs no database and always runs: the latest migration's CHECK names
|
|
27
|
+
* exactly `@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, so the
|
|
28
|
+
* SQL and the TypeScript spelling of the list cannot drift (SEC-7864-G).
|
|
21
29
|
*/
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
import { AGENT_GRANTABLE_PERMISSIONS } from "@working-theory/validation";
|
|
22
34
|
import { Client } from "pg";
|
|
23
35
|
|
|
24
36
|
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
25
37
|
|
|
38
|
+
// #7878 — the CHECK as it stands now: the latest migration that re-adds it.
|
|
39
|
+
const GRANTS_MIGRATION = join(
|
|
40
|
+
__dirname,
|
|
41
|
+
"..",
|
|
42
|
+
"migrations",
|
|
43
|
+
"20261011130000_agent_grants_member_pieces_manage",
|
|
44
|
+
"migration.sql",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
describe("the agents_grants_allowed CHECK (#7864 D2, AC-2)", () => {
|
|
48
|
+
it("names exactly AGENT_GRANTABLE_PERMISSIONS", () => {
|
|
49
|
+
const sql = readFileSync(GRANTS_MIGRATION, "utf8");
|
|
50
|
+
const check = /CHECK \("grants" <@ ARRAY\[([^\]]*)\]::TEXT\[\]\)/.exec(sql);
|
|
51
|
+
expect(check).not.toBeNull();
|
|
52
|
+
const listed = [...check![1]!.matchAll(/'([^']*)'/g)].map((m) => m[1]);
|
|
53
|
+
expect(new Set(listed)).toEqual(new Set(AGENT_GRANTABLE_PERMISSIONS));
|
|
54
|
+
expect(listed).toHaveLength(AGENT_GRANTABLE_PERMISSIONS.length);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
26
58
|
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
59
|
|
|
28
60
|
describeDb("the agents table (#3591, live DB)", () => {
|
|
@@ -172,6 +204,59 @@ describeDb("the agents table (#3591, live DB)", () => {
|
|
|
172
204
|
});
|
|
173
205
|
});
|
|
174
206
|
|
|
207
|
+
describe("grants (#7864 D2, AC-2)", () => {
|
|
208
|
+
async function grantsOf(id: string): Promise<string[]> {
|
|
209
|
+
return (
|
|
210
|
+
await db.query<{ grants: string[] }>(
|
|
211
|
+
`SELECT "grants" FROM "agents" WHERE "id" = $1`,
|
|
212
|
+
[id],
|
|
213
|
+
)
|
|
214
|
+
).rows[0]!.grants;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it("a new agent has no grants", async () => {
|
|
218
|
+
expect(await grantsOf(await insertAgent())).toEqual([]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("takes grantable permissions", async () => {
|
|
222
|
+
const id = await insertAgent();
|
|
223
|
+
await db.query(
|
|
224
|
+
`UPDATE "agents" SET "grants" = ARRAY['content-edit','magnets-manage']::TEXT[] WHERE "id" = $1`,
|
|
225
|
+
[id],
|
|
226
|
+
);
|
|
227
|
+
expect(await grantsOf(id)).toEqual(["content-edit", "magnets-manage"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("takes people-read (#852 D9)", async () => {
|
|
231
|
+
const id = await insertAgent();
|
|
232
|
+
await db.query(
|
|
233
|
+
`UPDATE "agents" SET "grants" = ARRAY['people-read']::TEXT[] WHERE "id" = $1`,
|
|
234
|
+
[id],
|
|
235
|
+
);
|
|
236
|
+
expect(await grantsOf(id)).toEqual(["people-read"]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("takes member-pieces-manage (#7878)", async () => {
|
|
240
|
+
const id = await insertAgent();
|
|
241
|
+
await db.query(
|
|
242
|
+
`UPDATE "agents" SET "grants" = ARRAY['member-pieces-manage','people-read']::TEXT[] WHERE "id" = $1`,
|
|
243
|
+
[id],
|
|
244
|
+
);
|
|
245
|
+
expect(await grantsOf(id)).toEqual(["member-pieces-manage", "people-read"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("refuses any other permission", async () => {
|
|
249
|
+
const id = await insertAgent();
|
|
250
|
+
await expect(
|
|
251
|
+
db.query(
|
|
252
|
+
`UPDATE "agents" SET "grants" = ARRAY['content-edit','agents-manage']::TEXT[] WHERE "id" = $1`,
|
|
253
|
+
[id],
|
|
254
|
+
),
|
|
255
|
+
).rejects.toThrow(/agents_grants_allowed/);
|
|
256
|
+
expect(await grantsOf(id)).toEqual([]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
175
260
|
it("a credential digest is unique", async () => {
|
|
176
261
|
const hash = nextHash();
|
|
177
262
|
await insertAgent({ credential_hash: hash });
|