@synap-core/sdk 1.4.0 → 1.5.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 (50) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +136 -10
  3. package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
  4. package/dist/chunk-UK2U6SMA.js.map +1 -0
  5. package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
  6. package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
  7. package/dist/connect.cjs +412 -64
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +400 -32
  10. package/dist/connect.d.ts +400 -32
  11. package/dist/connect.js +407 -65
  12. package/dist/connect.js.map +1 -1
  13. package/dist/public.cjs +9 -2
  14. package/dist/public.cjs.map +1 -1
  15. package/dist/public.d.cts +19 -4
  16. package/dist/public.d.ts +19 -4
  17. package/dist/public.js +8 -2
  18. package/dist/public.js.map +1 -1
  19. package/dist/testing.cjs +234 -12
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +122 -27
  22. package/dist/testing.d.ts +122 -27
  23. package/dist/testing.js +230 -12
  24. package/dist/testing.js.map +1 -1
  25. package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
  26. package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
  27. package/package.json +30 -6
  28. package/src/connect/auth.ts +156 -27
  29. package/src/connect/client.ts +374 -34
  30. package/src/connect/cloud.ts +14 -10
  31. package/src/connect/discovery.ts +66 -32
  32. package/src/connect/encoding.ts +1 -1
  33. package/src/connect/errors.ts +7 -0
  34. package/src/connect/flow.ts +42 -14
  35. package/src/connect/http.ts +42 -0
  36. package/src/connect/hub-types.ts +254 -0
  37. package/src/connect/index.ts +60 -42
  38. package/src/connect/rules.ts +18 -0
  39. package/src/connect/store.ts +9 -5
  40. package/src/connect/types.ts +28 -4
  41. package/src/public/client.ts +14 -2
  42. package/src/public/safe-markdown.ts +13 -0
  43. package/src/public/share-model.ts +6 -2
  44. package/src/public/site-model.ts +5 -3
  45. package/src/testing/auth-server.ts +239 -14
  46. package/src/testing/cloud.ts +3 -4
  47. package/src/testing/fixtures.ts +26 -0
  48. package/src/testing/index.ts +8 -6
  49. package/src/testing/seed.ts +120 -0
  50. package/dist/chunk-J7XHAAFJ.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/sdk",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Type-safe tRPC client SDK for Synap data pods, public-door client, and server-only pod-app sign-in",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -8,22 +8,34 @@
8
8
  "types": "./dist/index.d.ts",
9
9
  "exports": {
10
10
  ".": {
11
- "types": "./dist/index.d.ts",
11
+ "types": {
12
+ "import": "./dist/index.d.ts",
13
+ "require": "./dist/index.d.cts"
14
+ },
12
15
  "import": "./dist/index.js",
13
16
  "require": "./dist/index.cjs"
14
17
  },
15
18
  "./public": {
16
- "types": "./dist/public.d.ts",
19
+ "types": {
20
+ "import": "./dist/public.d.ts",
21
+ "require": "./dist/public.d.cts"
22
+ },
17
23
  "import": "./dist/public.js",
18
24
  "require": "./dist/public.cjs"
19
25
  },
20
26
  "./testing": {
21
- "types": "./dist/testing.d.ts",
27
+ "types": {
28
+ "import": "./dist/testing.d.ts",
29
+ "require": "./dist/testing.d.cts"
30
+ },
22
31
  "import": "./dist/testing.js",
23
32
  "require": "./dist/testing.cjs"
24
33
  },
25
34
  "./connect": {
26
- "types": "./dist/connect.d.ts",
35
+ "types": {
36
+ "import": "./dist/connect.d.ts",
37
+ "require": "./dist/connect.d.cts"
38
+ },
27
39
  "edge-light": "./dist/connect.js",
28
40
  "workerd": "./dist/connect.js",
29
41
  "browser": "./dist/connect.browser.js",
@@ -31,11 +43,23 @@
31
43
  "require": "./dist/connect.cjs"
32
44
  }
33
45
  },
46
+ "sideEffects": [
47
+ "./dist/connect.js",
48
+ "./dist/connect.cjs",
49
+ "./dist/connect.browser.js",
50
+ "./dist/connect.browser.cjs",
51
+ "./src/connect/index.ts",
52
+ "./src/connect/browser.ts"
53
+ ],
34
54
  "files": [
35
55
  "dist",
36
56
  "src",
37
- "!src/__tests__"
57
+ "!src/__tests__",
58
+ "CHANGELOG.md"
38
59
  ],
