@synap-core/sdk 1.1.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 (63) hide show
  1. package/README.md +138 -6
  2. package/dist/chunk-J7XHAAFJ.js +79 -0
  3. package/dist/chunk-J7XHAAFJ.js.map +1 -0
  4. package/dist/client-CVUT1Mcf.d.cts +253 -0
  5. package/dist/client-CVUT1Mcf.d.ts +253 -0
  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/index.cjs +48 -6
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +118 -7
  21. package/dist/index.d.ts +118 -7
  22. package/dist/index.js +41 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/public.cjs +627 -0
  25. package/dist/public.cjs.map +1 -0
  26. package/dist/public.d.cts +228 -0
  27. package/dist/public.d.ts +228 -0
  28. package/dist/public.js +578 -0
  29. package/dist/public.js.map +1 -0
  30. package/dist/testing.cjs +671 -0
  31. package/dist/testing.cjs.map +1 -0
  32. package/dist/testing.d.cts +181 -0
  33. package/dist/testing.d.ts +181 -0
  34. package/dist/testing.js +614 -0
  35. package/dist/testing.js.map +1 -0
  36. package/dist/types-DpqPNaaa.d.cts +76 -0
  37. package/dist/types-DpqPNaaa.d.ts +76 -0
  38. package/package.json +16 -3
  39. package/src/client.ts +24 -7
  40. package/src/connect/auth.ts +405 -0
  41. package/src/connect/browser.ts +10 -0
  42. package/src/connect/client.ts +193 -0
  43. package/src/connect/discovery.ts +262 -0
  44. package/src/connect/encoding.ts +93 -0
  45. package/src/connect/errors.ts +115 -0
  46. package/src/connect/flow.ts +449 -0
  47. package/src/connect/guard.ts +25 -0
  48. package/src/connect/index.ts +59 -0
  49. package/src/connect/seal.ts +119 -0
  50. package/src/connect/store.ts +151 -0
  51. package/src/connect/types.ts +80 -0
  52. package/src/index.ts +13 -0
  53. package/src/public/client.ts +579 -0
  54. package/src/public/form-model.ts +210 -0
  55. package/src/public/index.ts +12 -0
  56. package/src/public/safe-markdown.ts +114 -0
  57. package/src/public/share-model.ts +47 -0
  58. package/src/public/site-model.ts +217 -0
  59. package/src/templates.ts +122 -0
  60. package/src/testing/auth-server.ts +380 -0
  61. package/src/testing/fixtures.ts +132 -0
  62. package/src/testing/index.ts +359 -0
  63. package/src/public.ts +0 -262
