@synap-core/sdk 1.2.0 → 1.3.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.
Files changed (42) hide show
  1. package/README.md +80 -0
  2. package/dist/chunk-J7XHAAFJ.js +79 -0
  3. package/dist/chunk-J7XHAAFJ.js.map +1 -0
  4. package/dist/{client-C5sF20f1.d.cts → client-CVUT1Mcf.d.cts} +1 -1
  5. package/dist/{client-C5sF20f1.d.ts → client-CVUT1Mcf.d.ts} +1 -1
  6. package/dist/connect.browser.cjs +22 -0
  7. package/dist/connect.browser.cjs.map +1 -0
  8. package/dist/connect.browser.d.cts +2 -0
  9. package/dist/connect.browser.d.ts +2 -0
  10. package/dist/connect.browser.js +5 -0
  11. package/dist/connect.browser.js.map +1 -0
  12. package/dist/connect.cjs +1206 -0
  13. package/dist/connect.cjs.map +1 -0
  14. package/dist/connect.d.cts +442 -0
  15. package/dist/connect.d.ts +442 -0
  16. package/dist/connect.js +1092 -0
  17. package/dist/connect.js.map +1 -0
  18. package/dist/public.d.cts +2 -2
  19. package/dist/public.d.ts +2 -2
  20. package/dist/testing.cjs +304 -19
  21. package/dist/testing.cjs.map +1 -1
  22. package/dist/testing.d.cts +73 -2
  23. package/dist/testing.d.ts +73 -2
  24. package/dist/testing.js +281 -19
  25. package/dist/testing.js.map +1 -1
  26. package/dist/types-DpqPNaaa.d.cts +76 -0
  27. package/dist/types-DpqPNaaa.d.ts +76 -0
  28. package/package.json +11 -3
  29. package/src/connect/auth.ts +405 -0
  30. package/src/connect/browser.ts +10 -0
  31. package/src/connect/client.ts +193 -0
  32. package/src/connect/discovery.ts +262 -0
  33. package/src/connect/encoding.ts +93 -0
  34. package/src/connect/errors.ts +115 -0
  35. package/src/connect/flow.ts +449 -0
  36. package/src/connect/guard.ts +25 -0
  37. package/src/connect/index.ts +59 -0
  38. package/src/connect/seal.ts +119 -0
  39. package/src/connect/store.ts +151 -0
  40. package/src/connect/types.ts +80 -0
  41. package/src/testing/auth-server.ts +380 -0
  42. package/src/testing/index.ts +5 -0
@@ -1,4 +1,5 @@
1
- import { P as PublicForm, g as PublicShare, e as PublicRecord } from './client-C5sF20f1.cjs';
1
+ import { P as PublicForm, a as PublicShare, b as PublicRecord } from './client-CVUT1Mcf.cjs';
2
+ import { P as PodGrant, a as PodUser } from './types-DpqPNaaa.cjs';
2
3
 
3
4
  /**
4
5
  * Demo data for the mock pod. Shapes are the `@synap-core/sdk/public` wire
@@ -28,6 +29,73 @@ interface MockSite {
28
29
  */
29
30
  declare const MOCK_SITE: MockSite;
30
31
 