60
+ "engines": {
61
+ "node": ">=20"
62
+ },
39
63
  "keywords": [
40
64
  "synap",
41
65
  "sdk",
@@ -34,6 +34,7 @@ import { serializeCookie, parseCookies } from "./encoding";
34
34
  import { createPodClient, type PodClient, type PodClientOptions } from "./client";
35
35
  import { assertPermissions, handleCallback, refreshConnection, revokeConnection, startConnect, TRANSACTION_TTL_MS } from "./flow";
36
36
  import { assertServerRuntime } from "./guard";
37
+ import { isIpLiteral, LOOPBACK_HOSTS, SPACE_HINT_RE } from "./rules";
37
38
  import { assertAppSecret, type AppSecret } from "./seal";
38
39
  import { applySetCookies, cookieNames, createSealedCookieStore, markerCookie, readMarker, type ConnectionStore } from "./store";
39
40
  import type { AppRequest, ClientMetadataDocument, PodConnection } from "./types";
@@ -69,7 +70,12 @@ export const DEFAULT_PATHS: PodAppPaths = {
69
70
  export const REFRESH_WINDOW_MS = 5 * 60 * 1000;
70
71
 
71
72
  export interface PodAppAuthConfig {
72
- /** The app's origin, e.g. https://studio.example.com (http only on localhost). */
73
+ /**
74
+ * The app's origin, e.g. https://studio.example.com. `http://localhost` is
75
+ * accepted for the SDK's MOCK pod only: every real pod, a local one
76
+ * included, refuses an http client_id and redirect_uri, so an app that signs
77
+ * in to a real pod runs on https (a tunnel in development).
78
+ */
73
79
  appUrl: string;
74
80
  /** Shown on the pod's consent screen (at most 100 characters). */
75
81
  clientName: string;
@@ -89,7 +95,11 @@ export interface PodAppAuthConfig {
89
95
  * "Continue with Synap Cloud" (`cloudConnectAvailable`). Unset = address only.
90
96
  */
91
97
  cloudUrl?: string | null;
92
- /** Accept pods (and a Control Plane) on http://localhost (development against a local pod). */
98
+ /**
99
+ * Accept a pod (and a Control Plane) on http://localhost: development
100
+ * against the SDK's mock pod (`@synap-core/sdk/testing`). A real local pod
101
+ * still refuses an app that is not on https (see `appUrl`).
102
+ */
93
103
  allowInsecureLoopback?: boolean;
94
104
  /** Developer log of every refused sign-in; messages never carry a token. Default: console.warn. */
95
105
  onError?: (error: ConnectError, where: "connect" | "callback" | "disconnect" | "refresh") => void;
@@ -108,9 +118,9 @@ export type RefreshStatus =
108
118
  | "refreshed"
109
119
  /** The refresh failed (a lost rotation race, the pod busy) but the access token still works: keep it, set nothing. */
110
120
  | "kept"
111
- /** The access token is spent and cannot be renewed: connect again. */
121
+ /** The access token is spent and cannot be renewed (lapsed, or the pod moved: `error.code`): connect again. The session cookie is cleared (the marker stays). */
112
122
  | "expired"
113
- /** The pod refused the refresh token (access removed): connect again. */
123
+ /** The pod refused the refresh token (access removed): connect again. The session cookie is cleared (the marker stays). */
114
124
  | "revoked"
115
125
  /** The pod could not be reached and the access token is spent: try later. */
116
126
  | "unavailable";
@@ -132,6 +142,30 @@ export interface RefreshOutcome {
132
142
  lapsed?: { podUrl: string };
133
143
  }
134
144
 
145
+ export interface RefreshOptions {
146
+ /**
147
+ * Rotate now, whatever the access token's expiry says: the pod refused it
148
+ * (a Hub call answered `unauthorized` with `refreshable`). Needs a refresh
149
+ * token. If the pod refuses the refresh too, the outcome is `revoked` (or
150
+ * `expired`), never `kept`: the access token is known dead.
151
+ */
152
+ force?: boolean;
153
+ }
154
+
155
+ export interface PodAppClientOptions extends PodClientOptions {
156
+ /**
157
+ * Where the client may set cookies (a route handler, a server action): after
158
+ * a 401 it then renews the connection ONCE (rotate, seal, `persist` the
159
+ * Set-Cookie values, retry the request once). The rotation spends the old
160
+ * refresh token, so it is never done where its result cannot be stored: a
161
+ * server component passes no `persist`, and a 401 there answers
162
+ * `unauthorized` with `refreshable` (see `RefreshOptions.force`).
163
+ */
164
+ persist?: (setCookies: string[]) => void | Promise<void>;
165
+ /** The request (or its Cookie header) the connection came from: chunks a larger old cookie left are cleared. */
166
+ request?: Request | string | null;
167
+ }
168
+
135
169
  export interface PodAppAuth {
136
170
  clientId: string;
137
171
  redirectUri: string;
@@ -161,15 +195,48 @@ export interface PodAppAuth {
161
195
  * (or disconnected). Same sources as `readConnection`.
162
196
  */
163
197
  readLapsed(source: Request | string | null): Promise<{ podUrl: string } | null>;
164
- /** For `proxy.ts` / a route handler: rotate near expiry, lost-race safe. */
165
- refreshIfNeeded(request: Request): Promise<RefreshOutcome>;
166
- /** A Hub REST client for the connection. */
167
- client(connection: PodConnection, opts?: PodClientOptions): PodClient;
198
+ /** For `proxy.ts` / a route handler: rotate near expiry (or now, `force`), lost-race safe. */
199
+ refreshIfNeeded(request: Request, opts?: RefreshOptions): Promise<RefreshOutcome>;
200
+ /** A Hub REST client for the connection (renewing it after a 401 when it can `persist`). */
201
+ client(connection: PodConnection, opts?: PodAppClientOptions): PodClient;
168
202
  }
169
203
 
170
- const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
171
204
  const TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
172
205
  const MAX_METADATA_BYTES = 5 * 1024;
206
+ const MAX_REQUESTS = 32;
207
+ /** How long a refused refresh is not asked again while the access token still works ("kept"). */
208
+ const KEPT_RETRY_MS = 60_000;
209
+
210
+ /** The pod's grant actions: the only verbs a pod app may ask for (client-metadata.ts `assertAppPermission`). */
211
+ const GRANT_ACTIONS = ["read", "create", "update", "delete"];
212
+ /** The review verbs only an official Synap app (`https://<id>.synap.live`) may ask for, spelled exactly. */
213
+ const SYNAP_APP_REVIEW_PERMISSIONS = ["proposal.approve", "proposal.reject"];
214
+ const PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
215
+
216
+ function isSynapAppOrigin(origin: string): boolean {
217
+ return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
218
+ }
219
+
220
+ /**
221
+ * One `synap_requests[].permission`, refused here exactly when the pod would
222
+ * refuse it: `<subject>[.<kind>].<action>`, lowercase, a kind only on
223
+ * `entity`, an action in the pod's grant actions (the review verbs only for a
224
+ * Synap app). Stricter on one point: no `*` at all (`can()` matches exactly).
225
+ */
226
+ function assertAppPermission(permission: string, origin: string): void {
227
+ const segs = permission.split(".");
228
+ const fail = (why: string) => {
229
+ throw new ConnectError("misconfigured", `requests: "${permission}" ${why}`);
230
+ };
231
+ if (segs.length < 2 || segs.length > 3 || segs.some((seg) => !PERMISSION_SEGMENT_RE.test(seg))) {
232
+ fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
233
+ }
234
+ if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
235
+ if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && isSynapAppOrigin(origin)) return;
236
+ if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
237
+ fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
238
+ }
239
+ }
173
240
 
174
241
  function appOriginOf(appUrl: string): { origin: string; secure: boolean } {
175
242
  let url: URL;
@@ -179,8 +246,11 @@ function appOriginOf(appUrl: string): { origin: string; secure: boolean } {
179
246
  throw new ConnectError("misconfigured", "appUrl is not a URL");
180
247
  }
181
248
  const secure = url.protocol === "https:";
182
- if (!secure && !(url.protocol === "http:" && LOOPBACK.has(url.hostname))) {
183
- throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
249
+ if (!secure && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
250
+ throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost, for the SDK's mock pod)");
251
+ }
252
+ if (secure && isIpLiteral(url.hostname)) {
253
+ throw new ConnectError("misconfigured", "appUrl must use a domain name: the pod refuses an IP address client_id");
184
254
  }
185
255
  if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
186
256
  throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
@@ -194,7 +264,12 @@ function assertPath(name: string, path: string): void {
194
264
  }
195
265
  }
196
266
 
197
- /** The CIMD document (spec 17 §6.1), validated the way the pod will validate it. */
267
+ /**
268
+ * The app's client metadata document (its client_id URL serves it). Refuses,
269
+ * at setup, every document the pod would refuse at sign-in: the permissions
270
+ * (`assertAppPermission`), at most 32 requests counted as sent, duplicates
271
+ * included, an IP address origin, the 5 KB cap.
272
+ */
198
273
  export function buildClientMetadata(opts: {
199
274
  appUrl: string;
200
275
  clientName: string;
@@ -207,9 +282,13 @@ export function buildClientMetadata(opts: {
207
282
  for (const [name, path] of Object.entries(paths)) assertPath(name, path);
208
283
  const clientName = opts.clientName.trim();
209
284
  if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
285
+ // The pod counts the entries as sent, before it drops duplicates.
286
+ if (opts.requests.length === 0 || opts.requests.length > MAX_REQUESTS) {
287
+ throw new ConnectError("misconfigured", `requests must hold 1-${MAX_REQUESTS} entries (duplicates count)`);
288
+ }
210
289
  assertPermissions(opts.requests.map((r) => r.permission));
211
290
  for (const r of opts.requests) {
212
- if (r.permission.includes("*")) throw new ConnectError("misconfigured", "a permission cannot contain *");
291
+ assertAppPermission(r.permission, origin);
213
292
  if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
214
293
  throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
215
294
  }
@@ -335,7 +414,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
335
414
  permissions,
336
415
  secret: config.secret,
337
416
  resource: config.resource,
338
- spaceHint: opts.space && /^[A-Za-z0-9_-]{1,64}$/.test(opts.space) ? opts.space : null,
417
+ spaceHint: opts.space && SPACE_HINT_RE.test(opts.space) ? opts.space : null,
339
418
  next: opts.next,
340
419
  prompt: opts.prompt,
341
420
  allowInsecureLoopback: config.allowInsecureLoopback,
@@ -422,7 +501,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
422
501
  clientId,
423
502
  secret: config.secret,
424
503
  next,
425
- spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
504
+ spaceHint: space && SPACE_HINT_RE.test(space) ? space : null,
426
505
  prompt: formField(form, "prompt") === "consent" ? "consent" : null,
427
506
  now,
428
507
  });
@@ -459,7 +538,10 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
459
538
  }
460
539
  }
461
540
 
462
- async function refreshIfNeeded(request: Request): Promise<RefreshOutcome> {
541
+ /** Refresh tokens whose refresh failed while their access token still worked ("kept"): not asked again for a minute. */
542
+ const keptFailures = new Map<string, { error: ConnectError; at: number }>();
543
+
544
+ async function refreshIfNeeded(request: Request, opts: RefreshOptions = {}): Promise<RefreshOutcome> {
463
545
  const connection = await store.read(request);
464
546
  const unchanged = (status: RefreshStatus, conn: PodConnection | null, error?: ConnectError): RefreshOutcome => ({
465
547
  status,
@@ -474,12 +556,16 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
474
556
  }
475
557
  if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
476
558
  const t = now();
477
- const accessLives = connection.accessExpiresAt > t;
478
- if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
559
+ // Forced: the pod already refused the access token, whatever its expiry says.
560
+ const accessLives = !opts.force && connection.accessExpiresAt > t;
561
+ if (!opts.force && connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
479
562
  if (!connection.refreshToken) {
480
- // The pod issued none (before PB3): the connection ends with its access token.
563
+ // The pod issued none: the connection ends with its access token.
481
564
  return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
482
565
  }
566
+ for (const [token, f] of keptFailures) if (t - f.at >= KEPT_RETRY_MS) keptFailures.delete(token);
567
+ const keptFailure = accessLives ? keptFailures.get(connection.refreshToken) : undefined;
568
+ if (keptFailure) return unchanged("kept", connection, keptFailure.error);
483
569
  let next: PodConnection;
484
570
  try {
485
571
  next = await refreshConnection(connection, flow);
@@ -487,13 +573,24 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
487
573
  const e = fail(err, "refresh");
488
574
  // LOST-RACE SAFE: another instance may have redeemed the token at the
489
575
  // same moment; the pod refuses the concurrent loser without a family
490
- // revoke (PB3), and the access token we hold is still good. Keep it and
491
- // set NOTHING, so the winner's cookie stands. (Within one process the
492
- // race never reaches the pod: `refreshConnection` is single-flight.)
493
- // Never clear the cookie here either: a clear racing the winner's
494
- // Set-Cookie would sign the person out.
495
- if (accessLives) return unchanged("kept", connection, e);
496
- if (e.code === "revoked" || e.code === "expired") return unchanged(e.code, null, e);
576
+ // revoke, and the access token we hold is still good. Keep it and set
577
+ // NOTHING, so the winner's cookie stands. (Within one process the race
578
+ // never reaches the pod: `refreshConnection` is single-flight.) Never
579
+ // clear the cookie here either: a clear racing the winner's Set-Cookie
580
+ // would sign the person out. The failure is remembered for a minute, so
581
+ // each page view does not ask the pod again.
582
+ if (accessLives) {
583
+ keptFailures.set(connection.refreshToken, { error: e, at: t });
584
+ return unchanged("kept", connection, e);
585
+ }
586
+ // Spent and refused for good: clear the session cookie, so every later
587
+ // page view does not post the dead token to /token again (that budget is
588
+ // per caller IP: the app server, shared by all its visitors). The marker
589
+ // stays: the person still reads "expired, connect again to <pod>".
590
+ if (e.code === "revoked" || e.code === "expired" || e.code === "pod_moved") {
591
+ const cleared = await store.clear(request).catch(() => [] as string[]);
592
+ return { status: e.code === "revoked" ? "revoked" : "expired", connection: null, setCookies: cleared, cookieHeader: applySetCookies(request.headers.get("cookie"), cleared), error: e };
593
+ }
497
594
  return unchanged("unavailable", null, e);
498
595
  }
499
596
  // PERSIST BEFORE USE: the old refresh token is spent now; only a stored
@@ -517,6 +614,30 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
517
614
  };
518
615
  }
519
616
 
617
+ /**
618
+ * The client's renewal after a 401: rotate (single-flight per
619
+ * process), PERSIST through `persist`, then hand the new connection over.
620
+ * A rotation that cannot be persisted is not used (persist before use).
621
+ */
622
+ function renewer(persist: NonNullable<PodAppClientOptions["persist"]>, source: Request | string | null | undefined) {
623
+ return async (connection: PodConnection): Promise<PodConnection> => {
624
+ let next: PodConnection;
625
+ try {
626
+ next = await refreshConnection(connection, flow);
627
+ } catch (err) {
628
+ throw fail(err, "refresh");
629
+ }
630
+ try {
631
+ const cookies = await store.write(next, asRequest(source ?? null));
632
+ await persist([...cookies, marker(next.podUrl)]);
633
+ } catch (err) {
634
+ fail(err, "refresh");
635
+ throw new ConnectError("expired", "The renewed connection could not be stored; connect again");
636
+ }
637
+ return next;
638
+ };
639
+ }
640
+
520
641
  return {
521
642
  clientId,
522
643
  redirectUri,
@@ -530,6 +651,14 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
530
651
  return (await store.read(request)) ? null : lapsedIn(request);
531
652
  },
532
653
  refreshIfNeeded,
533
- client: (connection, opts) => createPodClient(connection, { fetch: config.fetch, now: config.now, ...opts }),
654
+ client(connection, opts = {}) {
655
+ const { persist, request, ...clientOpts } = opts;
656
+ return createPodClient(connection, {
657
+ fetch: config.fetch,
658
+ now: config.now,
659
+ ...(persist ? { renew: renewer(persist, request) } : {}),
660
+ ...clientOpts,
661
+ });
662
+ },
534
663
  };
535
664
  }