@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
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mock pod for pod-app sign-in: its OAuth authorization server (RFC 8414
|
|
3
|
+
* metadata, /authorize as a function standing in for the consent screen,
|
|
4
|
+
* /token, /revoke) and a few Hub REST doors behind the Bearer token. Inject
|
|
5
|
+
* `fetch` into the REAL `@synap-core/sdk/connect` (`createPodAppAuth({ fetch })`)
|
|
6
|
+
* so everything above the wire is the production path. No network.
|
|
7
|
+
*
|
|
8
|
+
* Wire behaviour mirrors synap-backend `routers/oauth/routes.ts` for a CIMD
|
|
9
|
+
* client: `iss` on every authorization answer (RFC 9207), PKCE S256 checked at
|
|
10
|
+
* /token, single-use codes bound to client_id + redirect_uri + resource, the
|
|
11
|
+
* token response's `synap_grant` / `synap_user`. Refresh mirrors PB3
|
|
12
|
+
* (`routers/oauth/refresh.ts`): rotating and single-use, 30 days from issue;
|
|
13
|
+
* a token presented AGAIN after its use is a reuse that revokes its whole
|
|
14
|
+
* family AND the live access key; a new code ends the client's older
|
|
15
|
+
* families; /revoke with a refresh token kills its family and access key.
|
|
16
|
+
* The reuse grace (PB3b): within 30 s of its use, a spent token presented
|
|
17
|
+
* again with its own client_id gets the SAME pair its first use got (the
|
|
18
|
+
* successor refresh token and the live key, `expires_in` = what that key has
|
|
19
|
+
* left), as long as the successor is unused; past it, a reuse.
|
|
20
|
+
* `refresh: false` is a pod before PB3 (no refresh_token at all).
|
|
21
|
+
*
|
|
22
|
+
* Hub REST: `GET /users/me`, `GET /entities` (an ARRAY of entities, as the pod
|
|
23
|
+
* answers; the seeds plus what applied writes created), `GET /proposals`
|
|
24
|
+
* (`{ proposals, limit, offset, total, hasMore }`, rows only in spaces where
|
|
25
|
+
* the token holds `proposal.read`, as the pod's grant clause), governed writes.
|
|
26
|
+
*
|
|
27
|
+
* mock.authorize(authorizeUrl) → the callback URL (approved)
|
|
28
|
+
* mock.authorize(authorizeUrl, { deny: true }) → error=access_denied
|
|
29
|
+
* mock.authorize(authorizeUrl, { iss: "https://evil.example" }) → a mix-up attempt
|
|
30
|
+
* mock.loseNextRefreshRace() → the next redeem is a concurrent loser that found no successor (refused, no revoke)
|
|
31
|
+
* mock.redeemElsewhere(t) → another app instance rotated t (presenting it within 30 s gets that pair)
|
|
32
|
+
* mock.consumeRefreshToken(t) → the token was used elsewhere with no successor (presenting it is a reuse)
|
|
33
|
+
* mock.revokeAll() → the person removed the app in their pod
|
|
34
|
+
*/
|
|
35
|
+
import { pkceChallenge, randomToken } from "../connect/encoding";
|
|
36
|
+
import type { PodGrant, PodUser } from "../connect/types";
|
|
37
|
+
|
|
38
|
+
/** A placeholder pod origin; never contacted. */
|
|
39
|
+
export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
40
|
+
|
|
41
|
+
export const MOCK_GRANT: PodGrant[] = [
|
|
42
|
+
{ permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
43
|
+
{ permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export const MOCK_USER: PodUser = { id: "user_mock_ada", name: "Ada Lovelace" };
|
|
47
|
+
|
|
48
|
+
export type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
|
|
49
|
+
|
|
50
|
+
export interface MockAuthServerOptions {
|
|
51
|
+
issuer?: string;
|
|
52
|
+
/** Issue rotating refresh tokens (PB3). Default true; false = a pod before PB3. */
|
|
53
|
+
refresh?: boolean;
|
|
54
|
+
/** Default 3600 (the pod's 1 h). */
|
|
55
|
+
accessTtlSec?: number;
|
|
56
|
+
/** Default 30 days. */
|
|
57
|
+
refreshIdleSec?: number;
|
|
58
|
+
/** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
|
|
59
|
+
advertiseRefreshExpiry?: boolean;
|
|
60
|
+
/** The reuse grace, seconds (PB3b). Default 30 (the pod's); 0 = off (a pod before PB3b). */
|
|
61
|
+
refreshGraceSec?: number;
|
|
62
|
+
/**
|
|
63
|
+
* `synap_app` on token responses: the connection's id and its page on the
|
|
64
|
+
* pod console. Default `MOCK_APP`; null = a pod that does not send it.
|
|
65
|
+
*/
|
|
66
|
+
app?: { id: string; manageUrl: string | null } | null;
|
|
67
|
+
/** Entities `GET /entities` answers (with `workspaceId` / `profileSlug` filters). */
|
|
68
|
+
entities?: Array<Record<string, unknown>>;
|
|
69
|
+
grant?: PodGrant[];
|
|
70
|
+
user?: PodUser;
|
|
71
|
+
/** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
|
|
72
|
+
metadata?: Record<string, unknown>;
|
|
73
|
+
/** Hub writes: "proposed" (ask-first, the default for an app) or "applied". */
|
|
74
|
+
writeMode?: "proposed" | "applied";
|
|
75
|
+
/** Hub paths (after /api/hub) that answer one failure. */
|
|
76
|
+
failures?: Record<string, MockHubFailure>;
|
|
77
|
+
now?: () => number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MockAuthRequest {
|
|
81
|
+
method: string;
|
|
82
|
+
url: string;
|
|
83
|
+
/** Lower-cased header names. */
|
|
84
|
+
headers: Record<string, string>;
|
|
85
|
+
/** The form body of a /token or /revoke call. */
|
|
86
|
+
form: Record<string, string> | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AuthorizeDecision {
|
|
90
|
+
/** The person said no. */
|
|
91
|
+
deny?: boolean;
|
|
92
|
+
/** Override `iss` on the answer (null = omit it): a mix-up attack. */
|
|
93
|
+
iss?: string | null;
|
|
94
|
+
/** Override `state` on the answer: a forged answer. */
|
|
95
|
+
state?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A proposal a governed write filed (`GET /proposals` lists the pending ones). */
|
|
99
|
+
export interface MockProposal {
|
|
100
|
+
id: string;
|
|
101
|
+
proposalType: string;
|
|
102
|
+
targetType: string;
|
|
103
|
+
targetId: string | null;
|
|
104
|
+
status: "pending" | "approved" | "rejected";
|
|
105
|
+
workspaceId: string | null;
|
|
106
|
+
createdAt: string;
|
|
107
|
+
correlationId: string | null;
|
|
108
|
+
sessionId: string | null;
|
|
109
|
+
agentUserId: string | null;
|
|
110
|
+
summary?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The connection's `synap_app` the mock pod answers by default (backend `appTokenResponse`). */
|
|
114
|
+
export const MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
|
|
115
|
+
|
|
116
|
+
/** The app's agent on the mock pod: who files its proposals. */
|
|
117
|
+
export const MOCK_APP_AGENT_ID = "agent_mock_app";
|
|
118
|
+
|
|
119
|
+
export interface MockAuthServer {
|
|
120
|
+
issuer: string;
|
|
121
|
+
fetch: typeof fetch;
|
|
122
|
+
requests: MockAuthRequest[];
|
|
123
|
+
/** Hub writes seen, in order. */
|
|
124
|
+
writes: Array<{ method: string; path: string; body: unknown }>;
|
|
125
|
+
/** Proposals filed by proposed writes, in order. */
|
|
126
|
+
proposals: MockProposal[];
|
|
127
|
+
/** The person approved or rejected one: it leaves the pending list. */
|
|
128
|
+
settleProposal(id: string, status: "approved" | "rejected"): void;
|
|
129
|
+
/** Another instance of the app rotated `token` (its own refresh): returns the pair the pod answered it. */
|
|
130
|
+
redeemElsewhere(token: string): Promise<{ access_token: string; refresh_token: string; expires_in: number }>;
|
|
131
|
+
/** Refresh redeems answered within the reuse grace (the same pair again). */
|
|
132
|
+
graceAnswers: number;
|
|
133
|
+
/** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
|
|
134
|
+
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
135
|
+
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
136
|
+
consumeRefreshToken(token: string): void;
|
|
137
|
+
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
138
|
+
loseNextRefreshRace(): void;
|
|
139
|
+
/** Refresh-token redeems that were reuses (each revoked a family). */
|
|
140
|
+
reuses: number;
|
|
141
|
+
/** The person revoked the app: every token stops working. */
|
|
142
|
+
revokeAll(): void;
|
|
143
|
+
/** Live (unrevoked) refresh tokens. */
|
|
144
|
+
liveRefreshTokens(): string[];
|
|
145
|
+
reset(): void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface CodeRow {
|
|
149
|
+
clientId: string;
|
|
150
|
+
redirectUri: string;
|
|
151
|
+
challenge: string;
|
|
152
|
+
resource: string | null;
|
|
153
|
+
scope: string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface AccessRow {
|
|
157
|
+
expiresAt: number;
|
|
158
|
+
revoked: boolean;
|
|
159
|
+
family: string | null;
|
|
160
|
+
scope: string[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface RefreshRow {
|
|
164
|
+
clientId: string;
|
|
165
|
+
family: string;
|
|
166
|
+
idleUntil: number;
|
|
167
|
+
used: boolean;
|
|
168
|
+
revoked: boolean;
|
|
169
|
+
scope: string[];
|
|
170
|
+
/** When it was spent, and the pair that use was answered (the grace re-answers it). */
|
|
171
|
+
usedAt?: number;
|
|
172
|
+
successor?: string;
|
|
173
|
+
pairAccess?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
|
177
|
+
return new Response(JSON.stringify(body), {
|
|
178
|
+
status,
|
|
179
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function oauthError(error: string, description: string, status = 400): Response {
|
|
184
|
+
return json(status, { error, error_description: description });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createMockAuthServer(options: MockAuthServerOptions = {}): MockAuthServer {
|
|
188
|
+
const issuer = options.issuer ?? MOCK_AUTH_ISSUER;
|
|
189
|
+
const now = options.now ?? Date.now;
|
|
190
|
+
const refresh = options.refresh ?? true;
|
|
191
|
+
const accessTtl = options.accessTtlSec ?? 3600;
|
|
192
|
+
const refreshIdle = options.refreshIdleSec ?? 30 * 24 * 3600;
|
|
193
|
+
const grant = options.grant ?? MOCK_GRANT;
|
|
194
|
+
const user = options.user ?? MOCK_USER;
|
|
195
|
+
const codes = new Map<string, CodeRow>();
|
|
196
|
+
const access = new Map<string, AccessRow>();
|
|
197
|
+
const refreshTokens = new Map<string, RefreshRow>();
|
|
198
|
+
const requests: MockAuthRequest[] = [];
|
|
199
|
+
const writes: Array<{ method: string; path: string; body: unknown }> = [];
|
|
200
|
+
const proposals: MockProposal[] = [];
|
|
201
|
+
const entities: Array<Record<string, unknown>> = [...(options.entities ?? [])];
|
|
202
|
+
const graceMs = (options.refreshGraceSec ?? 30) * 1000;
|
|
203
|
+
const app = options.app === undefined ? MOCK_APP : options.app;
|
|
204
|
+
let proposalSeq = 0;
|
|
205
|
+
let graceAnswers = 0;
|
|
206
|
+
|
|
207
|
+
const metadata = (): Record<string, unknown> => {
|
|
208
|
+
const base: Record<string, unknown> = {
|
|
209
|
+
issuer,
|
|
210
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
211
|
+
token_endpoint: `${issuer}/token`,
|
|
212
|
+
registration_endpoint: `${issuer}/register`,
|
|
213
|
+
revocation_endpoint: `${issuer}/revoke`,
|
|
214
|
+
revocation_endpoint_auth_methods_supported: ["none"],
|
|
215
|
+
response_types_supported: ["code"],
|
|
216
|
+
grant_types_supported: refresh ? ["authorization_code", "refresh_token"] : ["authorization_code"],
|
|
217
|
+
code_challenge_methods_supported: ["S256"],
|
|
218
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
219
|
+
authorization_response_iss_parameter_supported: true,
|
|
220
|
+
client_id_metadata_document_supported: true,
|
|
221
|
+
};
|
|
222
|
+
for (const [k, v] of Object.entries(options.metadata ?? {})) {
|
|
223
|
+
if (v === undefined) delete base[k];
|
|
224
|
+
else base[k] = v;
|
|
225
|
+
}
|
|
226
|
+
return base;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let loseNextRace = false;
|
|
230
|
+
let reuses = 0;
|
|
231
|
+
const revokeFamily = (family: string) => {
|
|
232
|
+
for (const r of refreshTokens.values()) if (r.family === family) r.revoked = true;
|
|
233
|
+
for (const a of access.values()) if (a.family === family) a.revoked = true;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** The token response; `pair` re-answers an existing access key + refresh token (the grace). */
|
|
237
|
+
const tokenBody = (scope: string[], clientId: string, family: string, pair?: { accessToken: string; refreshToken: string }) => {
|
|
238
|
+
const accessToken = pair?.accessToken ?? `synap_hub_mock_${randomToken()}`;
|
|
239
|
+
if (!pair) access.set(accessToken, { expiresAt: now() + accessTtl * 1000, revoked: false, family: refresh ? family : null, scope });
|
|
240
|
+
const row = access.get(accessToken)!;
|
|
241
|
+
const body: Record<string, unknown> = {
|
|
242
|
+
access_token: accessToken,
|
|
243
|
+
token_type: "Bearer",
|
|
244
|
+
// As the pod: what the key has left, never an assumed hour.
|
|
245
|
+
expires_in: Math.max(0, Math.floor((row.expiresAt - now()) / 1000)),
|
|
246
|
+
scope: scope.join(" "),
|
|
247
|
+
synap_grant: grant.filter((g) => scope.includes(g.permission)),
|
|
248
|
+
synap_user: user,
|
|
249
|
+
...(app ? { synap_app: app } : {}),
|
|
250
|
+
};
|
|
251
|
+
if (pair) {
|
|
252
|
+
body.refresh_token = pair.refreshToken;
|
|
253
|
+
if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = Math.floor((refreshTokens.get(pair.refreshToken)!.idleUntil - now()) / 1000);
|
|
254
|
+
} else if (refresh) {
|
|
255
|
+
const refreshToken = randomToken();
|
|
256
|
+
refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1000, used: false, revoked: false, scope });
|
|
257
|
+
body.refresh_token = refreshToken;
|
|
258
|
+
if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = refreshIdle;
|
|
259
|
+
}
|
|
260
|
+
return body;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
async function token(form: Record<string, string>): Promise<Response> {
|
|
264
|
+
if (form.grant_type === "authorization_code") {
|
|
265
|
+
const row = form.code ? codes.get(form.code) : undefined;
|
|
266
|
+
if (form.code) codes.delete(form.code); // claim first: a failed exchange burns the code
|
|
267
|
+
if (!row) return oauthError("invalid_grant", "Invalid, expired, or already-redeemed authorization code");
|
|
268
|
+
if (row.clientId !== form.client_id) return oauthError("invalid_grant", "client_id does not match the code");
|
|
269
|
+
if (row.redirectUri !== form.redirect_uri) return oauthError("invalid_grant", "redirect_uri mismatch");
|
|
270
|
+
if (!form.code_verifier || (await pkceChallenge(form.code_verifier)) !== row.challenge) {
|
|
271
|
+
return oauthError("invalid_grant", "PKCE verification failed");
|
|
272
|
+
}
|
|
273
|
+
if (form.resource !== undefined && row.resource && form.resource !== row.resource) {
|
|
274
|
+
return oauthError("invalid_target", "resource does not match the authorization request");
|
|
275
|
+
}
|
|
276
|
+
// The latest connect wins: a new code ends this client's older families.
|
|
277
|
+
for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
|
|
278
|
+
return json(200, tokenBody(row.scope, row.clientId, randomToken()));
|
|
279
|
+
}
|
|
280
|
+
if (form.grant_type === "refresh_token") {
|
|
281
|
+
if (!refresh) return oauthError("unsupported_grant_type", 'Only grant_type "authorization_code" is supported');
|
|
282
|
+
if (!form.refresh_token || !form.client_id) return oauthError("invalid_request", "refresh_token and client_id are required");
|
|
283
|
+
const row = refreshTokens.get(form.refresh_token);
|
|
284
|
+
if (!row || row.clientId !== form.client_id || row.revoked) {
|
|
285
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
286
|
+
}
|
|
287
|
+
if (row.used) {
|
|
288
|
+
// PB3b: the same pair again, inside the grace, while the successor is unused and its key lives.
|
|
289
|
+
const successor = row.successor ? refreshTokens.get(row.successor) : undefined;
|
|
290
|
+
const key = row.pairAccess ? access.get(row.pairAccess) : undefined;
|
|
291
|
+
const inGrace = graceMs > 0 && row.usedAt !== undefined && now() - row.usedAt < graceMs;
|
|
292
|
+
if (inGrace && successor && !successor.used && !successor.revoked && key && !key.revoked && key.expiresAt > now()) {
|
|
293
|
+
graceAnswers++;
|
|
294
|
+
return json(200, tokenBody(row.scope, row.clientId, row.family, { accessToken: row.pairAccess!, refreshToken: row.successor! }));
|
|
295
|
+
}
|
|
296
|
+
reuses++;
|
|
297
|
+
revokeFamily(row.family);
|
|
298
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
299
|
+
}
|
|
300
|
+
if (row.idleUntil <= now()) return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
301
|
+
if (loseNextRace) {
|
|
302
|
+
loseNextRace = false;
|
|
303
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
304
|
+
}
|
|
305
|
+
row.used = true;
|
|
306
|
+
row.usedAt = now();
|
|
307
|
+
// One live key per connection: the rotation retires the family's older keys.
|
|
308
|
+
for (const a of access.values()) if (a.family === row.family) a.revoked = true;
|
|
309
|
+
const body = tokenBody(row.scope, row.clientId, row.family);
|
|
310
|
+
row.successor = body.refresh_token as string;
|
|
311
|
+
row.pairAccess = body.access_token as string;
|
|
312
|
+
return json(200, body);
|
|
313
|
+
}
|
|
314
|
+
return oauthError("unsupported_grant_type", "Unsupported grant_type");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function hub(method: string, path: string, query: URLSearchParams, headers: Record<string, string>, body: unknown): Response | "reject" {
|
|
318
|
+
const failure = options.failures?.[path];
|
|
319
|
+
if (failure === "unreachable") return "reject";
|
|
320
|
+
const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
|
|
321
|
+
const row = bearer ? access.get(bearer) : undefined;
|
|
322
|
+
if (!row || row.revoked || row.expiresAt <= now()) return json(401, { error: "Unauthorized" });
|
|
323
|
+
switch (failure) {
|
|
324
|
+
case "rate_limited":
|
|
325
|
+
return json(429, { error: "Too many requests" }, { "Retry-After": "30" });
|
|
326
|
+
case "forbidden":
|
|
327
|
+
return json(403, { error: "Forbidden" });
|
|
328
|
+
case "door_unsupported":
|
|
329
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
330
|
+
case "server":
|
|
331
|
+
return json(500, { error: "Internal error" });
|
|
332
|
+
case "unavailable":
|
|
333
|
+
return json(503, { error: "Unavailable" });
|
|
334
|
+
}
|
|
335
|
+
// The grant fence (spec 17 §5): aggregate reads are refused to a scoped token.
|
|
336
|
+
if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
|
|
337
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
338
|
+
}
|
|
339
|
+
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
340
|
+
if (method === "GET" && path === "/entities") {
|
|
341
|
+
// The pod answers an ARRAY (rest/entities.ts), not a page object.
|
|
342
|
+
const ws = query.get("workspaceId");
|
|
343
|
+
const kind = query.get("profileSlug");
|
|
344
|
+
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
345
|
+
const limit = Number(query.get("limit") ?? 50) || 50;
|
|
346
|
+
const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
|
|
347
|
+
return json(200, rows.slice(offset, offset + limit));
|
|
348
|
+
}
|
|
349
|
+
if (method === "GET" && path === "/proposals") {
|
|
350
|
+
// The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
|
|
351
|
+
const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
|
|
352
|
+
const status = query.get("status") ?? "pending";
|
|
353
|
+
const ws = query.get("workspaceId");
|
|
354
|
+
const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
|
|
355
|
+
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
356
|
+
const rows = proposals
|
|
357
|
+
.filter((p) => p.workspaceId !== null && readable.has(p.workspaceId))
|
|
358
|
+
.filter((p) => (status === "all" || p.status === status) && (!ws || p.workspaceId === ws))
|
|
359
|
+
.reverse(); // newest first
|
|
360
|
+
const page = rows.slice(offset, offset + limit);
|
|
361
|
+
return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
|
|
362
|
+
}
|
|
363
|
+
if (method !== "GET") {
|
|
364
|
+
writes.push({ method, path, body });
|
|
365
|
+
const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
366
|
+
const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
|
|
367
|
+
if ((options.writeMode ?? "proposed") === "proposed") {
|
|
368
|
+
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
369
|
+
proposals.push({
|
|
370
|
+
id: proposalId,
|
|
371
|
+
proposalType: method === "POST" ? "create" : method === "DELETE" ? "delete" : "update",
|
|
372
|
+
targetType: path.split("/")[1]?.replace(/s$/, "") ?? "entity",
|
|
373
|
+
targetId: null,
|
|
374
|
+
status: "pending",
|
|
375
|
+
workspaceId,
|
|
376
|
+
createdAt: new Date(now()).toISOString(),
|
|
377
|
+
correlationId: null,
|
|
378
|
+
sessionId: null,
|
|
379
|
+
agentUserId: MOCK_APP_AGENT_ID,
|
|
380
|
+
...(typeof fields.title === "string" ? { summary: `Create "${fields.title}"` } : {}),
|
|
381
|
+
});
|
|
382
|
+
return json(200, {
|
|
383
|
+
status: "proposed",
|
|
384
|
+
message: "Sent for review",
|
|
385
|
+
proposalId,
|
|
386
|
+
reviewUrl: `${issuer}/open/${proposalId}`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const id = `ent_mock_${writes.length}`;
|
|
390
|
+
if (method === "POST" && path === "/entities") entities.push({ id, ...fields });
|
|
391
|
+
return json(200, { status: "created", id });
|
|
392
|
+
}
|
|
393
|
+
return json(404, { error: "Not found" });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const mockFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
397
|
+
const request = typeof input === "object" && "method" in input ? input : null;
|
|
398
|
+
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
399
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
400
|
+
const headers: Record<string, string> = {};
|
|
401
|
+
new Headers(init?.headers ?? request?.headers).forEach((value, name) => {
|
|
402
|
+
headers[name.toLowerCase()] = value;
|
|
403
|
+
});
|
|
404
|
+
const rawBody = typeof init?.body === "string" ? init.body : request ? await request.clone().text() : "";
|
|
405
|
+
const isForm = (headers["content-type"] ?? "").includes("application/x-www-form-urlencoded");
|
|
406
|
+
const form = isForm ? Object.fromEntries(new URLSearchParams(rawBody)) : null;
|
|
407
|
+
requests.push({ method, url: url.href, headers, form });
|
|
408
|
+
|
|
409
|
+
if (url.origin !== new URL(issuer).origin) throw new TypeError(`Failed to fetch: ${url.origin} is not the mock pod`);
|
|
410
|
+
const path = url.pathname;
|
|
411
|
+
if (method === "GET" && path === "/.well-known/oauth-authorization-server") return json(200, metadata());
|
|
412
|
+
if (method === "POST" && path === "/token") return token(form ?? {});
|
|
413
|
+
if (method === "POST" && path === "/revoke") {
|
|
414
|
+
const t = form?.token ?? "";
|
|
415
|
+
const r = refreshTokens.get(t);
|
|
416
|
+
if (r && form?.client_id && form.client_id !== r.clientId) {
|
|
417
|
+
return oauthError("unauthorized_client", "The token was not issued to this client");
|
|
418
|
+
}
|
|
419
|
+
if (r) revokeFamily(r.family);
|
|
420
|
+
const a = access.get(t);
|
|
421
|
+
if (a) a.revoked = true;
|
|
422
|
+
return new Response(null, { status: 200 });
|
|
423
|
+
}
|
|
424
|
+
const hubPath = /^\/api\/hub(\/.*)$/.exec(path)?.[1];
|
|
425
|
+
if (hubPath) {
|
|
426
|
+
let body: unknown = undefined;
|
|
427
|
+
try {
|
|
428
|
+
body = rawBody ? JSON.parse(rawBody) : undefined;
|
|
429
|
+
} catch {
|
|
430
|
+
body = rawBody;
|
|
431
|
+
}
|
|
432
|
+
const res = hub(method, hubPath, url.searchParams, headers, body);
|
|
433
|
+
if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
|
|
434
|
+
return res;
|
|
435
|
+
}
|
|
436
|
+
return json(404, { error: "Not found" });
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
issuer,
|
|
441
|
+
fetch: mockFetch as typeof fetch,
|
|
442
|
+
requests,
|
|
443
|
+
writes,
|
|
444
|
+
proposals,
|
|
445
|
+
settleProposal(id, status) {
|
|
446
|
+
const p = proposals.find((x) => x.id === id);
|
|
447
|
+
if (p) p.status = status;
|
|
448
|
+
},
|
|
449
|
+
async redeemElsewhere(t) {
|
|
450
|
+
const row = refreshTokens.get(t);
|
|
451
|
+
const res = await token({ grant_type: "refresh_token", refresh_token: t, client_id: row?.clientId ?? "" });
|
|
452
|
+
if (res.status !== 200) throw new Error(`redeemElsewhere: the pod answered ${res.status}`);
|
|
453
|
+
return (await res.json()) as { access_token: string; refresh_token: string; expires_in: number };
|
|
454
|
+
},
|
|
455
|
+
get graceAnswers() {
|
|
456
|
+
return graceAnswers;
|
|
457
|
+
},
|
|
458
|
+
authorize(authorizeUrl, decision = {}) {
|
|
459
|
+
const url = new URL(authorizeUrl);
|
|
460
|
+
if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
|
|
461
|
+
const q = url.searchParams;
|
|
462
|
+
const redirectUri = q.get("redirect_uri");
|
|
463
|
+
const clientId = q.get("client_id");
|
|
464
|
+
// Phase 1 (shown, never redirected): no verified redirect yet.
|
|
465
|
+
if (!clientId || !redirectUri) throw new Error("Authorization request rejected: client_id and redirect_uri are required");
|
|
466
|
+
const answer = new URL(redirectUri);
|
|
467
|
+
const state = decision.state ?? q.get("state");
|
|
468
|
+
const finish = (fields: Record<string, string>) => {
|
|
469
|
+
for (const [k, v] of Object.entries(fields)) answer.searchParams.set(k, v);
|
|
470
|
+
if (state !== null) answer.searchParams.set("state", state);
|
|
471
|
+
const iss = decision.iss === undefined ? issuer : decision.iss;
|
|
472
|
+
if (iss !== null) answer.searchParams.set("iss", iss);
|
|
473
|
+
return answer.href;
|
|
474
|
+
};
|
|
475
|
+
if (q.get("response_type") !== "code") return finish({ error: "unsupported_response_type" });
|
|
476
|
+
if (q.get("code_challenge_method") !== "S256" || !q.get("code_challenge")) {
|
|
477
|
+
return finish({ error: "invalid_request", error_description: "PKCE S256 is required" });
|
|
478
|
+
}
|
|
479
|
+
const resource = q.get("resource");
|
|
480
|
+
if (resource !== null && resource !== `${issuer}/api/hub` && resource !== `${issuer}/mcp`) {
|
|
481
|
+
return finish({ error: "invalid_target" });
|
|
482
|
+
}
|
|
483
|
+
const scope = (q.get("scope") ?? "").split(/\s+/).filter(Boolean);
|
|
484
|
+
if (scope.length === 0) return finish({ error: "invalid_scope" });
|
|
485
|
+
if (decision.deny) return finish({ error: "access_denied" });
|
|
486
|
+
const code = randomToken();
|
|
487
|
+
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge")!, resource, scope });
|
|
488
|
+
return finish({ code });
|
|
489
|
+
},
|
|
490
|
+
consumeRefreshToken(t) {
|
|
491
|
+
const row = refreshTokens.get(t);
|
|
492
|
+
if (row) row.used = true;
|
|
493
|
+
},
|
|
494
|
+
loseNextRefreshRace() {
|
|
495
|
+
loseNextRace = true;
|
|
496
|
+
},
|
|
497
|
+
get reuses() {
|
|
498
|
+
return reuses;
|
|
499
|
+
},
|
|
500
|
+
revokeAll() {
|
|
501
|
+
for (const row of access.values()) row.revoked = true;
|
|
502
|
+
for (const row of refreshTokens.values()) row.revoked = true;
|
|
503
|
+
},
|
|
504
|
+
liveRefreshTokens() {
|
|
505
|
+
return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
|
|
506
|
+
},
|
|
507
|
+
reset() {
|
|
508
|
+
requests.length = 0;
|
|
509
|
+
writes.length = 0;
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mock Synap Cloud pod chooser (Control Plane `GET /connect/pod`, wave C1)
|
|
3
|
+
* for "Continue with Synap Cloud" tests and a kit's mock mode. No network:
|
|
4
|
+
* `choose()` stands in for the person signing in to the CP and picking a pod.
|
|
5
|
+
*
|
|
6
|
+
* Wire behaviour mirrors the CP route (doc 18 §1.1): it accepts only
|
|
7
|
+
* `client_id` (an https CIMD URL; http on loopback only with
|
|
8
|
+
* `allowInsecureLoopback`, for a kit's mock mode on http://localhost) and
|
|
9
|
+
* `state` (`^[A-Za-z0-9._~-]{16,256}$`),
|
|
10
|
+
* takes the return URL from the app's client metadata (`initiate_login_uri`),
|
|
11
|
+
* never from the query, and answers `<initiate_login_uri>?iss=<pod issuer>&state=<state>`.
|
|
12
|
+
* It never sees a code or a token.
|
|
13
|
+
*
|
|
14
|
+
* const cloud = createMockCloud();
|
|
15
|
+
* createPodAppAuth({ ...config, cloudUrl: cloud.url });
|
|
16
|
+
* cloud.choose(location, { pod: mockPod.issuer, metadata: auth.metadata }) → the app's return URL
|
|
17
|
+
*/
|
|
18
|
+
import type { ClientMetadataDocument } from "../connect/types";
|
|
19
|
+
|
|
20
|
+
/** A placeholder Control Plane origin; never contacted. */
|
|
21
|
+
export const MOCK_CLOUD_URL = "https://cp.mock-synap.test";
|
|
22
|
+
|
|
23
|
+
const STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
|
|
24
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
25
|
+
|
|
26
|
+
export interface MockCloud {
|
|
27
|
+
url: string;
|
|
28
|
+
/** The chooser URLs the app sent browsers to. */
|
|
29
|
+
requests: string[];
|
|
30
|
+
/**
|
|
31
|
+
* The person picks `pod` on the chooser: validates the request like the CP,
|
|
32
|
+
* returns where the CP redirects the browser. Throws on a request the CP
|
|
33
|
+
* refuses (shown as a page there, never redirected).
|
|
34
|
+
*/
|
|
35
|
+
choose(location: string, opts: { pod: string; metadata: Pick<ClientMetadataDocument, "client_id" | "initiate_login_uri"> }): string;
|
|
36
|
+
/** The person cancels on the chooser: `<initiate_login_uri>?state=…&error=access_denied`, no `iss`. */
|
|
37
|
+
cancel(location: string, opts: { metadata: Pick<ClientMetadataDocument, "client_id" | "initiate_login_uri"> }): string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createMockCloud(options: { url?: string; allowInsecureLoopback?: boolean } = {}): MockCloud {
|
|
41
|
+
const base = (options.url ?? MOCK_CLOUD_URL).replace(/\/+$/, "");
|
|
42
|
+
const requests: string[] = [];
|
|
43
|
+
return {
|
|
44
|
+
url: base,
|
|
45
|
+
requests,
|
|
46
|
+
choose(location, { pod, metadata }) {
|
|
47
|
+
const back = check(location, metadata);
|
|
48
|
+
back.searchParams.set("iss", pod);
|
|
49
|
+
back.searchParams.set("state", new URL(location).searchParams.get("state")!);
|
|
50
|
+
return back.href;
|
|
51
|
+
},
|
|
52
|
+
cancel(location, { metadata }) {
|
|
53
|
+
const back = check(location, metadata);
|
|
54
|
+
back.searchParams.set("state", new URL(location).searchParams.get("state")!);
|
|
55
|
+
back.searchParams.set("error", "access_denied");
|
|
56
|
+
return back.href;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Validates like the CP (an HTML error there, never a redirect); returns the app's initiate_login_uri. */
|
|
61
|
+
function check(location: string, metadata: Pick<ClientMetadataDocument, "client_id" | "initiate_login_uri">): URL {
|
|
62
|
+
requests.push(location);
|
|
63
|
+
const url = new URL(location);
|
|
64
|
+
if (`${url.origin}${url.pathname}` !== `${base}/connect/pod`) throw new Error(`Not this CP's /connect/pod: ${location}`);
|
|
65
|
+
const clientId = url.searchParams.get("client_id");
|
|
66
|
+
const state = url.searchParams.get("state");
|
|
67
|
+
if (!clientId || !cimdUrlAccepted(clientId)) throw new Error("Chooser refused: client_id must be an https CIMD URL");
|
|
68
|
+
if (!state || !STATE_RE.test(state)) throw new Error("Chooser refused: state is missing or malformed");
|
|
69
|
+
if (clientId !== metadata.client_id) throw new Error("Chooser refused: the client metadata is not this client_id's");
|
|
70
|
+
return new URL(metadata.initiate_login_uri);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** https; http only on a loopback host, and only with `allowInsecureLoopback`. */
|
|
74
|
+
function cimdUrlAccepted(clientId: string): boolean {
|
|
75
|
+
let url: URL;
|
|
76
|
+
try {
|
|
77
|
+
url = new URL(clientId);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (url.protocol === "https:") return true;
|
|
82
|
+
return url.protocol === "http:" && options.allowInsecureLoopback === true && LOOPBACK_HOSTS.has(url.hostname);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* tokens MOCK_TOKENS.form / .share / .site the demo form, share and site
|
|
16
16
|
* tokens MOCK_FAILURE_TOKENS.* one failure on every door
|
|
17
17
|
* anything else the uniform 404
|
|
18
|
+
*
|
|
19
|
+
* Pod-app sign-in (`@synap-core/sdk/connect`): `createMockAuthServer()`, see
|
|
20
|
+
* ./auth-server.ts; Synap Cloud's pod chooser: `createMockCloud()`, ./cloud.ts.
|
|
18
21
|
*/
|
|
19
22
|
import type {
|
|
20
23
|
PublicCollectionPage,
|
|
@@ -28,6 +31,10 @@ import { MOCK_FORM, MOCK_SHARE, MOCK_SITE, type MockSite } from "./fixtures";
|
|
|
28
31
|
|
|
29
32
|
export { MOCK_FORM, MOCK_SHARE, MOCK_SITE };
|
|
30
33
|
export type { MockCollection, MockSite } from "./fixtures";
|
|
34
|
+
// Pod-app sign-in: a mock authorization server + Hub REST doors.
|
|
35
|
+
export * from "./auth-server";
|
|
36
|
+
// Synap Cloud: a mock Control Plane pod chooser.
|
|
37
|
+
export * from "./cloud";
|
|
31
38
|
|
|
32
39
|
/** A placeholder origin; never contacted. */
|
|
33
40
|
export const MOCK_POD_URL = "https://mock.synap.invalid";
|