32
+ /** A placeholder pod origin; never contacted. */
33
+ declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
34
+ declare const MOCK_GRANT: PodGrant[];
35
+ declare const MOCK_USER: PodUser;
36
+ type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
37
+ interface MockAuthServerOptions {
38
+ issuer?: string;
39
+ /** Issue rotating refresh tokens (PB3). Default true; false = a pod before PB3. */
40
+ refresh?: boolean;
41
+ /** Default 3600 (the pod's 1 h). */
42
+ accessTtlSec?: number;
43
+ /** Default 30 days. */
44
+ refreshIdleSec?: number;
45
+ /** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
46
+ advertiseRefreshExpiry?: boolean;
47
+ grant?: PodGrant[];
48
+ user?: PodUser;
49
+ /** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
50
+ metadata?: Record<string, unknown>;
51
+ /** Hub writes: "proposed" (ask-first, the default for an app) or "applied". */
52
+ writeMode?: "proposed" | "applied";
53
+ /** Hub paths (after /api/hub) that answer one failure. */
54
+ failures?: Record<string, MockHubFailure>;
55
+ now?: () => number;
56
+ }
57
+ interface MockAuthRequest {
58
+ method: string;
59
+ url: string;
60
+ /** Lower-cased header names. */
61
+ headers: Record<string, string>;
62
+ /** The form body of a /token or /revoke call. */
63
+ form: Record<string, string> | null;
64
+ }
65
+ interface AuthorizeDecision {
66
+ /** The person said no. */
67
+ deny?: boolean;
68
+ /** Override `iss` on the answer (null = omit it): a mix-up attack. */
69
+ iss?: string | null;
70
+ /** Override `state` on the answer: a forged answer. */
71
+ state?: string;
72
+ }
73
+ interface MockAuthServer {
74
+ issuer: string;
75
+ fetch: typeof fetch;
76
+ requests: MockAuthRequest[];
77
+ /** Hub writes seen, in order. */
78
+ writes: Array<{
79
+ method: string;
80
+ path: string;
81
+ body: unknown;
82
+ }>;
83
+ /** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
84
+ authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
85
+ /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
86
+ consumeRefreshToken(token: string): void;
87
+ /** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
88
+ loseNextRefreshRace(): void;
89
+ /** Refresh-token redeems that were reuses (each revoked a family). */
90
+ reuses: number;
91
+ /** The person revoked the app: every token stops working. */
92
+ revokeAll(): void;
93
+ /** Live (unrevoked) refresh tokens. */
94
+ liveRefreshTokens(): string[];
95
+ reset(): void;
96
+ }
97
+ declare function createMockAuthServer(options?: MockAuthServerOptions): MockAuthServer;
98
+
31
99
  /**
32
100
  * @synap-core/sdk/testing — a mock pod: a fake `fetch` that answers the pod's
33
101
  * public doors (forms, shares, sites) from fixtures, with no network. Inject
@@ -45,6 +113,9 @@ declare const MOCK_SITE: MockSite;
45
113
  * tokens MOCK_TOKENS.form / .share / .site the demo form, share and site
46
114
  * tokens MOCK_FAILURE_TOKENS.* one failure on every door
47
115
  * anything else the uniform 404
116
+ *
117
+ * Pod-app sign-in (`@synap-core/sdk/connect`): `createMockAuthServer()`, see
118
+ * ./auth-server.ts.
48
119
  */
49
120
 
50
121
  /** A placeholder origin; never contacted. */
@@ -107,4 +178,4 @@ interface MockPod {
107
178
  }
108
179
  declare function createMockPod(options?: MockPodOptions): MockPod;
109
180
 
110
- export { MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, type MockCollection, type MockPod, type MockPodOptions, type MockRequest, type MockSite, type MockSubmission, createMockPod };
181
+ export { type AuthorizeDecision, MOCK_AUTH_ISSUER, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockRequest, type MockSite, type MockSubmission, createMockAuthServer, createMockPod };
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { P as PublicForm, g as PublicShare, e as PublicRecord } from './client-C5sF20f1.js';
1
+ import { P as PublicForm, a as PublicShare, b as PublicRecord } from './client-CVUT1Mcf.js';
2
+ import { P as PodGrant, a as PodUser } from './types-DpqPNaaa.js';
2
3
 
3
4
  /**
4
5
  * Demo data for the mock pod. Shapes are the `@synap-core/sdk/public` wire
@@ -28,6 +29,73 @@ interface MockSite {
28
29
  */
29
30
  declare const MOCK_SITE: MockSite;
30
31
 
