@synap-core/sdk 1.5.0 → 1.5.2

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.
@@ -18,6 +18,11 @@
18
18
  * what the app may touch); without it a person already connected to that pod
19
19
  * goes straight to `next`. Either way a sign-in is a fresh PKCE + state flow.
20
20
  *
21
+ * `prompt=login` is the step-up: a credential door refused a stale device
22
+ * (`step_up_required`), so the person re-confirms at the POD (never through
23
+ * Synap Cloud) and the callback redeems the code with the connection's refresh
24
+ * token. Then retry the refused call once.
25
+ *
21
26
  * `connect` and `disconnect` change state, so they are POST and same-origin
22
27
  * only (Origin, else Sec-Fetch-Site): a third-party page cannot start a
23
28
  * sign-in into an attacker's pod for a visitor (login CSRF) or sign them out.
@@ -218,10 +223,25 @@ function isSynapAppOrigin(origin: string): boolean {
218
223
  }
219
224
 
220
225
  /**
221
- * One `synap_requests[].permission`, refused here exactly when the pod would
222
- * refuse it: `<subject>[.<kind>].<action>`, lowercase, a kind only on
226
+ * Mock mode's origin (`http://localhost`): it signs in to the SDK's mock pod
227
+ * only, since a real pod refuses an http client_id before it reads a single
228
+ * permission (synap-backend `routers/oauth/client-id-url.ts`
229
+ * `assertClientMetadataUrl`: "client_id URL must use https"). So the review
230
+ * verbs are not refused here, and an app that asks for them can run on the mock.
231
+ */
232
+ function isMockModeOrigin(origin: string): boolean {
233
+ const url = new URL(origin);
234
+ return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
235
+ }
236
+
237
+ /**
238
+ * One `synap_requests[].permission`, refused here when the pod would refuse
239
+ * it (at most as strict: any `https://<name>.synap.live` passes the review-verb
240
+ * check here, while the pod admits only its own list of Synap app ids, which
241
+ * this SDK does not copy; the pod's answer is the authority): `<subject>[.<kind>].<action>`, lowercase, a kind only on
223
242
  * `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).
243
+ * Synap app, or on mock mode's loopback origin). Stricter on one point: no `*`
244
+ * at all (`can()` matches exactly).
225
245
  */
226
246
  function assertAppPermission(permission: string, origin: string): void {
227
247
  const segs = permission.split(".");
@@ -232,7 +252,7 @@ function assertAppPermission(permission: string, origin: string): void {
232
252
  fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
233
253
  }
234
254
  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;
255
+ if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && (isSynapAppOrigin(origin) || isMockModeOrigin(origin))) return;
236
256
  if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
237
257
  fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
238
258
  }
@@ -405,7 +425,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
405
425
  };
406
426
 
407
427
  /** Off to the pod's consent: fresh PKCE + state, the transaction cookie set (extra cookies first). */
