@synap-core/sdk 1.2.0 → 1.4.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 +154 -0
- package/dist/chunk-J7XHAAFJ.js +79 -0
- package/dist/chunk-J7XHAAFJ.js.map +1 -0
- package/dist/connect.browser.cjs +22 -0
- package/dist/connect.browser.cjs.map +1 -0
- package/dist/connect.browser.d.cts +2 -0
- package/dist/connect.browser.d.ts +2 -0
- package/dist/connect.browser.js +5 -0
- package/dist/connect.browser.js.map +1 -0
- package/dist/connect.cjs +1487 -0
- package/dist/connect.cjs.map +1 -0
- package/dist/connect.d.cts +647 -0
- package/dist/connect.d.ts +647 -0
- package/dist/connect.js +1365 -0
- package/dist/connect.js.map +1 -0
- package/dist/testing.cjs +431 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +160 -1
- package/dist/testing.d.ts +160 -1
- package/dist/testing.js +404 -19
- package/dist/testing.js.map +1 -1
- package/dist/types-C2KGNXEn.d.cts +95 -0
- package/dist/types-C2KGNXEn.d.ts +95 -0
- package/package.json +18 -10
- package/src/connect/auth.ts +535 -0
- package/src/connect/browser.ts +10 -0
- package/src/connect/client.ts +299 -0
- package/src/connect/cloud.ts +206 -0
- package/src/connect/discovery.ts +263 -0
- package/src/connect/encoding.ts +93 -0
- package/src/connect/errors.ts +115 -0
- package/src/connect/flow.ts +489 -0
- package/src/connect/guard.ts +25 -0
- package/src/connect/index.ts +87 -0
- package/src/connect/seal.ts +119 -0
- package/src/connect/store.ts +189 -0
- package/src/connect/types.ts +100 -0
- package/src/testing/auth-server.ts +512 -0
- package/src/testing/cloud.ts +84 -0
- package/src/testing/index.ts +7 -0
package/dist/testing.cjs
CHANGED
|
@@ -20,13 +20,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/testing/index.ts
|
|
21
21
|
var testing_exports = {};
|
|
22
22
|
__export(testing_exports, {
|
|
23
|
+
MOCK_APP: () => MOCK_APP,
|
|
24
|
+
MOCK_APP_AGENT_ID: () => MOCK_APP_AGENT_ID,
|
|
25
|
+
MOCK_AUTH_ISSUER: () => MOCK_AUTH_ISSUER,
|
|
26
|
+
MOCK_CLOUD_URL: () => MOCK_CLOUD_URL,
|
|
23
27
|
MOCK_FAILURE_TOKENS: () => MOCK_FAILURE_TOKENS,
|
|
24
28
|
MOCK_FORM: () => MOCK_FORM,
|
|
29
|
+
MOCK_GRANT: () => MOCK_GRANT,
|
|
25
30
|
MOCK_POD_URL: () => MOCK_POD_URL,
|
|
26
31
|
MOCK_RETRY_AFTER_SEC: () => MOCK_RETRY_AFTER_SEC,
|
|
27
32
|
MOCK_SHARE: () => MOCK_SHARE,
|
|
28
33
|
MOCK_SITE: () => MOCK_SITE,
|
|
29
34
|
MOCK_TOKENS: () => MOCK_TOKENS,
|
|
35
|
+
MOCK_USER: () => MOCK_USER,
|
|
36
|
+
createMockAuthServer: () => createMockAuthServer,
|
|
37
|
+
createMockCloud: () => createMockCloud,
|
|
30
38
|
createMockPod: () => createMockPod
|
|
31
39
|
});
|
|
32
40
|
module.exports = __toCommonJS(testing_exports);
|
|
@@ -126,6 +134,402 @@ var MOCK_SITE = {
|
|
|
126
134
|
}
|
|
127
135
|
};
|
|
128
136
|
|
|
137
|
+
// src/connect/encoding.ts
|
|
138
|
+
var encoder = new TextEncoder();
|
|
139
|
+
var decoder = new TextDecoder();
|
|
140
|
+
function utf8(text) {
|
|
141
|
+
return encoder.encode(text);
|
|
142
|
+
}
|
|
143
|
+
function base64url(bytes) {
|
|
144
|
+
let binary = "";
|
|
145
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
146
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
147
|
+
}
|
|
148
|
+
function randomBytes(length) {
|
|
149
|
+
const out = new Uint8Array(length);
|
|
150
|
+
crypto.getRandomValues(out);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
function randomToken() {
|
|
154
|
+
return base64url(randomBytes(32));
|
|
155
|
+
}
|
|
156
|
+
async function pkceChallenge(verifier) {
|
|
157
|
+
const digest = await crypto.subtle.digest("SHA-256", utf8(verifier));
|
|
158
|
+
return base64url(new Uint8Array(digest));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/testing/auth-server.ts
|
|
162
|
+
var MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
163
|
+
var MOCK_GRANT = [
|
|
164
|
+
{ permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
165
|
+
{ permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" }
|
|
166
|
+
];
|
|
167
|
+
var MOCK_USER = { id: "user_mock_ada", name: "Ada Lovelace" };
|
|
168
|
+
var MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
|
|
169
|
+
var MOCK_APP_AGENT_ID = "agent_mock_app";
|
|
170
|
+
function json(status, body, headers = {}) {
|
|
171
|
+
return new Response(JSON.stringify(body), {
|
|
172
|
+
status,
|
|
173
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers }
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function oauthError(error, description, status = 400) {
|
|
177
|
+
return json(status, { error, error_description: description });
|
|
178
|
+
}
|
|
179
|
+
function createMockAuthServer(options = {}) {
|
|
180
|
+
const issuer = options.issuer ?? MOCK_AUTH_ISSUER;
|
|
181
|
+
const now = options.now ?? Date.now;
|
|
182
|
+
const refresh = options.refresh ?? true;
|
|
183
|
+
const accessTtl = options.accessTtlSec ?? 3600;
|
|
184
|
+
const refreshIdle = options.refreshIdleSec ?? 30 * 24 * 3600;
|
|
185
|
+
const grant = options.grant ?? MOCK_GRANT;
|
|
186
|
+
const user = options.user ?? MOCK_USER;
|
|
187
|
+
const codes = /* @__PURE__ */ new Map();
|
|
188
|
+
const access = /* @__PURE__ */ new Map();
|
|
189
|
+
const refreshTokens = /* @__PURE__ */ new Map();
|
|
190
|
+
const requests = [];
|
|
191
|
+
const writes = [];
|
|
192
|
+
const proposals = [];
|
|
193
|
+
const entities = [...options.entities ?? []];
|
|
194
|
+
const graceMs = (options.refreshGraceSec ?? 30) * 1e3;
|
|
195
|
+
const app = options.app === void 0 ? MOCK_APP : options.app;
|
|
196
|
+
let proposalSeq = 0;
|
|
197
|
+
let graceAnswers = 0;
|
|
198
|
+
const metadata = () => {
|
|
199
|
+
const base = {
|
|
200
|
+
issuer,
|
|
201
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
202
|
+
token_endpoint: `${issuer}/token`,
|
|
203
|
+
registration_endpoint: `${issuer}/register`,
|
|
204
|
+
revocation_endpoint: `${issuer}/revoke`,
|
|
205
|
+
revocation_endpoint_auth_methods_supported: ["none"],
|
|
206
|
+
response_types_supported: ["code"],
|
|
207
|
+
grant_types_supported: refresh ? ["authorization_code", "refresh_token"] : ["authorization_code"],
|
|
208
|
+
code_challenge_methods_supported: ["S256"],
|
|
209
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
210
|
+
authorization_response_iss_parameter_supported: true,
|
|
211
|
+
client_id_metadata_document_supported: true
|
|
212
|
+
};
|
|
213
|
+
for (const [k, v] of Object.entries(options.metadata ?? {})) {
|
|
214
|
+
if (v === void 0) delete base[k];
|
|
215
|
+
else base[k] = v;
|
|
216
|
+
}
|
|
217
|
+
return base;
|
|
218
|
+
};
|
|
219
|
+
let loseNextRace = false;
|
|
220
|
+
let reuses = 0;
|
|
221
|
+
const revokeFamily = (family) => {
|
|
222
|
+
for (const r of refreshTokens.values()) if (r.family === family) r.revoked = true;
|
|
223
|
+
for (const a of access.values()) if (a.family === family) a.revoked = true;
|
|
224
|
+
};
|
|
225
|
+
const tokenBody = (scope, clientId, family, pair) => {
|
|
226
|
+
const accessToken = pair?.accessToken ?? `synap_hub_mock_${randomToken()}`;
|
|
227
|
+
if (!pair) access.set(accessToken, { expiresAt: now() + accessTtl * 1e3, revoked: false, family: refresh ? family : null, scope });
|
|
228
|
+
const row = access.get(accessToken);
|
|
229
|
+
const body = {
|
|
230
|
+
access_token: accessToken,
|
|
231
|
+
token_type: "Bearer",
|
|
232
|
+
// As the pod: what the key has left, never an assumed hour.
|
|
233
|
+
expires_in: Math.max(0, Math.floor((row.expiresAt - now()) / 1e3)),
|
|
234
|
+
scope: scope.join(" "),
|
|
235
|
+
synap_grant: grant.filter((g) => scope.includes(g.permission)),
|
|
236
|
+
synap_user: user,
|
|
237
|
+
...app ? { synap_app: app } : {}
|
|
238
|
+
};
|
|
239
|
+
if (pair) {
|
|
240
|
+
body.refresh_token = pair.refreshToken;
|
|
241
|
+
if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = Math.floor((refreshTokens.get(pair.refreshToken).idleUntil - now()) / 1e3);
|
|
242
|
+
} else if (refresh) {
|
|
243
|
+
const refreshToken = randomToken();
|
|
244
|
+
refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1e3, used: false, revoked: false, scope });
|
|
245
|
+
body.refresh_token = refreshToken;
|
|
246
|
+
if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = refreshIdle;
|
|
247
|
+
}
|
|
248
|
+
return body;
|
|
249
|
+
};
|
|
250
|
+
async function token(form) {
|
|
251
|
+
if (form.grant_type === "authorization_code") {
|
|
252
|
+
const row = form.code ? codes.get(form.code) : void 0;
|
|
253
|
+
if (form.code) codes.delete(form.code);
|
|
254
|
+
if (!row) return oauthError("invalid_grant", "Invalid, expired, or already-redeemed authorization code");
|
|
255
|
+
if (row.clientId !== form.client_id) return oauthError("invalid_grant", "client_id does not match the code");
|
|
256
|
+
if (row.redirectUri !== form.redirect_uri) return oauthError("invalid_grant", "redirect_uri mismatch");
|
|
257
|
+
if (!form.code_verifier || await pkceChallenge(form.code_verifier) !== row.challenge) {
|
|
258
|
+
return oauthError("invalid_grant", "PKCE verification failed");
|
|
259
|
+
}
|
|
260
|
+
if (form.resource !== void 0 && row.resource && form.resource !== row.resource) {
|
|
261
|
+
return oauthError("invalid_target", "resource does not match the authorization request");
|
|
262
|
+
}
|
|
263
|
+
for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
|
|
264
|
+
return json(200, tokenBody(row.scope, row.clientId, randomToken()));
|
|
265
|
+
}
|
|
266
|
+
if (form.grant_type === "refresh_token") {
|
|
267
|
+
if (!refresh) return oauthError("unsupported_grant_type", 'Only grant_type "authorization_code" is supported');
|
|
268
|
+
if (!form.refresh_token || !form.client_id) return oauthError("invalid_request", "refresh_token and client_id are required");
|
|
269
|
+
const row = refreshTokens.get(form.refresh_token);
|
|
270
|
+
if (!row || row.clientId !== form.client_id || row.revoked) {
|
|
271
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
272
|
+
}
|
|
273
|
+
if (row.used) {
|
|
274
|
+
const successor = row.successor ? refreshTokens.get(row.successor) : void 0;
|
|
275
|
+
const key = row.pairAccess ? access.get(row.pairAccess) : void 0;
|
|
276
|
+
const inGrace = graceMs > 0 && row.usedAt !== void 0 && now() - row.usedAt < graceMs;
|
|
277
|
+
if (inGrace && successor && !successor.used && !successor.revoked && key && !key.revoked && key.expiresAt > now()) {
|
|
278
|
+
graceAnswers++;
|
|
279
|
+
return json(200, tokenBody(row.scope, row.clientId, row.family, { accessToken: row.pairAccess, refreshToken: row.successor }));
|
|
280
|
+
}
|
|
281
|
+
reuses++;
|
|
282
|
+
revokeFamily(row.family);
|
|
283
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
284
|
+
}
|
|
285
|
+
if (row.idleUntil <= now()) return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
286
|
+
if (loseNextRace) {
|
|
287
|
+
loseNextRace = false;
|
|
288
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
289
|
+
}
|
|
290
|
+
row.used = true;
|
|
291
|
+
row.usedAt = now();
|
|
292
|
+
for (const a of access.values()) if (a.family === row.family) a.revoked = true;
|
|
293
|
+
const body = tokenBody(row.scope, row.clientId, row.family);
|
|
294
|
+
row.successor = body.refresh_token;
|
|
295
|
+
row.pairAccess = body.access_token;
|
|
296
|
+
return json(200, body);
|
|
297
|
+
}
|
|
298
|
+
return oauthError("unsupported_grant_type", "Unsupported grant_type");
|
|
299
|
+
}
|
|
300
|
+
function hub(method, path, query, headers, body) {
|
|
301
|
+
const failure2 = options.failures?.[path];
|
|
302
|
+
if (failure2 === "unreachable") return "reject";
|
|
303
|
+
const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
|
|
304
|
+
const row = bearer ? access.get(bearer) : void 0;
|
|
305
|
+
if (!row || row.revoked || row.expiresAt <= now()) return json(401, { error: "Unauthorized" });
|
|
306
|
+
switch (failure2) {
|
|
307
|
+
case "rate_limited":
|
|
308
|
+
return json(429, { error: "Too many requests" }, { "Retry-After": "30" });
|
|
309
|
+
case "forbidden":
|
|
310
|
+
return json(403, { error: "Forbidden" });
|
|
311
|
+
case "door_unsupported":
|
|
312
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
313
|
+
case "server":
|
|
314
|
+
return json(500, { error: "Internal error" });
|
|
315
|
+
case "unavailable":
|
|
316
|
+
return json(503, { error: "Unavailable" });
|
|
317
|
+
}
|
|
318
|
+
if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
|
|
319
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
320
|
+
}
|
|
321
|
+
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
322
|
+
if (method === "GET" && path === "/entities") {
|
|
323
|
+
const ws = query.get("workspaceId");
|
|
324
|
+
const kind = query.get("profileSlug");
|
|
325
|
+
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
326
|
+
const limit = Number(query.get("limit") ?? 50) || 50;
|
|
327
|
+
const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
|
|
328
|
+
return json(200, rows.slice(offset, offset + limit));
|
|
329
|
+
}
|
|
330
|
+
if (method === "GET" && path === "/proposals") {
|
|
331
|
+
const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
|
|
332
|
+
const status = query.get("status") ?? "pending";
|
|
333
|
+
const ws = query.get("workspaceId");
|
|
334
|
+
const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
|
|
335
|
+
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
336
|
+
const rows = proposals.filter((p) => p.workspaceId !== null && readable.has(p.workspaceId)).filter((p) => (status === "all" || p.status === status) && (!ws || p.workspaceId === ws)).reverse();
|
|
337
|
+
const page = rows.slice(offset, offset + limit);
|
|
338
|
+
return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
|
|
339
|
+
}
|
|
340
|
+
if (method !== "GET") {
|
|
341
|
+
writes.push({ method, path, body });
|
|
342
|
+
const fields = body && typeof body === "object" ? body : {};
|
|
343
|
+
const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
|
|
344
|
+
if ((options.writeMode ?? "proposed") === "proposed") {
|
|
345
|
+
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
346
|
+
proposals.push({
|
|
347
|
+
id: proposalId,
|
|
348
|
+
proposalType: method === "POST" ? "create" : method === "DELETE" ? "delete" : "update",
|
|
349
|
+
targetType: path.split("/")[1]?.replace(/s$/, "") ?? "entity",
|
|
350
|
+
targetId: null,
|
|
351
|
+
status: "pending",
|
|
352
|
+
workspaceId,
|
|
353
|
+
createdAt: new Date(now()).toISOString(),
|
|
354
|
+
correlationId: null,
|
|
355
|
+
sessionId: null,
|
|
356
|
+
agentUserId: MOCK_APP_AGENT_ID,
|
|
357
|
+
...typeof fields.title === "string" ? { summary: `Create "${fields.title}"` } : {}
|
|
358
|
+
});
|
|
359
|
+
return json(200, {
|
|
360
|
+
status: "proposed",
|
|
361
|
+
message: "Sent for review",
|
|
362
|
+
proposalId,
|
|
363
|
+
reviewUrl: `${issuer}/open/${proposalId}`
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const id = `ent_mock_${writes.length}`;
|
|
367
|
+
if (method === "POST" && path === "/entities") entities.push({ id, ...fields });
|
|
368
|
+
return json(200, { status: "created", id });
|
|
369
|
+
}
|
|
370
|
+
return json(404, { error: "Not found" });
|
|
371
|
+
}
|
|
372
|
+
const mockFetch = async (input, init) => {
|
|
373
|
+
const request = typeof input === "object" && "method" in input ? input : null;
|
|
374
|
+
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
375
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
376
|
+
const headers = {};
|
|
377
|
+
new Headers(init?.headers ?? request?.headers).forEach((value, name) => {
|
|
378
|
+
headers[name.toLowerCase()] = value;
|
|
379
|
+
});
|
|
380
|
+
const rawBody = typeof init?.body === "string" ? init.body : request ? await request.clone().text() : "";
|
|
381
|
+
const isForm = (headers["content-type"] ?? "").includes("application/x-www-form-urlencoded");
|
|
382
|
+
const form = isForm ? Object.fromEntries(new URLSearchParams(rawBody)) : null;
|
|
383
|
+
requests.push({ method, url: url.href, headers, form });
|
|
384
|
+
if (url.origin !== new URL(issuer).origin) throw new TypeError(`Failed to fetch: ${url.origin} is not the mock pod`);
|
|
385
|
+
const path = url.pathname;
|
|
386
|
+
if (method === "GET" && path === "/.well-known/oauth-authorization-server") return json(200, metadata());
|
|
387
|
+
if (method === "POST" && path === "/token") return token(form ?? {});
|
|
388
|
+
if (method === "POST" && path === "/revoke") {
|
|
389
|
+
const t = form?.token ?? "";
|
|
390
|
+
const r = refreshTokens.get(t);
|
|
391
|
+
if (r && form?.client_id && form.client_id !== r.clientId) {
|
|
392
|
+
return oauthError("unauthorized_client", "The token was not issued to this client");
|
|
393
|
+
}
|
|
394
|
+
if (r) revokeFamily(r.family);
|
|
395
|
+
const a = access.get(t);
|
|
396
|
+
if (a) a.revoked = true;
|
|
397
|
+
return new Response(null, { status: 200 });
|
|
398
|
+
}
|
|
399
|
+
const hubPath = /^\/api\/hub(\/.*)$/.exec(path)?.[1];
|
|
400
|
+
if (hubPath) {
|
|
401
|
+
let body = void 0;
|
|
402
|
+
try {
|
|
403
|
+
body = rawBody ? JSON.parse(rawBody) : void 0;
|
|
404
|
+
} catch {
|
|
405
|
+
body = rawBody;
|
|
406
|
+
}
|
|
407
|
+
const res = hub(method, hubPath, url.searchParams, headers, body);
|
|
408
|
+
if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
|
|
409
|
+
return res;
|
|
410
|
+
}
|
|
411
|
+
return json(404, { error: "Not found" });
|
|
412
|
+
};
|
|
413
|
+
return {
|
|
414
|
+
issuer,
|
|
415
|
+
fetch: mockFetch,
|
|
416
|
+
requests,
|
|
417
|
+
writes,
|
|
418
|
+
proposals,
|
|
419
|
+
settleProposal(id, status) {
|
|
420
|
+
const p = proposals.find((x) => x.id === id);
|
|
421
|
+
if (p) p.status = status;
|
|
422
|
+
},
|
|
423
|
+
async redeemElsewhere(t) {
|
|
424
|
+
const row = refreshTokens.get(t);
|
|
425
|
+
const res = await token({ grant_type: "refresh_token", refresh_token: t, client_id: row?.clientId ?? "" });
|
|
426
|
+
if (res.status !== 200) throw new Error(`redeemElsewhere: the pod answered ${res.status}`);
|
|
427
|
+
return await res.json();
|
|
428
|
+
},
|
|
429
|
+
get graceAnswers() {
|
|
430
|
+
return graceAnswers;
|
|
431
|
+
},
|
|
432
|
+
authorize(authorizeUrl, decision = {}) {
|
|
433
|
+
const url = new URL(authorizeUrl);
|
|
434
|
+
if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
|
|
435
|
+
const q = url.searchParams;
|
|
436
|
+
const redirectUri = q.get("redirect_uri");
|
|
437
|
+
const clientId = q.get("client_id");
|
|
438
|
+
if (!clientId || !redirectUri) throw new Error("Authorization request rejected: client_id and redirect_uri are required");
|
|
439
|
+
const answer = new URL(redirectUri);
|
|
440
|
+
const state = decision.state ?? q.get("state");
|
|
441
|
+
const finish = (fields) => {
|
|
442
|
+
for (const [k, v] of Object.entries(fields)) answer.searchParams.set(k, v);
|
|
443
|
+
if (state !== null) answer.searchParams.set("state", state);
|
|
444
|
+
const iss = decision.iss === void 0 ? issuer : decision.iss;
|
|
445
|
+
if (iss !== null) answer.searchParams.set("iss", iss);
|
|
446
|
+
return answer.href;
|
|
447
|
+
};
|
|
448
|
+
if (q.get("response_type") !== "code") return finish({ error: "unsupported_response_type" });
|
|
449
|
+
if (q.get("code_challenge_method") !== "S256" || !q.get("code_challenge")) {
|
|
450
|
+
return finish({ error: "invalid_request", error_description: "PKCE S256 is required" });
|
|
451
|
+
}
|
|
452
|
+
const resource = q.get("resource");
|
|
453
|
+
if (resource !== null && resource !== `${issuer}/api/hub` && resource !== `${issuer}/mcp`) {
|
|
454
|
+
return finish({ error: "invalid_target" });
|
|
455
|
+
}
|
|
456
|
+
const scope = (q.get("scope") ?? "").split(/\s+/).filter(Boolean);
|
|
457
|
+
if (scope.length === 0) return finish({ error: "invalid_scope" });
|
|
458
|
+
if (decision.deny) return finish({ error: "access_denied" });
|
|
459
|
+
const code = randomToken();
|
|
460
|
+
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope });
|
|
461
|
+
return finish({ code });
|
|
462
|
+
},
|
|
463
|
+
consumeRefreshToken(t) {
|
|
464
|
+
const row = refreshTokens.get(t);
|
|
465
|
+
if (row) row.used = true;
|
|
466
|
+
},
|
|
467
|
+
loseNextRefreshRace() {
|
|
468
|
+
loseNextRace = true;
|
|
469
|
+
},
|
|
470
|
+
get reuses() {
|
|
471
|
+
return reuses;
|
|
472
|
+
},
|
|
473
|
+
revokeAll() {
|
|
474
|
+
for (const row of access.values()) row.revoked = true;
|
|
475
|
+
for (const row of refreshTokens.values()) row.revoked = true;
|
|
476
|
+
},
|
|
477
|
+
liveRefreshTokens() {
|
|
478
|
+
return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
|
|
479
|
+
},
|
|
480
|
+
reset() {
|
|
481
|
+
requests.length = 0;
|
|
482
|
+
writes.length = 0;
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/testing/cloud.ts
|
|
488
|
+
var MOCK_CLOUD_URL = "https://cp.mock-synap.test";
|
|
489
|
+
var STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
|
|
490
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
491
|
+
function createMockCloud(options = {}) {
|
|
492
|
+
const base = (options.url ?? MOCK_CLOUD_URL).replace(/\/+$/, "");
|
|
493
|
+
const requests = [];
|
|
494
|
+
return {
|
|
495
|
+
url: base,
|
|
496
|
+
requests,
|
|
497
|
+
choose(location, { pod, metadata }) {
|
|
498
|
+
const back = check(location, metadata);
|
|
499
|
+
back.searchParams.set("iss", pod);
|
|
500
|
+
back.searchParams.set("state", new URL(location).searchParams.get("state"));
|
|
501
|
+
return back.href;
|
|
502
|
+
},
|
|
503
|
+
cancel(location, { metadata }) {
|
|
504
|
+
const back = check(location, metadata);
|
|
505
|
+
back.searchParams.set("state", new URL(location).searchParams.get("state"));
|
|
506
|
+
back.searchParams.set("error", "access_denied");
|
|
507
|
+
return back.href;
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
function check(location, metadata) {
|
|
511
|
+
requests.push(location);
|
|
512
|
+
const url = new URL(location);
|
|
513
|
+
if (`${url.origin}${url.pathname}` !== `${base}/connect/pod`) throw new Error(`Not this CP's /connect/pod: ${location}`);
|
|
514
|
+
const clientId = url.searchParams.get("client_id");
|
|
515
|
+
const state = url.searchParams.get("state");
|
|
516
|
+
if (!clientId || !cimdUrlAccepted(clientId)) throw new Error("Chooser refused: client_id must be an https CIMD URL");
|
|
517
|
+
if (!state || !STATE_RE.test(state)) throw new Error("Chooser refused: state is missing or malformed");
|
|
518
|
+
if (clientId !== metadata.client_id) throw new Error("Chooser refused: the client metadata is not this client_id's");
|
|
519
|
+
return new URL(metadata.initiate_login_uri);
|
|
520
|
+
}
|
|
521
|
+
function cimdUrlAccepted(clientId) {
|
|
522
|
+
let url;
|
|
523
|
+
try {
|
|
524
|
+
url = new URL(clientId);
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
if (url.protocol === "https:") return true;
|
|
529
|
+
return url.protocol === "http:" && options.allowInsecureLoopback === true && LOOPBACK_HOSTS.has(url.hostname);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
129
533
|
// src/testing/index.ts
|
|
130
534
|
var MOCK_POD_URL = "https://mock.synap.invalid";
|
|
131
535
|
var MOCK_TOKENS = { form: "demo-form", share: "demo", site: "demo-site" };
|
|
@@ -160,7 +564,7 @@ var MAX_OFFSET = 1e4;
|
|
|
160
564
|
var MAX_LIMIT = 100;
|
|
161
565
|
var MAX_SORT_KEYS = 3;
|
|
162
566
|
var PUBLISHED_AT = "publishedAt";
|
|
163
|
-
function
|
|
567
|
+
function json2(status, body, headers = {}) {
|
|
164
568
|
return new Response(JSON.stringify(body), {
|
|
165
569
|
status,
|
|
166
570
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-cache", ...headers }
|
|
@@ -183,15 +587,15 @@ function etagMatches(header, etag) {
|
|
|
183
587
|
function failure(token) {
|
|
184
588
|
switch (token) {
|
|
185
589
|
case MOCK_FAILURE_TOKENS.rateLimited:
|
|
186
|
-
return
|
|
590
|
+
return json2(429, { error: "Too many requests", retryAfter: "5 minutes" });
|
|
187
591
|
case MOCK_FAILURE_TOKENS.rateLimitedRetryAfter:
|
|
188
|
-
return
|
|
592
|
+
return json2(429, { error: "Too many requests" }, { "Retry-After": String(MOCK_RETRY_AFTER_SEC) });
|
|
189
593
|
case MOCK_FAILURE_TOKENS.doorsDisabled:
|
|
190
|
-
return
|
|
594
|
+
return json2(403, DOORS_DISABLED);
|
|
191
595
|
case MOCK_FAILURE_TOKENS.serverError:
|
|
192
|
-
return
|
|
596
|
+
return json2(500, { error: "Internal error" });
|
|
193
597
|
case MOCK_FAILURE_TOKENS.unavailable:
|
|
194
|
-
return
|
|
598
|
+
return json2(503, { received: false });
|
|
195
599
|
case MOCK_FAILURE_TOKENS.unreachable:
|
|
196
600
|
return "reject";
|
|
197
601
|
default:
|
|
@@ -266,17 +670,17 @@ function cached(view, ifNoneMatch) {
|
|
|
266
670
|
if (etagMatches(ifNoneMatch, etag)) {
|
|
267
671
|
return new Response(null, { status: 304, headers: { ETag: etag, "Cache-Control": "no-cache" } });
|
|
268
672
|
}
|
|
269
|
-
return
|
|
673
|
+
return json2(200, view, { ETag: etag });
|
|
270
674
|
}
|
|
271
675
|
function siteDoor(site, rest, params, ifNoneMatch) {
|
|
272
676
|
if (rest.length === 0) return cached(manifest(site), ifNoneMatch);
|
|
273
677
|
if (rest[0] !== "collections" || rest.length > 3 || !Object.hasOwn(site.collections, rest[1])) {
|
|
274
|
-
return
|
|
678
|
+
return json2(404, NOT_FOUND);
|
|
275
679
|
}
|
|
276
680
|
const c = site.collections[rest[1]];
|
|
277
681
|
if (rest.length === 2) {
|
|
278
682
|
const q = parseQuery(params, c);
|
|
279
|
-
if (typeof q === "string") return
|
|
683
|
+
if (typeof q === "string") return json2(400, { error: q });
|
|
280
684
|
const sorted = [...c.records].sort(compareRecords(q.sort));
|
|
281
685
|
const page = {
|
|
282
686
|
items: sorted.slice(q.offset, q.offset + q.limit).map(summary),
|
|
@@ -288,10 +692,10 @@ function siteDoor(site, rest, params, ifNoneMatch) {
|
|
|
288
692
|
}
|
|
289
693
|
const address = rest[2];
|
|
290
694
|
const byPublicId = PUBLIC_ID_RE.test(address);
|
|
291
|
-
if (!byPublicId && !(address.length <= SLUG_MAX_LENGTH && SLUG_RE.test(address))) return
|
|
695
|
+
if (!byPublicId && !(address.length <= SLUG_MAX_LENGTH && SLUG_RE.test(address))) return json2(404, NOT_FOUND);
|
|
292
696
|
const record = c.records.find((r) => byPublicId ? r.publicId === address : r.slug === address);
|
|
293
|
-
if (!record) return
|
|
294
|
-
if (params.has("embed")) return
|
|
697
|
+
if (!record) return json2(404, NOT_FOUND);
|
|
698
|
+
if (params.has("embed")) return json2(400, { error: "embed is not available" });
|
|
295
699
|
return cached(record, ifNoneMatch);
|
|
296
700
|
}
|
|
297
701
|
function invalidKeys(form, body) {
|
|
@@ -328,12 +732,12 @@ function createMockPod(options = {}) {
|
|
|
328
732
|
if (preflighted.length > 0) throw new TypeError(`Failed to fetch: preflight refuses ${preflighted.join(", ")}`);
|
|
329
733
|
}
|
|
330
734
|
const m = url.pathname.match(/^\/api\/hub\/public\/(forms|shares|sites)\/(.+)$/);
|
|
331
|
-
if (!m) return
|
|
735
|
+
if (!m) return json2(404, NOT_FOUND);
|
|
332
736
|
let segments;
|
|
333
737
|
try {
|
|
334
738
|
segments = m[2].split("/").map(decodeURIComponent);
|
|
335
739
|
} catch {
|
|
336
|
-
return
|
|
740
|
+
return json2(404, NOT_FOUND);
|
|
337
741
|
}
|
|
338
742
|
const [token, ...rest] = segments;
|
|
339
743
|
const failed = failure(token);
|
|
@@ -351,15 +755,15 @@ function createMockPod(options = {}) {
|
|
|
351
755
|
const invalid = form ? invalidKeys(form, body) : [];
|
|
352
756
|
const status = invalid.length > 0 ? 422 : 202;
|
|
353
757
|
submissions.push({ token, body, status });
|
|
354
|
-
return status === 422 ?
|
|
758
|
+
return status === 422 ? json2(422, { received: false, invalid }) : json2(202, { received: true });
|
|
355
759
|
}
|
|
356
|
-
if (method !== "GET") return
|
|
357
|
-
if (door === "forms" && rest.length === 0 && Object.hasOwn(forms, token)) return
|
|
358
|
-
if (door === "shares" && rest.length === 0 && Object.hasOwn(shares, token)) return
|
|
760
|
+
if (method !== "GET") return json2(404, NOT_FOUND);
|
|
761
|
+
if (door === "forms" && rest.length === 0 && Object.hasOwn(forms, token)) return json2(200, forms[token]);
|
|
762
|
+
if (door === "shares" && rest.length === 0 && Object.hasOwn(shares, token)) return json2(200, shares[token]);
|
|
359
763
|
if (door === "sites" && Object.hasOwn(sites, token)) {
|
|
360
764
|
return siteDoor(sites[token], rest, url.searchParams, headers["if-none-match"] ?? null);
|
|
361
765
|
}
|
|
362
|
-
return
|
|
766
|
+
return json2(404, NOT_FOUND);
|
|
363
767
|
};
|
|
364
768
|
return {
|
|
365
769
|
podUrl: MOCK_POD_URL,
|
|
@@ -374,13 +778,21 @@ function createMockPod(options = {}) {
|
|
|
374
778
|
}
|
|
375
779
|
// Annotate the CommonJS export names for ESM import in node:
|
|
376
780
|
0 && (module.exports = {
|
|
781
|
+
MOCK_APP,
|
|
782
|
+
MOCK_APP_AGENT_ID,
|
|
783
|
+
MOCK_AUTH_ISSUER,
|
|
784
|
+
MOCK_CLOUD_URL,
|
|
377
785
|
MOCK_FAILURE_TOKENS,
|
|
378
786
|
MOCK_FORM,
|
|
787
|
+
MOCK_GRANT,
|
|
379
788
|
MOCK_POD_URL,
|
|
380
789
|
MOCK_RETRY_AFTER_SEC,
|
|
381
790
|
MOCK_SHARE,
|
|
382
791
|
MOCK_SITE,
|
|
383
792
|
MOCK_TOKENS,
|
|
793
|
+
MOCK_USER,
|
|
794
|
+
createMockAuthServer,
|
|
795
|
+
createMockCloud,
|
|
384
796
|
createMockPod
|
|
385
797
|
});
|
|
386
798
|
//# sourceMappingURL=testing.cjs.map
|