32
+ /** A placeholder pod origin; never contacted. */
33
+ declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
34
+ declare const MOCK_GRANT: PodGrant[];
35
+ declare const MOCK_USER: PodUser;
36
+ type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
37
+ interface MockAuthServerOptions {
38
+ issuer?: string;
39
+ /** Issue rotating refresh tokens (PB3). Default true; false = a pod before PB3. */
40
+ refresh?: boolean;
41
+ /** Default 3600 (the pod's 1 h). */
42
+ accessTtlSec?: number;
43
+ /** Default 30 days. */
44
+ refreshIdleSec?: number;
45
+ /** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
46
+ advertiseRefreshExpiry?: boolean;
47
+ grant?: PodGrant[];
48
+ user?: PodUser;
49
+ /** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
50
+ metadata?: Record<string, unknown>;
51
+ /** Hub writes: "proposed" (ask-first, the default for an app) or "applied". */
52
+ writeMode?: "proposed" | "applied";
53
+ /** Hub paths (after /api/hub) that answer one failure. */
54
+ failures?: Record<string, MockHubFailure>;
55
+ now?: () => number;
56
+ }
57
+ interface MockAuthRequest {
58
+ method: string;
59
+ url: string;
60
+ /** Lower-cased header names. */
61
+ headers: Record<string, string>;
62
+ /** The form body of a /token or /revoke call. */
63
+ form: Record<string, string> | null;
64
+ }
65
+ interface AuthorizeDecision {
66
+ /** The person said no. */
67
+ deny?: boolean;
68
+ /** Override `iss` on the answer (null = omit it): a mix-up attack. */
69
+ iss?: string | null;
70
+ /** Override `state` on the answer: a forged answer. */
71
+ state?: string;
72
+ }
73
+ interface MockAuthServer {
74
+ issuer: string;
75
+ fetch: typeof fetch;
76
+ requests: MockAuthRequest[];
77
+ /** Hub writes seen, in order. */
78
+ writes: Array<{
79
+ method: string;
80
+ path: string;
81
+ body: unknown;
82
+ }>;
83
+ /** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
84
+ authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
85
+ /** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
86
+ consumeRefreshToken(token: string): void;
87
+ /** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
88
+ loseNextRefreshRace(): void;
89
+ /** Refresh-token redeems that were reuses (each revoked a family). */
90
+ reuses: number;
91
+ /** The person revoked the app: every token stops working. */
92
+ revokeAll(): void;
93
+ /** Live (unrevoked) refresh tokens. */
94
+ liveRefreshTokens(): string[];
95
+ reset(): void;
96
+ }
97
+ declare function createMockAuthServer(options?: MockAuthServerOptions): MockAuthServer;
98
+
31
99
  /**
32
100
  * @synap-core/sdk/testing — a mock pod: a fake `fetch` that answers the pod's
33
101
  * public doors (forms, shares, sites) from fixtures, with no network. Inject
@@ -45,6 +113,9 @@ declare const MOCK_SITE: MockSite;
45
113
  * tokens MOCK_TOKENS.form / .share / .site the demo form, share and site
46
114
  * tokens MOCK_FAILURE_TOKENS.* one failure on every door
47
115
  * anything else the uniform 404
116
+ *
117
+ * Pod-app sign-in (`@synap-core/sdk/connect`): `createMockAuthServer()`, see
118
+ * ./auth-server.ts.
48
119
  */
49
120
 
50
121
  /** A placeholder origin; never contacted. */
@@ -107,4 +178,4 @@ interface MockPod {
107
178
  }
108
179
  declare function createMockPod(options?: MockPodOptions): MockPod;
109
180
 
110
- export { MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, type MockCollection, type MockPod, type MockPodOptions, type MockRequest, type MockSite, type MockSubmission, createMockPod };
181
+ export { type AuthorizeDecision, MOCK_AUTH_ISSUER, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockRequest, type MockSite, type MockSubmission, createMockAuthServer, createMockPod };
package/dist/testing.js CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ pkceChallenge,
3
+ randomToken
4
+ } from "./chunk-J7XHAAFJ.js";
5
+
1
6
  // src/testing/fixtures.ts
