create-ailk 0.12.0 → 0.13.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/dist/module-architecture.js +27 -0
- package/package.json +3 -3
- package/templates/.env.example +23 -9
- package/templates/apps/api/CLAUDE.md +21 -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/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/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +25 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/__tests__/build.test.ts +131 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +3 -3
- package/versions.json +34 -34
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The same-origin relay (#7644, spec docs/specs/7644-the-browser-talks-only-to-spec.md).
|
|
3
|
+
*
|
|
4
|
+
* A visitor's browser posts lead capture, newsletter signup, flow submits and
|
|
5
|
+
* booking calls to THIS site's own address — `resolveSubmitUrl` leaves a
|
|
6
|
+
* root-relative action alone in a build of this app (next.config.mjs sets
|
|
7
|
+
* `NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY`). `app/v1/[...path]/route.ts` hands
|
|
8
|
+
* every `/v1/...` request here, and this module forwards it server-side to
|
|
9
|
+
* the hub through `@working-theory/api-client`'s `relayToApi`, which attaches
|
|
10
|
+
* the site's own `AILK_SPOKE_KEY` when it has one. A browser never holds the
|
|
11
|
+
* key and never calls the hub, so a hub can refuse callers it does not know
|
|
12
|
+
* (#7633) without taking any site's forms down. A single-site install takes
|
|
13
|
+
* the same path with no key.
|
|
14
|
+
*
|
|
15
|
+
* What it guarantees:
|
|
16
|
+
*
|
|
17
|
+
* - ONLY THE LIST (D2). `RELAY_ROUTES` is the fixed set of (method, path)
|
|
18
|
+
* pairs a browser form calls; an exact, case-sensitive match on the
|
|
19
|
+
* pathname or a 404 with no hub call. Adding a route is a reviewed change
|
|
20
|
+
* to this list, its test and apps/web/.env.example.
|
|
21
|
+
* - NO CROSS-SITE POST (D4). `Sec-Fetch-Site` other than `same-origin`, or
|
|
22
|
+
* an `Origin` whose host is not this site's own, is refused with 403
|
|
23
|
+
* before the hub is called. A POST must be JSON, which no cross-origin
|
|
24
|
+
* browser can send without a preflight this route never answers.
|
|
25
|
+
* - THE VISITOR, UNCHANGED (D3). `x-forwarded-for` as received (else
|
|
26
|
+
* `x-real-ip`) and `user-agent` reach the hub, so per-visitor rate limits
|
|
27
|
+
* still count each visitor (#7615's rule; `flow.actions.ts`).
|
|
28
|
+
* - AN ALLOW-LIST OF HEADERS (D3). Nothing else from the incoming request is
|
|
29
|
+
* forwarded: no `authorization`, no client-sent `x-ailk-*`, no `host`, and
|
|
30
|
+
* a `cookie` only on the one session route.
|
|
31
|
+
*
|
|
32
|
+
* NO SECRET IS LOGGED. A failure logs `{ path, outcome }` and nothing else.
|
|
33
|
+
*/
|
|
34
|
+
import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
|
|
35
|
+
|
|
36
|
+
type RelayMethod = "GET" | "POST";
|
|
37
|
+
|
|
38
|
+
/** One relayed (method, path) pair. */
|
|
39
|
+
export interface RelayRoute {
|
|
40
|
+
readonly method: RelayMethod;
|
|
41
|
+
readonly path: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function route(method: RelayMethod, path: string): RelayRoute {
|
|
45
|
+
return Object.freeze({ method, path });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The fixed list (D2). The scheduling and commerce paths are plain strings:
|
|
50
|
+
* on a hub without those modules the hub's own 404 passes through.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* The one session route: a site-admin, cookie-authenticated write. It is
|
|
54
|
+
* forwarded with the visitor's `cookie` and `Origin` (the hub still checks
|
|
55
|
+
* that `Origin` against its trusted list), mirroring `forwardedCookieInit` in
|
|
56
|
+
* lib/server-api.ts. Every other entry is a visitor route.
|
|
57
|
+
*/
|
|
58
|
+
const SESSION_ROUTE = route("POST", "/v1/commerce/invoices");
|
|
59
|
+
|
|
60
|
+
export const RELAY_ROUTES: readonly RelayRoute[] = Object.freeze([
|
|
61
|
+
route("POST", "/v1/leads/submit"),
|
|
62
|
+
route("POST", "/v1/leads/subscribe"),
|
|
63
|
+
route("POST", "/v1/leads/callback"),
|
|
64
|
+
route("GET", "/v1/schedule/availability"),
|
|
65
|
+
route("POST", "/v1/schedule/book"),
|
|
66
|
+
SESSION_ROUTE,
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** Fastify's default `bodyLimit`; the hub sets none (NFR4). */
|
|
70
|
+
const RELAY_BODY_LIMIT = 1_048_576;
|
|
71
|
+
|
|
72
|
+
/** Set on every forwarded call; an incoming request carrying it is a loop. */
|
|
73
|
+
const RELAYED_HEADER = "x-ailk-relayed";
|
|
74
|
+
|
|
75
|
+
/** Set on every answer: never cached, never content-sniffed by the browser. */
|
|
76
|
+
const BASE_RESPONSE_HEADERS = {
|
|
77
|
+
"cache-control": "no-store",
|
|
78
|
+
"x-content-type-options": "nosniff",
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
function refusal(status: number, code: string, message: string): Response {
|
|
82
|
+
return Response.json(
|
|
83
|
+
{ ok: false, error: { code, message } },
|
|
84
|
+
{ status, headers: BASE_RESPONSE_HEADERS },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* True when the site has an API address to forward to. `getApiBase()` falls
|
|
90
|
+
* back to `http://localhost:3001` for local development; a production site
|
|
91
|
+
* with no address set must not send its visitors' calls (and its key) there.
|
|
92
|
+
*/
|
|
93
|
+
function hubConfigured(): boolean {
|
|
94
|
+
if (process.env.NODE_ENV !== "production") return true;
|
|
95
|
+
return Boolean(
|
|
96
|
+
process.env.AILK_API_URL ||
|
|
97
|
+
process.env.NEXT_PUBLIC_AILK_API_URL ||
|
|
98
|
+
process.env.NEXT_PUBLIC_API_URL,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The host the browser addressed: the first `x-forwarded-host`, else `host`.
|
|
104
|
+
* The site's edge must overwrite `x-forwarded-host`, never pass a client's on.
|
|
105
|
+
*/
|
|
106
|
+
function ownHost(request: Request): string {
|
|
107
|
+
const forwarded = request.headers.get("x-forwarded-host")?.split(",")[0];
|
|
108
|
+
if (forwarded) return forwarded.trim();
|
|
109
|
+
return request.headers.get("host") ?? new URL(request.url).host;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* True when the request is a cross-site browser call (D4 step 3). A request
|
|
114
|
+
* with neither `Sec-Fetch-Site` nor `Origin` is a non-browser caller and is
|
|
115
|
+
* admitted: it can forge either header anyway.
|
|
116
|
+
*/
|
|
117
|
+
function isCrossSite(request: Request): boolean {
|
|
118
|
+
const fetchSite = request.headers.get("sec-fetch-site");
|
|
119
|
+
if (fetchSite !== null && fetchSite !== "same-origin") return true;
|
|
120
|
+
|
|
121
|
+
const origin = request.headers.get("origin");
|
|
122
|
+
if (origin === null) return false;
|
|
123
|
+
try {
|
|
124
|
+
const originUrl = new URL(origin);
|
|
125
|
+
// Parsing the own host under the Origin's scheme normalises a default
|
|
126
|
+
// port and letter case, so `spoke.example:443` equals `spoke.example`.
|
|
127
|
+
const own = new URL(`${originUrl.protocol}//${ownHost(request)}`);
|
|
128
|
+
return originUrl.host !== own.host;
|
|
129
|
+
} catch {
|
|
130
|
+
// `null`, or anything that is not a URL.
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isJson(contentType: string | null): boolean {
|
|
136
|
+
return (
|
|
137
|
+
contentType?.split(";")[0]?.trim().toLowerCase() === "application/json"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The body as text, or `null` once it passes `limit` bytes. Read as a stream
|
|
143
|
+
* so an oversized body is refused without buffering all of it.
|
|
144
|
+
*/
|
|
145
|
+
async function readCapped(
|
|
146
|
+
request: Request,
|
|
147
|
+
limit: number,
|
|
148
|
+
): Promise<string | null> {
|
|
149
|
+
const declared = Number(request.headers.get("content-length"));
|
|
150
|
+
if (Number.isFinite(declared) && declared > limit) return null;
|
|
151
|
+
if (!request.body) return "";
|
|
152
|
+
|
|
153
|
+
const reader = request.body.getReader();
|
|
154
|
+
const chunks: Uint8Array[] = [];
|
|
155
|
+
let size = 0;
|
|
156
|
+
for (;;) {
|
|
157
|
+
const { done, value } = await reader.read();
|
|
158
|
+
if (done) break;
|
|
159
|
+
size += value.byteLength;
|
|
160
|
+
if (size > limit) {
|
|
161
|
+
await reader.cancel();
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
chunks.push(value);
|
|
165
|
+
}
|
|
166
|
+
const bytes = new Uint8Array(size);
|
|
167
|
+
let offset = 0;
|
|
168
|
+
for (const chunk of chunks) {
|
|
169
|
+
bytes.set(chunk, offset);
|
|
170
|
+
offset += chunk.byteLength;
|
|
171
|
+
}
|
|
172
|
+
return new TextDecoder().decode(bytes);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The outbound headers, built from an allow-list (D3). Never a spread of the incoming set. */
|
|
176
|
+
function relayHeaders(
|
|
177
|
+
request: Request,
|
|
178
|
+
entry: RelayRoute,
|
|
179
|
+
): Record<string, string> {
|
|
180
|
+
const incoming = request.headers;
|
|
181
|
+
const out: Record<string, string> = { [RELAYED_HEADER]: "1" };
|
|
182
|
+
|
|
183
|
+
const contentType = incoming.get("content-type");
|
|
184
|
+
if (entry.method === "POST" && contentType) out["content-type"] = contentType;
|
|
185
|
+
|
|
186
|
+
const userAgent = incoming.get("user-agent");
|
|
187
|
+
if (userAgent) out["user-agent"] = userAgent;
|
|
188
|
+
|
|
189
|
+
const visitorIp =
|
|
190
|
+
incoming.get("x-forwarded-for") ?? incoming.get("x-real-ip");
|
|
191
|
+
if (visitorIp) out["x-forwarded-for"] = visitorIp;
|
|
192
|
+
|
|
193
|
+
if (entry === SESSION_ROUTE) {
|
|
194
|
+
const cookie = incoming.get("cookie");
|
|
195
|
+
if (cookie) out.cookie = cookie;
|
|
196
|
+
const origin = incoming.get("origin");
|
|
197
|
+
if (origin) out.origin = origin;
|
|
198
|
+
} else {
|
|
199
|
+
Object.assign(
|
|
200
|
+
out,
|
|
201
|
+
visitorForwardHeaders(incoming, process.env.AILK_VISITOR_FORWARD_KEY),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Answer one `/v1/...` request to this site: refuse it, or forward it to the hub. */
|
|
208
|
+
export async function handleRelay(request: Request): Promise<Response> {
|
|
209
|
+
if (request.headers.has(RELAYED_HEADER)) {
|
|
210
|
+
return refusal(508, "relay_loop", "Request already relayed");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const url = new URL(request.url);
|
|
214
|
+
const entry = RELAY_ROUTES.find(
|
|
215
|
+
(r) => r.method === request.method && r.path === url.pathname,
|
|
216
|
+
);
|
|
217
|
+
if (!entry) return refusal(404, "not_found", "Not found");
|
|
218
|
+
|
|
219
|
+
if (isCrossSite(request)) {
|
|
220
|
+
return refusal(403, "cross_site", "Cross-site request refused");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let body: string | undefined;
|
|
224
|
+
if (entry.method === "POST") {
|
|
225
|
+
if (!isJson(request.headers.get("content-type"))) {
|
|
226
|
+
return refusal(415, "unsupported_media_type", "Expected application/json");
|
|
227
|
+
}
|
|
228
|
+
const text = await readCapped(request, RELAY_BODY_LIMIT);
|
|
229
|
+
if (text === null) {
|
|
230
|
+
return refusal(413, "payload_too_large", "Request body too large");
|
|
231
|
+
}
|
|
232
|
+
body = text;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!hubConfigured()) {
|
|
236
|
+
console.error("[relay] hub call failed", {
|
|
237
|
+
path: entry.path,
|
|
238
|
+
outcome: "no-api-address",
|
|
239
|
+
});
|
|
240
|
+
return refusal(502, "relay_unreachable", "The site could not reach its API");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const result = await relayToApi(
|
|
245
|
+
entry.method === "GET" ? `${entry.path}${url.search}` : entry.path,
|
|
246
|
+
{
|
|
247
|
+
method: entry.method,
|
|
248
|
+
headers: relayHeaders(request, entry),
|
|
249
|
+
...(body !== undefined ? { body } : {}),
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
// Only a JSON answer keeps its content-type; anything else (an HTML error
|
|
253
|
+
// page from a proxy in front of the hub) is served as plain text, so it
|
|
254
|
+
// can never render as markup on this site's origin.
|
|
255
|
+
const headers: Record<string, string> = {
|
|
256
|
+
...BASE_RESPONSE_HEADERS,
|
|
257
|
+
"content-type":
|
|
258
|
+
result.contentType && isJson(result.contentType)
|
|
259
|
+
? result.contentType
|
|
260
|
+
: "text/plain; charset=utf-8",
|
|
261
|
+
};
|
|
262
|
+
if (result.retryAfter) headers["retry-after"] = result.retryAfter;
|
|
263
|
+
// A null-body status (204, 304) cannot carry even an empty string.
|
|
264
|
+
return new Response(result.body === "" ? null : result.body, {
|
|
265
|
+
status: result.status,
|
|
266
|
+
headers,
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
// The error's name only (TypeError for a refused redirect or a network
|
|
270
|
+
// failure, TimeoutError for a hub that never answered) — never its
|
|
271
|
+
// message, which can quote a request detail.
|
|
272
|
+
console.error("[relay] hub call failed", {
|
|
273
|
+
path: entry.path,
|
|
274
|
+
outcome: "unreachable",
|
|
275
|
+
error: err instanceof Error ? err.name : "unknown",
|
|
276
|
+
});
|
|
277
|
+
return refusal(502, "relay_unreachable", "The site could not reach its API");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// Locale list: read once at module load from project.yaml via readLocales() (S1).
|
|
13
13
|
// Static assets and internal Next.js paths are excluded via the matcher below.
|
|
14
14
|
//
|
|
15
|
-
// /api
|
|
16
|
-
// next-intl — whose as-needed
|
|
17
|
-
//
|
|
15
|
+
// /api/*, /og and /v1/* are route handlers outside app/[locale] (#6864; /v1
|
|
16
|
+
// is the same-origin relay, #7644). They skip next-intl — whose as-needed
|
|
17
|
+
// rewrite would send them to /en/api/…, /en/og and /en/v1/…, which do not
|
|
18
|
+
// exist — but still get the security headers. They are exempted
|
|
18
19
|
// here rather than in the matcher, because this middleware is the only place
|
|
19
20
|
// the security headers are stamped.
|
|
20
21
|
//
|
|
@@ -57,7 +58,7 @@ const intlMiddleware = createMiddleware({
|
|
|
57
58
|
|
|
58
59
|
// Top-level app/ route directories that live outside [locale]. Kept in step
|
|
59
60
|
// with app/ by the drift test in __tests__/i18n/middleware.test.ts.
|
|
60
|
-
export const NON_LOCALE_ROUTE_PREFIXES = ["/api", "/og"] as const;
|
|
61
|
+
export const NON_LOCALE_ROUTE_PREFIXES = ["/api", "/og", "/v1"] as const;
|
|
61
62
|
|
|
62
63
|
// Segment-bound: /og and /og/x are exempt; /ogden and /og-image are not.
|
|
63
64
|
export function isNonLocaleRoute(pathname: string): boolean {
|
|
@@ -148,6 +148,12 @@ const nextConfig = {
|
|
|
148
148
|
// Default locale baked at build time — consumed by the sitemap route for
|
|
149
149
|
// x-default URL composition and bare-path default-locale serving.
|
|
150
150
|
NEXT_PUBLIC_DEFAULT_LOCALE: defaultLocaleAtBuild,
|
|
151
|
+
// This site serves the same-origin relay (app/v1/[...path]/route.ts,
|
|
152
|
+
// #7644), so @working-theory/ui's resolveSubmitUrl leaves a form's
|
|
153
|
+
// root-relative action on this site's own address instead of rewriting it
|
|
154
|
+
// onto the hub's. Part of the source, not an operator setting: a build
|
|
155
|
+
// that carries the relay always carries the flag, and no other build does.
|
|
156
|
+
NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY: "1",
|
|
151
157
|
},
|
|
152
158
|
async headers() {
|
|
153
159
|
if (!STEALTH) return [];
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @working-theory/database
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#7671](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7671) [`25d4a69`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/25d4a699cefaac7cf9c73dafa802696ebdc995e8) Thanks [@claudio-planck](https://github.com/claudio-planck)! - The agent seat, step A ([#3591](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/3591), PR-A of docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md).
|
|
8
|
+
|
|
9
|
+
- `@working-theory/database` — a new `auth` model, `Agent`, and its enum `AgentPermissionLevel` (`member`): a stored delegate of one person, bound to one workspace, keeping only the SHA-256 digest of its key. The migration `20261005120000_agents` is purely additive: one table, its indexes, the ADR-0031 `agent_` primary-key CHECK and format CHECKs on every reference column. Deleting the organization, the workspace or the person's membership deletes the agent; deleting the membership of the person last shown its key clears `key_issued_to_membership_id`.
|
|
10
|
+
- `@working-theory/ids` — registers the `agent_` prefix and exports `zAgentId`.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- Updated dependencies [[`589b167`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/589b167dcd5acb83cb37b46bd5cdb0a57a80df1a), [`25d4a69`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/25d4a699cefaac7cf9c73dafa802696ebdc995e8), [`0fb0f37`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/0fb0f3727db9ad32776b88f39ec8b0943671bcad), [`a6833f9`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/a6833f940da064c36f3150a4af614c9fd8b38628)]:
|
|
15
|
+
- @working-theory/validation@0.35.0
|
|
16
|
+
- @working-theory/ids@0.10.0
|
|
17
|
+
|
|
18
|
+
## 0.34.1
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- [#7566](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/7566) [`64a3245`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/64a324556595861b42166a49d98a4c73cef5115a) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `build` runs `prisma generate` first and always re-emits `dist/client` ([#7565](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/7565)), so a stale or skipped generated client can no longer be built into a release. Before this, `build` was `tsc -b` alone: it never regenerated the client, and `tsc -b` trusted `tsconfig.build.tsbuildinfo`, so `database@0.14.0` shipped without `FlowCheckout` and `database@0.34.0` without `Spoke`, `SchedulingConnection` and `AppointmentOrigin`. `db:generate` is now `pnpm run build`, so it still generates and builds, once.
|
|
23
|
+
|
|
24
|
+
- Updated dependencies [[`4df3e2c`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/4df3e2cd0f4d22471f7422ada4018040ecca402b)]:
|
|
25
|
+
- @working-theory/ids@0.9.1
|
|
26
|
+
- @working-theory/validation@0.34.1
|
|
27
|
+
|
|
3
28
|
## 0.34.0
|
|
4
29
|
|
|
5
30
|
### Minor Changes
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file agents.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of the agents table (#3591, spec
|
|
5
|
+
* docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md D1; AC-1 and AC-2's
|
|
6
|
+
* cascade legs). Requires DATABASE_URL pointing at a Postgres with the
|
|
7
|
+
* migrations applied — the whole suite SKIPS when DATABASE_URL is unset,
|
|
8
|
+
* keeping the workspace Jest run DB-free (the repo convention; the
|
|
9
|
+
* `apps/api Route Tests (Postgres)` workflow exercises it).
|
|
10
|
+
*
|
|
11
|
+
* Uses the `pg` driver directly, as `spoke-registry.db.test.ts` does. What it
|
|
12
|
+
* proves that a mock cannot:
|
|
13
|
+
* 1. The six format CHECKs reject a malformed id.
|
|
14
|
+
* 2. `credential_hash` is unique.
|
|
15
|
+
* 3. Deleting the responsible person's membership, the workspace or the
|
|
16
|
+
* organization deletes the agent — so a re-invited person's new
|
|
17
|
+
* membership cannot revive it.
|
|
18
|
+
* 4. Deleting the membership of the person last shown the key sets
|
|
19
|
+
* `key_issued_to_membership_id` null and keeps the agent.
|
|
20
|
+
* 5. Deleting the creator's user row leaves the agent (no relation).
|
|
21
|
+
*/
|
|
22
|
+
import { Client } from "pg";
|
|
23
|
+
|
|
24
|
+
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
25
|
+
|
|
26
|
+
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
|
+
|
|
28
|
+
describeDb("the agents table (#3591, live DB)", () => {
|
|
29
|
+
let db: Client;
|
|
30
|
+
let organizationId: string;
|
|
31
|
+
let workspaceId: string;
|
|
32
|
+
let ownerUserId: string;
|
|
33
|
+
let personUserId: string;
|
|
34
|
+
let ownerMembershipId: string;
|
|
35
|
+
let personMembershipId: string;
|
|
36
|
+
let hashSeq = 0;
|
|
37
|
+
|
|
38
|
+
/** A fresh 64-character digest per insert, so the unique key never collides by accident. */
|
|
39
|
+
function nextHash(): string {
|
|
40
|
+
hashSeq += 1;
|
|
41
|
+
return hashSeq.toString(16).padStart(64, "0");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function insertUser(tag: string): Promise<string> {
|
|
45
|
+
return (
|
|
46
|
+
await db.query<{ id: string }>(
|
|
47
|
+
`INSERT INTO "users" ("email", "updated_at") VALUES ($1, now()) RETURNING "id"`,
|
|
48
|
+
[`agents3591-${tag}@example.test`],
|
|
49
|
+
)
|
|
50
|
+
).rows[0]!.id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function insertOrganization(slug: string): Promise<string> {
|
|
54
|
+
return (
|
|
55
|
+
await db.query<{ id: string }>(
|
|
56
|
+
`INSERT INTO "organizations" ("name", "slug", "updated_at")
|
|
57
|
+
VALUES ($1, $1, now()) RETURNING "id"`,
|
|
58
|
+
[`agents3591-${slug}`],
|
|
59
|
+
)
|
|
60
|
+
).rows[0]!.id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function insertWorkspace(orgId: string, slug: string): Promise<string> {
|
|
64
|
+
return (
|
|
65
|
+
await db.query<{ id: string }>(
|
|
66
|
+
`INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
|
|
67
|
+
VALUES ($1, $2, $2, now()) RETURNING "id"`,
|
|
68
|
+
[orgId, slug],
|
|
69
|
+
)
|
|
70
|
+
).rows[0]!.id;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function insertMembership(orgId: string, userId: string, role: string): Promise<string> {
|
|
74
|
+
return (
|
|
75
|
+
await db.query<{ id: string }>(
|
|
76
|
+
`INSERT INTO "memberships" ("organization_id", "user_id", "role")
|
|
77
|
+
VALUES ($1, $2, $3::"Role") RETURNING "id"`,
|
|
78
|
+
[orgId, userId, role],
|
|
79
|
+
)
|
|
80
|
+
).rows[0]!.id;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type AgentColumns = {
|
|
84
|
+
id?: string;
|
|
85
|
+
organization_id: string;
|
|
86
|
+
workspace_id: string;
|
|
87
|
+
responsible_membership_id: string;
|
|
88
|
+
created_by_user_id: string;
|
|
89
|
+
key_issued_to_membership_id: string | null;
|
|
90
|
+
credential_hash: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function defaults(): AgentColumns {
|
|
94
|
+
return {
|
|
95
|
+
organization_id: organizationId,
|
|
96
|
+
workspace_id: workspaceId,
|
|
97
|
+
responsible_membership_id: personMembershipId,
|
|
98
|
+
created_by_user_id: ownerUserId,
|
|
99
|
+
key_issued_to_membership_id: ownerMembershipId,
|
|
100
|
+
credential_hash: nextHash(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Insert one agent; the database mints its id unless one is given. */
|
|
105
|
+
async function insertAgent(overrides: Partial<AgentColumns> = {}): Promise<string> {
|
|
106
|
+
const row = { ...defaults(), ...overrides };
|
|
107
|
+
const columns = Object.keys(row);
|
|
108
|
+
const values = Object.values(row);
|
|
109
|
+
const { rows } = await db.query<{ id: string }>(
|
|
110
|
+
`INSERT INTO "agents" (${columns.map((c) => `"${c}"`).join(", ")}, "name", "credential_prefix", "updated_at")
|
|
111
|
+
VALUES (${columns.map((_, i) => `$${i + 1}`).join(", ")}, 'inbox helper', 'ailk_agt_abcdefgh', now())
|
|
112
|
+
RETURNING "id"`,
|
|
113
|
+
values,
|
|
114
|
+
);
|
|
115
|
+
return rows[0]!.id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function agentRow(id: string): Promise<{ key_issued_to_membership_id: string | null } | undefined> {
|
|
119
|
+
return (
|
|
120
|
+
await db.query<{ key_issued_to_membership_id: string | null }>(
|
|
121
|
+
`SELECT "key_issued_to_membership_id" FROM "agents" WHERE "id" = $1`,
|
|
122
|
+
[id],
|
|
123
|
+
)
|
|
124
|
+
).rows[0];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
beforeAll(async () => {
|
|
128
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
129
|
+
await db.connect();
|
|
130
|
+
organizationId = await insertOrganization("org");
|
|
131
|
+
workspaceId = await insertWorkspace(organizationId, "agents3591-ws");
|
|
132
|
+
ownerUserId = await insertUser("owner");
|
|
133
|
+
personUserId = await insertUser("person");
|
|
134
|
+
ownerMembershipId = await insertMembership(organizationId, ownerUserId, "owner");
|
|
135
|
+
personMembershipId = await insertMembership(organizationId, personUserId, "member");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterAll(async () => {
|
|
139
|
+
await db.query(`DELETE FROM "organizations" WHERE "slug" LIKE 'agents3591-%'`);
|
|
140
|
+
await db.query(`DELETE FROM "users" WHERE "email" LIKE 'agents3591-%'`);
|
|
141
|
+
await db.end();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("format CHECKs (ADR-0031)", () => {
|
|
145
|
+
it("an agent takes a database-minted agent_ id, a member ceiling and is enabled", async () => {
|
|
146
|
+
const id = await insertAgent();
|
|
147
|
+
expect(id).toMatch(AGENT_ID);
|
|
148
|
+
const { rows } = await db.query<{ permission_level: string; enabled: boolean }>(
|
|
149
|
+
`SELECT "permission_level", "enabled" FROM "agents" WHERE "id" = $1`,
|
|
150
|
+
[id],
|
|
151
|
+
);
|
|
152
|
+
expect(rows[0]).toEqual({ permission_level: "member", enabled: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it.each([
|
|
156
|
+
["id", "agents_id_format"],
|
|
157
|
+
["organization_id", "agents_organization_id_format"],
|
|
158
|
+
["workspace_id", "agents_workspace_id_format"],
|
|
159
|
+
["responsible_membership_id", "agents_responsible_membership_id_format"],
|
|
160
|
+
["key_issued_to_membership_id", "agents_key_issued_to_membership_id_format"],
|
|
161
|
+
["created_by_user_id", "agents_created_by_user_id_format"],
|
|
162
|
+
] as const)("REJECTS a malformed %s", async (column, constraint) => {
|
|
163
|
+
// The format CHECK fires before any foreign key.
|
|
164
|
+
await expect(insertAgent({ [column]: "bad" })).rejects.toThrow(new RegExp(constraint));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("refuses a ceiling outside the enum", async () => {
|
|
168
|
+
const id = await insertAgent();
|
|
169
|
+
await expect(
|
|
170
|
+
db.query(`UPDATE "agents" SET "permission_level" = 'owner' WHERE "id" = $1`, [id]),
|
|
171
|
+
).rejects.toThrow(/AgentPermissionLevel/);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("a credential digest is unique", async () => {
|
|
176
|
+
const hash = nextHash();
|
|
177
|
+
await insertAgent({ credential_hash: hash });
|
|
178
|
+
await expect(insertAgent({ credential_hash: hash })).rejects.toThrow(
|
|
179
|
+
/agents_credential_hash_key/,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("cascades (D1 — leaving stops the agent)", () => {
|
|
184
|
+
it("deleting the person's membership deletes their agent, and a re-invite does not revive it", async () => {
|
|
185
|
+
const user = await insertUser("leaver");
|
|
186
|
+
const membership = await insertMembership(organizationId, user, "member");
|
|
187
|
+
const agent = await insertAgent({ responsible_membership_id: membership });
|
|
188
|
+
|
|
189
|
+
await db.query(`DELETE FROM "memberships" WHERE "id" = $1`, [membership]);
|
|
190
|
+
expect(await agentRow(agent)).toBeUndefined();
|
|
191
|
+
|
|
192
|
+
const reinvited = await insertMembership(organizationId, user, "member");
|
|
193
|
+
expect(reinvited).not.toBe(membership);
|
|
194
|
+
expect(await agentRow(agent)).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("deleting the workspace deletes its agents", async () => {
|
|
198
|
+
const doomed = await insertWorkspace(organizationId, "agents3591-doomed");
|
|
199
|
+
const agent = await insertAgent({ workspace_id: doomed });
|
|
200
|
+
await db.query(`DELETE FROM "workspaces" WHERE "id" = $1`, [doomed]);
|
|
201
|
+
expect(await agentRow(agent)).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("deleting the organization deletes its agents", async () => {
|
|
205
|
+
const org = await insertOrganization("doomed-org");
|
|
206
|
+
const ws = await insertWorkspace(org, "agents3591-doomed-org-ws");
|
|
207
|
+
const user = await insertUser("doomed-org-person");
|
|
208
|
+
const membership = await insertMembership(org, user, "owner");
|
|
209
|
+
const agent = await insertAgent({
|
|
210
|
+
organization_id: org,
|
|
211
|
+
workspace_id: ws,
|
|
212
|
+
responsible_membership_id: membership,
|
|
213
|
+
key_issued_to_membership_id: membership,
|
|
214
|
+
});
|
|
215
|
+
await db.query(`DELETE FROM "organizations" WHERE "id" = $1`, [org]);
|
|
216
|
+
expect(await agentRow(agent)).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("deleting the key-holder's membership keeps the agent and clears key_issued_to_membership_id", async () => {
|
|
220
|
+
const admin = await insertUser("departing-admin");
|
|
221
|
+
const adminMembership = await insertMembership(organizationId, admin, "admin");
|
|
222
|
+
const agent = await insertAgent({ key_issued_to_membership_id: adminMembership });
|
|
223
|
+
|
|
224
|
+
await db.query(`DELETE FROM "memberships" WHERE "id" = $1`, [adminMembership]);
|
|
225
|
+
expect(await agentRow(agent)).toEqual({ key_issued_to_membership_id: null });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("deleting the creator's user row leaves the agent (created_by_user_id has no relation)", async () => {
|
|
229
|
+
const creator = await insertUser("creator");
|
|
230
|
+
const agent = await insertAgent({ created_by_user_id: creator, key_issued_to_membership_id: null });
|
|
231
|
+
await db.query(`DELETE FROM "users" WHERE "id" = $1`, [creator]);
|
|
232
|
+
expect(await agentRow(agent)).toEqual({ key_issued_to_membership_id: null });
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|