@@ -0,0 +1,449 @@
1
+ /**
2
+ * The OAuth 2.1 authorization-code flow of a pod app (spec 17 §6.4-§6.7), as
3
+ * plain functions over a SEALED transaction string. `createPodAppAuth` wires
4
+ * them to cookies and route handlers; use these directly for another store.
5
+ *
6
+ * Security properties (each pinned by a test in src/__tests__/connect.*):
7
+ * - PKCE S256 with a fresh 32-byte verifier per sign-in;
8
+ * - a fresh 32-byte `state`, compared in constant time on the callback;
9
+ * - mix-up defence (RFC 9207): the callback's `iss` must equal the issuer the
10
+ * transaction was started with; a missing `iss` is refused too;
11
+ * - the transaction lives 10 minutes and is sealed (AES-GCM) under the app
12
+ * secret with its own purpose: tampered, foreign or stale → refused;
13
+ * - the `error` of a callback is honoured only AFTER `iss` and `state` match,
14
+ * so a forged error cannot end someone else's sign-in;
15
+ * - tokens never leave the server: nothing here returns a value meant for a
16
+ * browser except the authorize URL.
17
+ */
18
+ import { ConnectError, fromOAuthError, retryAfterSeconds } from "./errors";
19
+ import { constantTimeEqual, pkceChallenge, randomToken } from "./encoding";
20
+ import { discoverPod, type DiscoverOptions } from "./discovery";
21
+ import { seal, unseal, type AppSecret } from "./seal";
22
+ import type { PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
23
+
24
+ /** How long a started sign-in stays valid. */
25
+ export const TRANSACTION_TTL_MS = 10 * 60 * 1000;
26
+ /** The refresh token's idle life when the pod does not say (spec 17 F5: 30 days). */
27
+ export const DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1000;
28
+ const DEFAULT_ACCESS_TTL_SEC = 3600;
29
+ const TIMEOUT_MS = 10_000;
30
+
31
+ /** A sealed `ConnectTransaction`: opaque, safe to put in an HttpOnly cookie. */
32
+ export type SealedTransaction = string & { readonly __sealedTransaction: true };
33
+
34
+ /** What a started sign-in remembers (sealed; never sent to the pod). */
35
+ export interface ConnectTransaction {
36
+ v: 1;
37
+ issuer: string;
38
+ tokenEndpoint: string;
39
+ revocationEndpoint: string | null;
40
+ clientId: string;
41
+ redirectUri: string;
42
+ state: string;
43
+ verifier: string;
44
+ resource: string | null;
45
+ /** A same-origin path to land on afterwards. */
46
+ next: string | null;
47
+ createdAt: number;
48
+ }
49
+
50
+ export interface FlowOptions {
51
+ fetch?: typeof fetch;
52
+ now?: () => number;
53
+ }
54
+
55
+ export interface StartConnectOptions extends FlowOptions {
56
+ /** A validated issuer (`discoverPod`), or an address / launch `iss` to discover. */
57
+ pod: PodIssuer | string;
58
+ clientId: string;
59
+ redirectUri: string;
60
+ /** Grant-grammar permissions, each declared in the app's client metadata. */
61
+ permissions: readonly string[];
62
+ secret: AppSecret;
63
+ /** RFC 8707 resource: the pod's Hub API (default) or its MCP endpoint; null = none. */
64
+ resource?: "api/hub" | "mcp" | null;
65
+ /** A space to preselect on consent. */
66
+ spaceHint?: string | null;
67
+ /** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
68
+ next?: string | null;
69
+ allowInsecureLoopback?: boolean;
70
+ }
71
+
72
+ export interface StartedConnect {
73
+ /** Redirect the browser here (303). */
74
+ authorizeUrl: string;
75
+ /** Store in an HttpOnly cookie until the callback. */
76
+ transaction: SealedTransaction;
77
+ /** ms since epoch. */
78
+ expiresAt: number;
79
+ issuer: PodIssuer;
80
+ }
81
+
82
+ const PERMISSION_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$/i;
83
+
84
+ export function assertPermissions(permissions: readonly string[]): string[] {
85
+ const unique = [...new Set(permissions)];
86
+ if (unique.length === 0 || unique.length > 32 || unique.some((p) => !PERMISSION_RE.test(p))) {
87
+ throw new ConnectError(
88
+ "misconfigured",
89
+ "permissions must be 1-32 grant-grammar strings such as entity.post.read (no *)",
90
+ );
91
+ }
92
+ return unique;
93
+ }
94
+
95
+ export async function startConnect(opts: StartConnectOptions): Promise<StartedConnect> {
96
+ const now = (opts.now ?? Date.now)();
97
+ const permissions = assertPermissions(opts.permissions);
98
+ const issuer =
99
+ typeof opts.pod === "string"
100
+ ? await discoverPod(opts.pod, { fetch: opts.fetch, now: opts.now, allowInsecureLoopback: opts.allowInsecureLoopback } satisfies DiscoverOptions)
101
+ : opts.pod;
102
+ const verifier = randomToken();
103
+ const state = randomToken();
104
+ const resourceKind = opts.resource === undefined ? "api/hub" : opts.resource;
105
+ const resource = resourceKind ? `${issuer.issuer}/${resourceKind}` : null;
106
+
107
+ const url = new URL(issuer.authorizationEndpoint);
108
+ url.searchParams.set("response_type", "code");
109
+ url.searchParams.set("client_id", opts.clientId);
110
+ url.searchParams.set("redirect_uri", opts.redirectUri);
111
+ url.searchParams.set("scope", permissions.join(" "));
112
+ url.searchParams.set("state", state);
113
+ url.searchParams.set("code_challenge", await pkceChallenge(verifier));
114
+ url.searchParams.set("code_challenge_method", "S256");
115
+ if (resource) url.searchParams.set("resource", resource);
116
+ if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
117
+
118
+ const tx: ConnectTransaction = {
119
+ v: 1,
120
+ issuer: issuer.issuer,
121
+ tokenEndpoint: issuer.tokenEndpoint,
122
+ revocationEndpoint: issuer.revocationEndpoint,
123
+ clientId: opts.clientId,
124
+ redirectUri: opts.redirectUri,
125
+ state,
126
+ verifier,
127
+ resource,
128
+ next: opts.next ?? null,
129
+ createdAt: now,
130
+ };
131
+ const expiresAt = now + TRANSACTION_TTL_MS;
132
+ const transaction = (await seal(tx, {
133
+ secret: opts.secret,
134
+ purpose: "synap-connect/transaction",
135
+ expiresAt,
136
+ })) as SealedTransaction;
137
+ return { authorizeUrl: url.toString(), transaction, expiresAt, issuer };
138
+ }
139
+
140
+ /** Open a sealed transaction; refuses tampered, foreign and stale ones. */
141
+ export async function openTransaction(
142
+ sealed: string | null | undefined,
143
+ opts: { secret: AppSecret; now?: () => number },
144
+ ): Promise<ConnectTransaction> {
145
+ if (!sealed) throw new ConnectError("expired_transaction", "No sign-in in progress in this browser");
146
+ const opened = await unseal<ConnectTransaction>(sealed, {
147
+ secret: opts.secret,
148
+ purpose: "synap-connect/transaction",
149
+ now: (opts.now ?? Date.now)(),
150
+ });
151
+ if (!opened.ok) {
152
+ throw new ConnectError(
153
+ "expired_transaction",
154
+ opened.reason === "expired" ? "The sign-in is older than 10 minutes" : "The sign-in cookie is not one this app sealed",
155
+ );
156
+ }
157
+ const tx = opened.value;
158
+ if (!tx || tx.v !== 1 || typeof tx.state !== "string" || typeof tx.issuer !== "string") {
159
+ throw new ConnectError("expired_transaction", "The sign-in cookie is not one this app sealed");
160
+ }
161
+ return tx;
162
+ }
163
+
164
+ export interface HandleCallbackOptions extends FlowOptions {
165
+ /** The full URL the pod redirected the browser to. */
166
+ callbackUrl: string | URL;
167
+ transaction: string | null | undefined;
168
+ clientId: string;
169
+ redirectUri: string;
170
+ secret: AppSecret;
171
+ }
172
+
173
+ export interface CallbackResult {
174
+ connection: PodConnection;
175
+ /** The same-origin path the sign-in was started for. */
176
+ next: string | null;
177
+ }
178
+
179
+ export async function handleCallback(opts: HandleCallbackOptions): Promise<CallbackResult> {
180
+ const tx = await openTransaction(opts.transaction, opts);
181
+ if (tx.clientId !== opts.clientId || tx.redirectUri !== opts.redirectUri) {
182
+ throw new ConnectError("misconfigured", "The sign-in was started for another client_id or redirect_uri");
183
+ }
184
+ const params = new URL(opts.callbackUrl).searchParams;
185
+
186
+ // RFC 9207: the answer must name the AS this sign-in went to. Absent = refused
187
+ // (every Synap pod sends it, and discovery required it).
188
+ const iss = params.get("iss");
189
+ if (iss === null || iss !== tx.issuer) {
190
+ throw new ConnectError("mismatch", iss === null ? "The answer names no issuer" : "The answer comes from another pod than the one asked");
191
+ }
192
+ const state = params.get("state");
193
+ if (state === null || !constantTimeEqual(state, tx.state)) {
194
+ throw new ConnectError("mismatch", "The answer does not belong to the sign-in this browser started");
195
+ }
196
+ const error = params.get("error");
197
+ if (error !== null) {
198
+ throw new ConnectError(fromOAuthError(error, "authorize"), `The pod answered ${error}`, { oauthError: error });
199
+ }
200
+ const code = params.get("code");
201
+ if (!code) throw new ConnectError("server_error", "The answer carries no code");
202
+
203
+ const body = new URLSearchParams({
204
+ grant_type: "authorization_code",
205
+ code,
206
+ redirect_uri: tx.redirectUri,
207
+ client_id: tx.clientId,
208
+ code_verifier: tx.verifier,
209
+ });
210
+ if (tx.resource) body.set("resource", tx.resource);
211
+ const json = await tokenRequest(tx.tokenEndpoint, body, "token", opts);
212
+ const connection = parseTokenResponse(json, {
213
+ podUrl: tx.issuer,
214
+ clientId: tx.clientId,
215
+ tokenEndpoint: tx.tokenEndpoint,
216
+ revocationEndpoint: tx.revocationEndpoint,
217
+ now: (opts.now ?? Date.now)(),
218
+ });
219
+ return { connection, next: tx.next };
220
+ }
221
+
222
+ async function tokenRequest(
223
+ endpoint: string,
224
+ body: URLSearchParams,
225
+ during: "token" | "refresh",
226
+ opts: FlowOptions,
227
+ ): Promise<Record<string, unknown>> {
228
+ const doFetch = opts.fetch ?? fetch;
229
+ let res: Response;
230
+ try {
231
+ res = await doFetch(endpoint, {
232
+ method: "POST",
233
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
234
+ body: body.toString(),
235
+ redirect: "manual",
236
+ signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
237
+ });
238
+ } catch {
239
+ throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
240
+ }
241
+ if (res.status === 429) {
242
+ throw new ConnectError("rate_limited", "The pod asked to slow down", {
243
+ retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
244
+ });
245
+ }
246
+ if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
247
+ let json: unknown;
248
+ try {
249
+ json = await res.json();
250
+ } catch {
251
+ throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
252
+ }
253
+ const obj = json && typeof json === "object" && !Array.isArray(json) ? (json as Record<string, unknown>) : {};
254
+ if (res.status !== 200) {
255
+ const oauthError = typeof obj.error === "string" ? obj.error : "server_error";
256
+ throw new ConnectError(fromOAuthError(oauthError, during), `The token endpoint answered ${oauthError}`, { oauthError });
257
+ }
258
+ return obj;
259
+ }
260
+
261
+ function parseGrant(value: unknown): PodGrant[] | null {
262
+ if (!Array.isArray(value)) return null;
263
+ const out: PodGrant[] = [];
264
+ for (const g of value) {
265
+ if (!g || typeof g !== "object") return null;
266
+ const { permission, workspaceId, workspaceName } = g as Record<string, unknown>;
267
+ if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
268
+ out.push({ permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ function parseUser(value: unknown): PodUser | null {
274
+ if (!value || typeof value !== "object") return null;
275
+ const { id, name } = value as Record<string, unknown>;
276
+ return typeof id === "string" ? { id, name: typeof name === "string" ? name : "" } : null;
277
+ }
278
+
279
+ /**
280
+ * A token response → a connection. On a refresh, `previous` supplies what the
281
+ * response may omit (grant, user). A refresh response WITHOUT a new
282
+ * refresh_token drops the old one: with single-use rotation, resending a
283
+ * consumed token reads as theft and revokes the whole family.
284
+ */
285
+ export function parseTokenResponse(
286
+ body: Record<string, unknown>,
287
+ ctx: {
288
+ podUrl: string;
289
+ clientId: string;
290
+ tokenEndpoint: string;
291
+ revocationEndpoint: string | null;
292
+ now: number;
293
+ previous?: PodConnection;
294
+ },
295
+ ): PodConnection {
296
+ const { access_token, token_type, expires_in, scope, refresh_token, refresh_token_expires_in } = body;
297
+ if (typeof access_token !== "string" || access_token === "") {
298
+ throw new ConnectError("server_error", "The token response has no access_token");
299
+ }
300
+ if (typeof token_type !== "string" || token_type.toLowerCase() !== "bearer") {
301
+ throw new ConnectError("server_error", "The token response is not a Bearer token");
302
+ }
303
+ if (expires_in !== undefined && (typeof expires_in !== "number" || !Number.isFinite(expires_in) || expires_in < 0)) {
304
+ throw new ConnectError("server_error", "The token response has an invalid expires_in");
305
+ }
306
+ const grant = parseGrant(body.synap_grant) ?? ctx.previous?.grant ?? null;
307
+ const user = parseUser(body.synap_user) ?? ctx.previous?.user ?? null;
308
+ if (!grant || !user) {
309
+ throw new ConnectError("pod_unsupported", "The token response lacks synap_grant / synap_user (pod older than app sign-in)");
310
+ }
311
+ const scopes =
312
+ typeof scope === "string" ? scope.split(/\s+/).filter(Boolean) : (ctx.previous?.scope ?? [...new Set(grant.map((g) => g.permission))]);
313
+ const connection: PodConnection = {
314
+ podUrl: ctx.podUrl,
315
+ clientId: ctx.clientId,
316
+ tokenEndpoint: ctx.tokenEndpoint,
317
+ revocationEndpoint: ctx.revocationEndpoint,
318
+ accessToken: access_token,
319
+ accessExpiresAt: ctx.now + (typeof expires_in === "number" ? expires_in : DEFAULT_ACCESS_TTL_SEC) * 1000,
320
+ scope: scopes,
321
+ grant,
322
+ user,
323
+ connectedAt: ctx.previous?.connectedAt ?? ctx.now,
324
+ };
325
+ if (typeof refresh_token === "string" && refresh_token !== "") {
326
+ connection.refreshToken = refresh_token;
327
+ const idle =
328
+ typeof refresh_token_expires_in === "number" && Number.isFinite(refresh_token_expires_in) && refresh_token_expires_in > 0
329
+ ? refresh_token_expires_in * 1000
330
+ : DEFAULT_REFRESH_IDLE_MS;
331
+ connection.refreshExpiresAt = ctx.now + idle;
332
+ }
333
+ return connection;
334
+ }
335
+
336
+ /**
337
+ * How long a rotation's result is remembered for its SPENT refresh token.
338
+ * The pod (PB3) refuses a concurrent second redeem softly, but a token
339
+ * presented AGAIN after its use is a reuse: the whole family and the live
340
+ * access key are revoked. A browser easily sends that replay: parallel
341
+ * requests, a second tab, a response it had not stored yet, all still
342
+ * carrying the old cookie. Within this window such a request gets the
343
+ * rotation's result (and re-sends the new cookie) instead of reaching the pod.
344
+ */
345
+ export const ROTATION_MEMORY_MS = 2 * 60 * 1000;
346
+
347
+ interface Rotation {
348
+ result: Promise<PodConnection>;
349
+ /** When it succeeded (ms, the caller's clock); undefined while in flight. */
350
+ settledAt?: number;
351
+ }
352
+
353
+ /** Spent refresh token → its rotation, per process. */
354
+ const rotations = new Map<string, Rotation>();
355
+
356
+ function pruneRotations(now: number): void {
357
+ for (const [token, r] of rotations) {
358
+ if (r.settledAt !== undefined && now - r.settledAt > ROTATION_MEMORY_MS) rotations.delete(token);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Exchange the refresh token for a new access token and the rotated refresh
364
+ * token (PB3: single-use, 30 days from issue, reach re-derived by the pod).
365
+ *
366
+ * SINGLE-FLIGHT per connection within this process: concurrent callers share
367
+ * one request, and for `ROTATION_MEMORY_MS` after it a caller still holding
368
+ * the spent token gets the same result, so this process never replays a
369
+ * spent token. Across processes nothing here can coordinate: a server-side
370
+ * `ConnectionStore` (one row per connection) is the way to rule that out.
371
+ * The caller must PERSIST the result before using its access token
372
+ * (`refreshIfNeeded` does).
373
+ *
374
+ * Throws `expired` (no refresh token, or past its idle life), `revoked`
375
+ * (invalid_grant while it should live: spent, revoked, the app removed or
376
+ * narrowed to nothing, or a concurrent redeem lost), `misconfigured`
377
+ * (invalid_client, unauthorized_client), `rate_limited`, `pod_unreachable`,
378
+ * `server_error`.
379
+ */
380
+ export function refreshConnection(connection: PodConnection, opts: FlowOptions = {}): Promise<PodConnection> {
381
+ const clock = opts.now ?? Date.now;
382
+ const now = clock();
383
+ const token = connection.refreshToken;
384
+ if (!token) return Promise.reject(new ConnectError("expired", "The pod issued no refresh token; connect again"));
385
+ pruneRotations(now);
386
+ const known = rotations.get(token);
387
+ if (known) return known.result;
388
+ if (connection.refreshExpiresAt !== undefined && connection.refreshExpiresAt <= now) {
389
+ return Promise.reject(new ConnectError("expired", "The connection was idle too long; connect again"));
390
+ }
391
+ const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: token, client_id: connection.clientId });
392
+ const rotation: Rotation = {
393
+ result: tokenRequest(connection.tokenEndpoint, body, "refresh", opts)
394
+ .then((json) => {
395
+ const next = parseTokenResponse(json, {
396
+ podUrl: connection.podUrl,
397
+ clientId: connection.clientId,
398
+ tokenEndpoint: connection.tokenEndpoint,
399
+ revocationEndpoint: connection.revocationEndpoint,
400
+ now: clock(),
401
+ previous: connection,
402
+ });
403
+ rotation.settledAt = clock();
404
+ return next;
405
+ })
406
+ .catch((err: unknown) => {
407
+ rotations.delete(token);
408
+ // The AS answers invalid_grant to an idle-expired AND a revoked token;
409
+ // the locally known idle deadline tells them apart.
410
+ if (err instanceof ConnectError && err.code === "revoked" && connection.refreshExpiresAt !== undefined && connection.refreshExpiresAt <= clock()) {
411
+ throw new ConnectError("expired", err.message, { oauthError: err.oauthError });
412
+ }
413
+ throw err;
414
+ }),
415
+ };
416
+ rotations.set(token, rotation);
417
+ return rotation.result;
418
+ }
419
+
420
+ /**
421
+ * RFC 7009, best effort: the refresh token (the pod retires its whole family
422
+ * and the live access key) then the access token (for a pod without refresh).
423
+ * Never throws; `revoked` says whether the pod confirmed.
424
+ */
425
+ export async function revokeConnection(connection: PodConnection, opts: FlowOptions = {}): Promise<{ revoked: boolean }> {
426
+ if (!connection.revocationEndpoint) return { revoked: false };
427
+ const doFetch = opts.fetch ?? fetch;
428
+ let revoked = true;
429
+ const tokens: Array<[string | undefined, string]> = [
430
+ [connection.refreshToken, "refresh_token"],
431
+ [connection.accessToken, "access_token"],
432
+ ];
433
+ for (const [token, hint] of tokens) {
434
+ if (!token) continue;
435
+ try {
436
+ const res = await doFetch(connection.revocationEndpoint, {
437
+ method: "POST",
438
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
439
+ body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
440
+ redirect: "manual",
441
+ signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
442
+ });
443
+ if (res.status !== 200) revoked = false;
444
+ } catch {
445
+ revoked = false;
446
+ }
447
+ }
448
+ return { revoked };
449
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `@synap-core/sdk/connect` holds pod tokens and the app secret: it must never
3
+ * run in a browser. Two guards:
4
+ *
5
+ * - BUILD: the package's `exports["./connect"]` maps the `browser` condition
6
+ * to `connect.browser` (src/connect/browser.ts), which exports nothing and
7
+ * throws on load, so a client bundle never contains this code and a strict
8
+ * ESM bundler fails on the missing named import. Server and edge
9
+ * conditions (`edge-light`, `workerd`, `import`, `require`) get the real
10
+ * module;
11
+ * - RUNTIME: importing the real module where `window` and `document` exist
12
+ * throws (a test DOM, a misconfigured bundler).
13
+ */
14
+ export const BROWSER_REFUSAL =
15
+ "@synap-core/sdk/connect is server-only: it holds the pod's tokens and the app secret. " +
16
+ "Import it from route handlers, proxy.ts or server code, never from a client component.";
17
+
18
+ export function isBrowserRuntime(): boolean {
19
+ const g = globalThis as { window?: unknown; document?: unknown };
20
+ return typeof g.window !== "undefined" && typeof g.document !== "undefined";
21
+ }
22
+
23
+ export function assertServerRuntime(): void {
24
+ if (isBrowserRuntime()) throw new Error(BROWSER_REFUSAL);
25
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `@synap-core/sdk/connect` — pod-app sign-in, SERVER-ONLY (spec 17 §10.1,
3
+ * doc 18 § SDK): an app connects to a person's own pod as an OAuth 2.1 public
4
+ * client (CIMD client_id, PKCE S256, `iss`-checked answers), keeps the pod
5
+ * token on its server in a sealed HttpOnly cookie, refreshes it in `proxy.ts`,
6
+ * and reads/writes the pod's Hub API with typed results.
7
+ *
8
+ * Framework-free and Web-standard (Request/Response, WebCrypto): Node 18+,
9
+ * edge runtimes, workers. Throws when loaded in a browser (./guard.ts).
10
+ *
11
+ * Invariant (pinned by src/__tests__/connect.guard.test.ts): every file of
12
+ * this entry imports only its siblings.
13
+ */
14
+ import { assertServerRuntime } from "./guard";
15
+
16
+ assertServerRuntime();
17
+
18
+ export { createPodAppAuth, buildClientMetadata, DEFAULT_PATHS, REFRESH_WINDOW_MS } from "./auth";
19
+ export type { PodAppAuth, PodAppAuthConfig, PodAppPaths, RefreshOutcome, RefreshStatus } from "./auth";
20
+ export {
21
+ discoverPod,
22
+ canonicalPodUrl,
23
+ acceptLaunch,
24
+ safeNextPath,
25
+ cloudConnectAvailable,
26
+ startCloudConnect,
27
+ } from "./discovery";
28
+ export type { DiscoverOptions, LaunchHint } from "./discovery";
29
+ export {
30
+ startConnect,
31
+ openTransaction,
32
+ handleCallback,
33
+ refreshConnection,
34
+ revokeConnection,
35
+ parseTokenResponse,
36
+ TRANSACTION_TTL_MS,
37
+ DEFAULT_REFRESH_IDLE_MS,
38
+ ROTATION_MEMORY_MS,
39
+ } from "./flow";
40
+ export type {
41
+ CallbackResult,
42
+ ConnectTransaction,
43
+ FlowOptions,
44
+ HandleCallbackOptions,
45
+ SealedTransaction,
46
+ StartConnectOptions,
47
+ StartedConnect,
48
+ } from "./flow";
49
+ export { createPodClient, can, reviewUrlFor } from "./client";
50
+ export type { PodClient, PodClientOptions, PodError, PodErrorCode, PodResult, PodWriteResult, QueryValue } from "./client";
51
+ export { createSealedCookieStore, cookieNames, applySetCookies, connectionExpiresAt } from "./store";
52
+ export type { ConnectionStore, CookieNames, SealedCookieStoreOptions } from "./store";
53
+ export { seal, unseal, MIN_SECRET_LENGTH } from "./seal";
54
+ export type { AppSecret, Opened, SealPurpose } from "./seal";
55
+ export { pkceChallenge } from "./encoding";
56
+ export { ConnectError, isConnectError, parseConnectErrorCode, CONNECT_ERROR_CODES } from "./errors";
57
+ export type { ConnectErrorCode } from "./errors";
58
+ export { isBrowserRuntime } from "./guard";
59
+ export type { AppRequest, ClientMetadataDocument, PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Sealed values: AES-256-GCM under a key derived (HKDF-SHA-256) from the app
3
+ * secret and a PURPOSE. The purpose is both the HKDF `info` and the GCM
4
+ * additional data, so a value sealed as a sign-in transaction never opens as a
5
+ * session, and the reverse. Any change to the sealed text, a wrong secret or a
6
+ * wrong purpose fails GCM authentication: the value reads as absent.
7
+ *
8
+ * v1.<base64url(iv[12] ‖ ciphertext ‖ tag[16])>
9
+ *
10
+ * The plaintext carries its own expiry (`exp`, ms), checked after decryption,
11
+ * so a cookie kept past its Max-Age by a hostile client still expires.
12
+ */
13
+ import { ConnectError } from "./errors";
14
+ import { base64url, fromBase64url, fromUtf8, randomBytes, utf8 } from "./encoding";
15
+
16
+ export type SealPurpose = "synap-connect/transaction" | "synap-connect/session";
17
+
18
+ /** The app secret: one value, or several to rotate (the first seals, all open). */
19
+ export type AppSecret = string | readonly string[];
20
+
21
+ const VERSION = "v1";
22
+ const IV_BYTES = 12;
23
+ const TAG_BYTES = 16;
24
+ export const MIN_SECRET_LENGTH = 32;
25
+ const SALT = utf8("synap-connect/v1");
26
+
27
+ const keyCache = new Map<string, Promise<CryptoKey>>();
28
+
29
+ function secretsOf(secret: AppSecret): readonly string[] {
30
+ const list = typeof secret === "string" ? [secret] : secret;
31
+ if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
32
+ throw new ConnectError(
33
+ "misconfigured",
34
+ `The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`,
35
+ );
36
+ }
37
+ return list;
38
+ }
39
+
40
+ /** Fail fast at setup when the secret is unusable. */
41
+ export function assertAppSecret(secret: AppSecret): void {
42
+ secretsOf(secret);
43
+ }
44
+
45
+ function keyFor(secret: string, purpose: SealPurpose): Promise<CryptoKey> {
46
+ const cacheKey = `${purpose}\u0000${secret}`;
47
+ let key = keyCache.get(cacheKey);
48
+ if (!key) {
49
+ key = crypto.subtle
50
+ .importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"])
51
+ .then((base) =>
52
+ crypto.subtle.deriveKey(
53
+ { name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
54
+ base,
55
+ { name: "AES-GCM", length: 256 },
56
+ false,
57
+ ["encrypt", "decrypt"],
58
+ ),
59
+ );
60
+ keyCache.set(cacheKey, key);
61
+ }
62
+ return key;
63
+ }
64
+
65
+ export async function seal(value: unknown, opts: { secret: AppSecret; purpose: SealPurpose; expiresAt: number }): Promise<string> {
66
+ const [current] = secretsOf(opts.secret);
67
+ const iv = randomBytes(IV_BYTES);
68
+ const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
69
+ const ciphertext = await crypto.subtle.encrypt(
70
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
71
+ await keyFor(current, opts.purpose),
72
+ plaintext,
73
+ );
74
+ const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
75
+ out.set(iv, 0);
76
+ out.set(new Uint8Array(ciphertext), IV_BYTES);
77
+ return `${VERSION}.${base64url(out)}`;
78
+ }
79
+
80
+ export type Opened<T> = { ok: true; value: T; expiresAt: number } | { ok: false; reason: "invalid" | "expired" };
81
+
82
+ /**
83
+ * `invalid` = not ours (malformed, tampered, other secret, other purpose);
84
+ * `expired` = ours, but past its `exp`.
85
+ */
86
+ export async function unseal<T = unknown>(
87
+ sealed: string,
88
+ opts: { secret: AppSecret; purpose: SealPurpose; now: number },
89
+ ): Promise<Opened<T>> {
90
+ const secrets = secretsOf(opts.secret);
91
+ const dot = sealed.indexOf(".");
92
+ if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
93
+ const bytes = fromBase64url(sealed.slice(dot + 1));
94
+ if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
95
+ const iv = bytes.slice(0, IV_BYTES);
96
+ const ciphertext = bytes.slice(IV_BYTES);
97
+ for (const secret of secrets) {
98
+ let plaintext: ArrayBuffer;
99
+ try {
100
+ plaintext = await crypto.subtle.decrypt(
101
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
102
+ await keyFor(secret, opts.purpose),
103
+ ciphertext,
104
+ );
105
+ } catch {
106
+ continue; // not this secret (or tampered): try the next, then refuse
107
+ }
108
+ let parsed: { exp?: unknown; data?: unknown };
109
+ try {
110
+ parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext))) as { exp?: unknown; data?: unknown };
111
+ } catch {
112
+ return { ok: false, reason: "invalid" };
113
+ }
114
+ if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
115
+ if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
116
+ return { ok: true, value: parsed.data as T, expiresAt: parsed.exp };
117
+ }
118
+ return { ok: false, reason: "invalid" };
119
+ }