2
7
  var MOCK_FORM = {
3
8
  title: "Get in touch",
@@ -93,6 +98,259 @@ var MOCK_SITE = {
93
98
  }
94
99
  };
95
100
 
101
+ // src/testing/auth-server.ts
102
+ var MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
103
+ var MOCK_GRANT = [
104
+ { permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
105
+ { permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" }
106
+ ];
107
+ var MOCK_USER = { id: "user_mock_ada", name: "Ada Lovelace" };
108
+ function json(status, body, headers = {}) {
109
+ return new Response(JSON.stringify(body), {
110
+ status,
111
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers }
112
+ });
113
+ }
114
+ function oauthError(error, description, status = 400) {
115
+ return json(status, { error, error_description: description });
116
+ }
117
+ function createMockAuthServer(options = {}) {
118
+ const issuer = options.issuer ?? MOCK_AUTH_ISSUER;
119
+ const now = options.now ?? Date.now;
120
+ const refresh = options.refresh ?? true;
121
+ const accessTtl = options.accessTtlSec ?? 3600;
122
+ const refreshIdle = options.refreshIdleSec ?? 30 * 24 * 3600;
123
+ const grant = options.grant ?? MOCK_GRANT;
124
+ const user = options.user ?? MOCK_USER;
125
+ const codes = /* @__PURE__ */ new Map();
126
+ const access = /* @__PURE__ */ new Map();
127
+ const refreshTokens = /* @__PURE__ */ new Map();
128
+ const requests = [];
129
+ const writes = [];
130
+ let proposalSeq = 0;
131
+ const metadata = () => {
132
+ const base = {
133
+ issuer,
134
+ authorization_endpoint: `${issuer}/authorize`,
135
+ token_endpoint: `${issuer}/token`,
136
+ registration_endpoint: `${issuer}/register`,
137
+ revocation_endpoint: `${issuer}/revoke`,
138
+ revocation_endpoint_auth_methods_supported: ["none"],
139
+ response_types_supported: ["code"],
140
+ grant_types_supported: refresh ? ["authorization_code", "refresh_token"] : ["authorization_code"],
141
+ code_challenge_methods_supported: ["S256"],
142
+ token_endpoint_auth_methods_supported: ["none"],
143
+ authorization_response_iss_parameter_supported: true,
144
+ client_id_metadata_document_supported: true
145
+ };
146
+ for (const [k, v] of Object.entries(options.metadata ?? {})) {
147
+ if (v === void 0) delete base[k];
148
+ else base[k] = v;
149
+ }
150
+ return base;
151
+ };
152
+ let loseNextRace = false;
153
+ let reuses = 0;
154
+ const revokeFamily = (family) => {
155
+ for (const r of refreshTokens.values()) if (r.family === family) r.revoked = true;
156
+ for (const a of access.values()) if (a.family === family) a.revoked = true;
157
+ };
158
+ const tokenBody = (scope, clientId, family) => {
159
+ const accessToken = `synap_hub_mock_${randomToken()}`;
160
+ access.set(accessToken, { expiresAt: now() + accessTtl * 1e3, revoked: false, family: refresh ? family : null });
161
+ const body = {
162
+ access_token: accessToken,
163
+ token_type: "Bearer",
164
+ expires_in: accessTtl,
165
+ scope: scope.join(" "),
166
+ synap_grant: grant.filter((g) => scope.includes(g.permission)),
167
+ synap_user: user
168
+ };
169
+ if (refresh) {
170
+ const refreshToken = randomToken();
171
+ refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1e3, used: false, revoked: false, scope });
172
+ body.refresh_token = refreshToken;
173
+ if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = refreshIdle;
174
+ }
175
+ return body;
176
+ };
177
+ async function token(form) {
178
+ if (form.grant_type === "authorization_code") {
179
+ const row = form.code ? codes.get(form.code) : void 0;
180
+ if (form.code) codes.delete(form.code);
181
+ if (!row) return oauthError("invalid_grant", "Invalid, expired, or already-redeemed authorization code");
182
+ if (row.clientId !== form.client_id) return oauthError("invalid_grant", "client_id does not match the code");
183
+ if (row.redirectUri !== form.redirect_uri) return oauthError("invalid_grant", "redirect_uri mismatch");
184
+ if (!form.code_verifier || await pkceChallenge(form.code_verifier) !== row.challenge) {
185
+ return oauthError("invalid_grant", "PKCE verification failed");
186
+ }
187
+ if (form.resource !== void 0 && row.resource && form.resource !== row.resource) {
188
+ return oauthError("invalid_target", "resource does not match the authorization request");
189
+ }
190
+ for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
191
+ return json(200, tokenBody(row.scope, row.clientId, randomToken()));
192
+ }
193
+ if (form.grant_type === "refresh_token") {
194
+ if (!refresh) return oauthError("unsupported_grant_type", 'Only grant_type "authorization_code" is supported');
195
+ if (!form.refresh_token || !form.client_id) return oauthError("invalid_request", "refresh_token and client_id are required");
196
+ const row = refreshTokens.get(form.refresh_token);
197
+ if (!row || row.clientId !== form.client_id || row.revoked) {
198
+ return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
199
+ }
200
+ if (row.used) {
201
+ reuses++;
202
+ revokeFamily(row.family);
203
+ return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
204
+ }
205
+ if (row.idleUntil <= now()) return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
206
+ if (loseNextRace) {
207
+ loseNextRace = false;
208
+ return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
209
+ }
210
+ row.used = true;
211
+ return json(200, tokenBody(row.scope, row.clientId, row.family));
212
+ }
213
+ return oauthError("unsupported_grant_type", "Unsupported grant_type");
214
+ }
215
+ function hub(method, path, headers, body) {
216
+ const failure2 = options.failures?.[path];
217
+ if (failure2 === "unreachable") return "reject";
218
+ const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
219
+ const row = bearer ? access.get(bearer) : void 0;
220
+ if (!row || row.revoked || row.expiresAt <= now()) return json(401, { error: "Unauthorized" });
221
+ switch (failure2) {
222
+ case "rate_limited":
223
+ return json(429, { error: "Too many requests" }, { "Retry-After": "30" });
224
+ case "forbidden":
225
+ return json(403, { error: "Forbidden" });
226
+ case "door_unsupported":
227
+ return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
228
+ case "server":
229
+ return json(500, { error: "Internal error" });
230
+ case "unavailable":
231
+ return json(503, { error: "Unavailable" });
232
+ }
233
+ if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
234
+ return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
235
+ }
236
+ if (method === "GET" && path === "/users/me") return json(200, user);
237
+ if (method === "GET" && path === "/entities") return json(200, { entities: [], total: 0 });
238
+ if (method !== "GET") {
239
+ writes.push({ method, path, body });
240
+ if ((options.writeMode ?? "proposed") === "proposed") {
241
+ const proposalId = `prop_mock_${++proposalSeq}`;
242
+ return json(200, {
243
+ status: "proposed",
244
+ message: "Sent for review",
245
+ proposalId,
246
+ reviewUrl: `${issuer}/open/${proposalId}`
247
+ });
248
+ }
249
+ return json(200, { status: "created", id: `ent_mock_${writes.length}` });
250
+ }
251
+ return json(404, { error: "Not found" });
252
+ }
253
+ const mockFetch = async (input, init) => {
254
+ const request = typeof input === "object" && "method" in input ? input : null;
255
+ const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
256
+ const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
257
+ const headers = {};
258
+ new Headers(init?.headers ?? request?.headers).forEach((value, name) => {
259
+ headers[name.toLowerCase()] = value;
260
+ });
261
+ const rawBody = typeof init?.body === "string" ? init.body : request ? await request.clone().text() : "";
262
+ const isForm = (headers["content-type"] ?? "").includes("application/x-www-form-urlencoded");
263
+ const form = isForm ? Object.fromEntries(new URLSearchParams(rawBody)) : null;
264
+ requests.push({ method, url: url.href, headers, form });
265
+ if (url.origin !== new URL(issuer).origin) throw new TypeError(`Failed to fetch: ${url.origin} is not the mock pod`);
266
+ const path = url.pathname;
267
+ if (method === "GET" && path === "/.well-known/oauth-authorization-server") return json(200, metadata());
268
+ if (method === "POST" && path === "/token") return token(form ?? {});
269
+ if (method === "POST" && path === "/revoke") {
270
+ const t = form?.token ?? "";
271
+ const r = refreshTokens.get(t);
272
+ if (r && form?.client_id && form.client_id !== r.clientId) {
273
+ return oauthError("unauthorized_client", "The token was not issued to this client");
274
+ }
275
+ if (r) revokeFamily(r.family);
276
+ const a = access.get(t);
277
+ if (a) a.revoked = true;
278
+ return new Response(null, { status: 200 });
279
+ }
280
+ const hubPath = /^\/api\/hub(\/.*)$/.exec(path)?.[1];
281
+ if (hubPath) {
282
+ let body = void 0;
283
+ try {
284
+ body = rawBody ? JSON.parse(rawBody) : void 0;
285
+ } catch {
286
+ body = rawBody;
287
+ }
288
+ const res = hub(method, hubPath, headers, body);
289
+ if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
290
+ return res;
291
+ }
292
+ return json(404, { error: "Not found" });
293
+ };
294
+ return {
295
+ issuer,
296
+ fetch: mockFetch,
297
+ requests,
298
+ writes,
299
+ authorize(authorizeUrl, decision = {}) {
300
+ const url = new URL(authorizeUrl);
301
+ if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
302
+ const q = url.searchParams;
303
+ const redirectUri = q.get("redirect_uri");
304
+ const clientId = q.get("client_id");
305
+ if (!clientId || !redirectUri) throw new Error("Authorization request rejected: client_id and redirect_uri are required");
306
+ const answer = new URL(redirectUri);
307
+ const state = decision.state ?? q.get("state");
308
+ const finish = (fields) => {
309
+ for (const [k, v] of Object.entries(fields)) answer.searchParams.set(k, v);
310
+ if (state !== null) answer.searchParams.set("state", state);
311
+ const iss = decision.iss === void 0 ? issuer : decision.iss;
312
+ if (iss !== null) answer.searchParams.set("iss", iss);
313
+ return answer.href;
314
+ };
315
+ if (q.get("response_type") !== "code") return finish({ error: "unsupported_response_type" });
316
+ if (q.get("code_challenge_method") !== "S256" || !q.get("code_challenge")) {
317
+ return finish({ error: "invalid_request", error_description: "PKCE S256 is required" });
318
+ }
319
+ const resource = q.get("resource");
320
+ if (resource !== null && resource !== `${issuer}/api/hub` && resource !== `${issuer}/mcp`) {
321
+ return finish({ error: "invalid_target" });
322
+ }
323
+ const scope = (q.get("scope") ?? "").split(/\s+/).filter(Boolean);
324
+ if (scope.length === 0) return finish({ error: "invalid_scope" });
325
+ if (decision.deny) return finish({ error: "access_denied" });
326
+ const code = randomToken();
327
+ codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope });
328
+ return finish({ code });
329
+ },
330
+ consumeRefreshToken(t) {
331
+ const row = refreshTokens.get(t);
332
+ if (row) row.used = true;
333
+ },
334
+ loseNextRefreshRace() {
335
+ loseNextRace = true;
336
+ },
337
+ get reuses() {
338
+ return reuses;
339
+ },
340
+ revokeAll() {
341
+ for (const row of access.values()) row.revoked = true;
342
+ for (const row of refreshTokens.values()) row.revoked = true;
343
+ },
344
+ liveRefreshTokens() {
345
+ return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
346
+ },
347
+ reset() {
348
+ requests.length = 0;
349
+ writes.length = 0;
350
+ }
351
+ };
352
+ }
353
+
96
354
  // src/testing/index.ts