408
- async function toPod(opts: { pod: string; next: string; space: string | null; prompt: "consent" | null }, extra: string[] = []) {
428
+ async function toPod(opts: { pod: string; next: string; space: string | null; prompt: "consent" | "login" | null }, extra: string[] = []) {
409
429
  const started = await startConnect({
410
430
  ...flow,
411
431
  pod: opts.pod,
@@ -445,7 +465,9 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
445
465
  const pod = formField(form, "pod")?.slice(0, 512) ?? "";
446
466
  const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
447
467
  const space = formField(form, "space");
448
- const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
468
+ const asked = formField(form, "prompt");
469
+ // `login` is a step-up of the pod's own sign-in; it never goes through Synap Cloud (a Cloud sign-in never counts).
470
+ const prompt = asked === "consent" || asked === "login" ? asked : null;
449
471
  try {
450
472
  if (!prompt && (await liveConnectionTo(request, pod))) return redirect(new URL(next, origin).href);
451
473
  return await toPod({ pod, next, space, prompt });
@@ -463,6 +485,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
463
485
  ...flow,
464
486
  callbackUrl: request.url,
465
487
  transaction,
488
+ current: await store.read(request).catch(() => null),
466
489
  clientId,
467
490
  redirectUri,
468
491
  secret: config.secret,
@@ -66,8 +66,29 @@ export type PodErrorCode =
66
66
  | "pod_unreachable"
67
67
  /** The pod answered a redirect (a moved pod answers 308 from its old name): connect again at its new address. */
68
68
  | "pod_moved"
69
+ /**
70
+ * A credential door (approving a proposal that widens who or what can reach
71
+ * the pod) wants the person to re-confirm at the POD within 15 minutes; a
72
+ * Synap Cloud sign-in never counts. Recovery: `prompt_login`.
73
+ */
74
+ | "step_up_required"
75
+ /** Synap Cloud refused to vouch a person with no verified email. Recovery: `verify_email`. */
76
+ | "email_unverified"
77
+ /** Synap Cloud vouches an app origin over HTTPS only. Recovery: `use_https`. */
78
+ | "origin_insecure"
69
79
  | "server";
70
80
 
81
+ /**
82
+ * What a typed refusal asks the app to do:
83
+ * - `prompt_login`: send the person through the connect handler with
84
+ * `prompt=login` (re-confirm at the pod; the SDK redeems it with the
85
+ * device's refresh token), then retry ONCE;
86
+ * - `verify_email`: tell the person to verify their Synap email;
87
+ * - `use_https`: serve the app over HTTPS (`http://localhost` signs in to a
88
+ * local Control Plane started with `CP_ALLOW_DEV_ORIGINS=true`, never to api.synap.live).
89
+ */
90
+ export type PodRecovery = "prompt_login" | "verify_email" | "use_https";
91
+
71
92
  /**
72
93
  * Why the SDK refused before sending anything (`status: null`):
73
94
  * - `agent_cannot_approve`: an agent connection asked to approve (the pod
@@ -86,6 +107,8 @@ export interface PodError {
86
107
  detail?: string;
87
108
  /** Set when the SDK refused locally (1.5). */
88
109
  reason?: PodRefusalReason;
110
+ /** What to do next, on `step_up_required`, `email_unverified` and `origin_insecure`. */
111
+ recovery?: PodRecovery;
89
112
  /**
90
113
  * On `unauthorized` (1.5): the pod refused the access token, the connection
91
114
  * still holds a refresh token, and this client could not renew it (no
@@ -297,14 +320,44 @@ function renewalError(err: unknown): PodError {
297
320
  }
298
321
  }
299
322
 
323
+ /** The pod's step-up refusal (backend `STEP_UP_REQUIRED_MESSAGE`); the Hub's REST body carries only this text. */
324
+ const STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
325
+
326
+ /** The Control Plane's public problem codes (409, `{ error, code, recovery }`). */
327
+ const CP_PROBLEMS: Readonly<Record<string, { code: PodErrorCode; recovery: PodRecovery }>> = {
328
+ CP_ACCOUNT_EMAIL_REQUIRED: { code: "email_unverified", recovery: "verify_email" },
329
+ FEDERATED_APPLICATION_ORIGIN_INSECURE: { code: "origin_insecure", recovery: "use_https" },
330
+ };
331
+
332
+ function isStepUp(obj: Record<string, unknown>): boolean {
333
+ const data = (v: unknown) => (v && typeof v === "object" ? (v as Record<string, unknown>) : {});
334
+ // tRPC shape (`error.data.reasonCode`), a flat reasonCode, or the Hub REST body: `{ error: <message> }`.
335
+ const nested = data(data(obj.error).data).reasonCode ?? data(obj.data).reasonCode ?? obj.reasonCode;
336
+ return nested === "STEP_UP_REQUIRED" || obj.error === STEP_UP_MESSAGE || obj.message === STEP_UP_MESSAGE;
337
+ }
338
+
339
+ /**
340
+ * A 403 or 409 body → its typed code. A step-up is read on a 403 only, a Control
341
+ * Plane problem on a 409 only; everything else keeps the plain `forbidden` /
342
+ * `door_unsupported` / `conflict`, so a typed refusal is never read as a generic one.
343
+ */
344
+ export function classifyRefusal(status: 403 | 409 | number, body: unknown): { code: PodErrorCode; recovery?: PodRecovery } {
345
+ const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
346
+ if (status === 403) {
347
+ if (isStepUp(obj)) return { code: "step_up_required", recovery: "prompt_login" };
348
+ return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden" };
349
+ }
350
+ const problem = typeof obj.code === "string" ? CP_PROBLEMS[obj.code] : undefined;
351
+ return problem ?? { code: "conflict" };
352
+ }
353
+
300
354
  function errorFor(status: number, body: unknown, retryAfter: string | null, now: number): PodError {
301
355
  const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
302
356
  const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : undefined;
303
357
  const base = { status, ...(detail !== undefined ? { detail } : {}) };
304
358
  if (status === 401) return { code: "unauthorized", ...base };
305
- if (status === 403) return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden", ...base };
359
+ if (status === 403 || status === 409) return { ...classifyRefusal(status, body), ...base };
306
360
  if (status === 404) return { code: "not_found", ...base };
307
- if (status === 409) return { code: "conflict", ...base };
308
361
  if (status === 400 || status === 422) return { code: "invalid", ...base };
309
362
  if (status === 429) {
310
363
  const retryAfterSec = retryAfterSeconds(retryAfter, now);
@@ -501,6 +554,9 @@ export function createPodClient(connection: PodConnection, opts: PodClientOption
501
554
  offset: clamp(opts.offset, 0, Number.MAX_SAFE_INTEGER),
502
555
  sort: opts.sort,
503
556
  includePodWide: opts.includePodWide,
557
+ facetSlug: opts.facetSlug,
558
+ // Sent only when asked: the default request stays the 1.5.0 one.
559
+ includeFacets: opts.includeFacets ? true : undefined,
504
560
  }, (data) => (Array.isArray(data) ? (data.filter(hasId) as PodEntity[]) : null));
505
561
  }
506
562
 
@@ -48,6 +48,8 @@ export interface ConnectTransaction {
48
48
  resource: string | null;
49
49
  /** A same-origin path to land on afterwards. */
50
50
  next: string | null;
51
+ /** The sign-in re-confirms a device that is already connected (`prompt=login`). */
52
+ stepUp?: boolean;
51
53
  createdAt: number;
52
54
  }
53
55
 
@@ -74,8 +76,13 @@ export interface StartConnectOptions extends FlowOptions {
74
76
  * `"consent"`: the person asked to see the consent screen again (to widen or
75
77
  * narrow what the app may touch). Sent as OIDC `prompt=consent`; the sign-in
76
78
  * is otherwise the same (fresh PKCE verifier and state).
79
+ *
80
+ * `"login"`: a step-up. A credential door refused a stale device
81
+ * (`step_up_required`): the person re-confirms at the pod (OIDC
82
+ * `prompt=login`) and the callback redeems the code WITH the connection's
83
+ * refresh token (`synap_device_refresh_token`; see `HandleCallbackOptions.current`).
77
84
  */
78
- prompt?: "consent" | null;
85
+ prompt?: "consent" | "login" | null;
79
86
  allowInsecureLoopback?: boolean;
80
87
  }
81
88
 
@@ -124,7 +131,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
124
131
  url.searchParams.set("code_challenge_method", "S256");
125
132
  if (resource) url.searchParams.set("resource", resource);
126
133
  if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
127
- if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
134
+ if (opts.prompt === "consent" || opts.prompt === "login") url.searchParams.set("prompt", opts.prompt);
128
135
 
129
136
  const tx: ConnectTransaction = {
130
137
  v: 1,
@@ -137,6 +144,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
137
144
  verifier,
138
145
  resource,
139
146
  next: opts.next ?? null,
147
+ ...(opts.prompt === "login" ? { stepUp: true } : {}),
140
148
  createdAt: now,
141
149
  };
142
150
  const expiresAt = now + TRANSACTION_TTL_MS;
@@ -179,6 +187,13 @@ export interface HandleCallbackOptions extends FlowOptions {
179
187
  clientId: string;
180
188
  redirectUri: string;
181
189
  secret: AppSecret;
190
+ /**
191
+ * The connection this browser holds now. A step-up (`prompt=login`) of a
192
+ * Synap app's device is redeemed with ITS refresh token (the pod moves that
193
+ * device's clock instead of making a second device); without one, or for an
194
+ * app that is not a person's device, the code is redeemed as any other.
195
+ */
196
+ current?: PodConnection | null;
182
197
  }
183
198
 
184
199
  export interface CallbackResult {
@@ -219,6 +234,11 @@ export async function handleCallback(opts: HandleCallbackOptions): Promise<Callb
219
234
  code_verifier: tx.verifier,
220
235
  });
221
236
  if (tx.resource) body.set("resource", tx.resource);
237
+ const device =
238
+ tx.stepUp && opts.current && opts.current.actsAs === "person" && opts.current.podUrl === tx.issuer && opts.current.clientId === tx.clientId
239
+ ? opts.current
240
+ : null;
241
+ if (device?.refreshToken) body.set("synap_device_refresh_token", device.refreshToken);
222
242
  const json = await tokenRequest(tx.tokenEndpoint, body, "token", opts);
223
243
  const connection = parseTokenResponse(json, {
224
244
  podUrl: tx.issuer,
@@ -226,6 +246,7 @@ export async function handleCallback(opts: HandleCallbackOptions): Promise<Callb
226
246
  tokenEndpoint: tx.tokenEndpoint,
227
247
  revocationEndpoint: tx.revocationEndpoint,
228
248
  now: (opts.now ?? Date.now)(),
249
+ ...(device ? { previous: device } : {}),
229
250
  });
230
251
  return { connection, next: tx.next };
231
252
  }
@@ -38,9 +38,22 @@ export interface PodEntity {
38
38
  /** ISO 8601. */
39
39
  createdAt?: string;
40
40
  updatedAt?: string;
41
+ /** The roles it wears, with `includeFacets`; absent when it wears none. */
42
+ facetSlugs?: string[];
43
+ /** Each role's overlay, with `includeFacets`; absent when it wears none. */
44
+ facets?: PodEntityFacet[];
41
45
  [key: string]: unknown;
42
46
  }
43
47
 
48
+ /** One role an entity wears (`FacetRowAnnotation`). */
49
+ export interface PodEntityFacet {
50
+ facetId: string;
51
+ /** The role, e.g. `lead`. */
52
+ slug: string;
53
+ properties: Record<string, unknown>;
54
+ status: string | null;
55
+ }
56
+
44
57
  export interface ListEntitiesOptions {
45
58
  /** One granted space. Omitted: every space the connection can read. */
46
59
  workspaceId?: string;
@@ -53,6 +66,10 @@ export interface ListEntitiesOptions {
53
66
  sort?: string;
54
67
  /** With `workspaceId`: also the person's pod-wide rows (the pod's default: true). */
55
68
  includePodWide?: boolean;
69
+ /** Only the entities wearing this role, e.g. `lead`. */
70
+ facetSlug?: string;
71
+ /** Put `facetSlugs` and `facets` on each row that wears a role. */
72
+ includeFacets?: boolean;
56
73
  }
57
74
 
58
75
  export type PodConnectionSource = "graph" | "property" | "thread" | "context_channel" | "focus_session";
@@ -30,6 +30,7 @@ export type {
30
30
  PodClientOptions,
31
31
  PodError,
32
32
  PodErrorCode,
33
+ PodRecovery,
33
34
  PodRefusalReason,
34
35
  PodResult,
35
36
  PodWriteResult,
@@ -48,6 +49,7 @@ export type {
48
49
  PodEntity,
49
50
  PodEntityConnection,
50
51
  PodEntityConnections,
52
+ PodEntityFacet,
51
53
  PodFileUrl,
52
54
  PodFocusSession,
53
55
  PodSessionSlot,