97
355
  var MOCK_POD_URL = "https://mock.synap.invalid";
98
356
  var MOCK_TOKENS = { form: "demo-form", share: "demo", site: "demo-site" };
@@ -127,7 +385,7 @@ var MAX_OFFSET = 1e4;
127
385
  var MAX_LIMIT = 100;
128
386
  var MAX_SORT_KEYS = 3;
129
387
  var PUBLISHED_AT = "publishedAt";
130
- function json(status, body, headers = {}) {
388
+ function json2(status, body, headers = {}) {
131
389
  return new Response(JSON.stringify(body), {
132
390
  status,
133
391
  headers: { "Content-Type": "application/json", "Cache-Control": "no-cache", ...headers }
@@ -150,15 +408,15 @@ function etagMatches(header, etag) {
150
408
  function failure(token) {
151
409
  switch (token) {
152
410
  case MOCK_FAILURE_TOKENS.rateLimited:
153
- return json(429, { error: "Too many requests", retryAfter: "5 minutes" });
411
+ return json2(429, { error: "Too many requests", retryAfter: "5 minutes" });
154
412
  case MOCK_FAILURE_TOKENS.rateLimitedRetryAfter:
155
- return json(429, { error: "Too many requests" }, { "Retry-After": String(MOCK_RETRY_AFTER_SEC) });
413
+ return json2(429, { error: "Too many requests" }, { "Retry-After": String(MOCK_RETRY_AFTER_SEC) });
156
414
  case MOCK_FAILURE_TOKENS.doorsDisabled:
157
- return json(403, DOORS_DISABLED);
415
+ return json2(403, DOORS_DISABLED);
158
416
  case MOCK_FAILURE_TOKENS.serverError:
159
- return json(500, { error: "Internal error" });
417
+ return json2(500, { error: "Internal error" });
160
418
  case MOCK_FAILURE_TOKENS.unavailable:
161
- return json(503, { received: false });
419
+ return json2(503, { received: false });
162
420
  case MOCK_FAILURE_TOKENS.unreachable:
163
421
  return "reject";
164
422
  default:
@@ -233,17 +491,17 @@ function cached(view, ifNoneMatch) {
233
491
  if (etagMatches(ifNoneMatch, etag)) {
234
492
  return new Response(null, { status: 304, headers: { ETag: etag, "Cache-Control": "no-cache" } });
235
493
  }
236
- return json(200, view, { ETag: etag });
494
+ return json2(200, view, { ETag: etag });
237
495
  }
238
496
  function siteDoor(site, rest, params, ifNoneMatch) {
239
497
  if (rest.length === 0) return cached(manifest(site), ifNoneMatch);
240
498
  if (rest[0] !== "collections" || rest.length > 3 || !Object.hasOwn(site.collections, rest[1])) {
241
- return json(404, NOT_FOUND);
499
+ return json2(404, NOT_FOUND);
242
500
  }
243
501
  const c = site.collections[rest[1]];
244
502
  if (rest.length === 2) {
245
503
  const q = parseQuery(params, c);
246
- if (typeof q === "string") return json(400, { error: q });
504
+ if (typeof q === "string") return json2(400, { error: q });
247
505
  const sorted = [...c.records].sort(compareRecords(q.sort));
248
506
  const page = {
249
507
  items: sorted.slice(q.offset, q.offset + q.limit).map(summary),
@@ -255,10 +513,10 @@ function siteDoor(site, rest, params, ifNoneMatch) {
255
513
  }
256
514
  const address = rest[2];
257
515
  const byPublicId = PUBLIC_ID_RE.test(address);
258
- if (!byPublicId && !(address.length <= SLUG_MAX_LENGTH && SLUG_RE.test(address))) return json(404, NOT_FOUND);
516
+ if (!byPublicId && !(address.length <= SLUG_MAX_LENGTH && SLUG_RE.test(address))) return json2(404, NOT_FOUND);
259
517
  const record = c.records.find((r) => byPublicId ? r.publicId === address : r.slug === address);
260
- if (!record) return json(404, NOT_FOUND);
261
- if (params.has("embed")) return json(400, { error: "embed is not available" });
518
+ if (!record) return json2(404, NOT_FOUND);
519
+ if (params.has("embed")) return json2(400, { error: "embed is not available" });
262
520
  return cached(record, ifNoneMatch);
263
521
  }
264
522
  function invalidKeys(form, body) {
@@ -295,12 +553,12 @@ function createMockPod(options = {}) {
295
553
  if (preflighted.length > 0) throw new TypeError(`Failed to fetch: preflight refuses ${preflighted.join(", ")}`);
296
554
  }
297
555
  const m = url.pathname.match(/^\/api\/hub\/public\/(forms|shares|sites)\/(.+)$/);
298
- if (!m) return json(404, NOT_FOUND);
556
+ if (!m) return json2(404, NOT_FOUND);
299
557
  let segments;
300
558
  try {
301
559
  segments = m[2].split("/").map(decodeURIComponent);
302
560
  } catch {
303
- return json(404, NOT_FOUND);
561
+ return json2(404, NOT_FOUND);
304
562
  }
305
563
  const [token, ...rest] = segments;
306
564
  const failed = failure(token);
@@ -318,15 +576,15 @@ function createMockPod(options = {}) {
318
576
  const invalid = form ? invalidKeys(form, body) : [];
319
577
  const status = invalid.length > 0 ? 422 : 202;
320
578
  submissions.push({ token, body, status });
321
- return status === 422 ? json(422, { received: false, invalid }) : json(202, { received: true });
579
+ return status === 422 ? json2(422, { received: false, invalid }) : json2(202, { received: true });
322
580
  }
323
- if (method !== "GET") return json(404, NOT_FOUND);
324
- if (door === "forms" && rest.length === 0 && Object.hasOwn(forms, token)) return json(200, forms[token]);
325
- if (door === "shares" && rest.length === 0 && Object.hasOwn(shares, token)) return json(200, shares[token]);
581
+ if (method !== "GET") return json2(404, NOT_FOUND);
582
+ if (door === "forms" && rest.length === 0 && Object.hasOwn(forms, token)) return json2(200, forms[token]);
583
+ if (door === "shares" && rest.length === 0 && Object.hasOwn(shares, token)) return json2(200, shares[token]);
326
584
  if (door === "sites" && Object.hasOwn(sites, token)) {
327
585
  return siteDoor(sites[token], rest, url.searchParams, headers["if-none-match"] ?? null);
328
586
  }
329
- return json(404, NOT_FOUND);
587
+ return json2(404, NOT_FOUND);
330
588
  };
331
589
  return {
332
590
  podUrl: MOCK_POD_URL,
@@ -340,13 +598,17 @@ function createMockPod(options = {}) {
340
598
  };
341
599
  }
342
600
  export {
601
+ MOCK_AUTH_ISSUER,
343
602
  MOCK_FAILURE_TOKENS,
344
603
  MOCK_FORM,
604
+ MOCK_GRANT,
345
605
  MOCK_POD_URL,
346
606
  MOCK_RETRY_AFTER_SEC,
347
607
  MOCK_SHARE,
348
608
  MOCK_SITE,
349
609
  MOCK_TOKENS,
610
+ MOCK_USER,
611
+ createMockAuthServer,
350
612
  createMockPod
351
613
  };
352
614
  //# sourceMappingURL=testing.js.map