@synap-core/sdk 1.3.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 CHANGED
@@ -119,7 +119,7 @@ SDK refuses out-of-bounds `offset`/`limit`/`sort` locally, without a request),
119
119
  pass `ifNoneMatch` from a browser: the doors allow no cross-origin request header
120
120
  but `Content-Type`, so the preflight fails (browsers revalidate on their own).
121
121
 
122
- ## Pod-app sign-in (server-only): `@synap-core/sdk/connect` (1.3.0)
122
+ ## Pod-app sign-in (server-only): `@synap-core/sdk/connect` (1.3.0, 1.4.0)
123
123
 
124
124
  An app that reads and writes a person's OWN pod connects as an OAuth 2.1 public
125
125
  client of that pod: its `client_id` is the https URL of its client metadata
@@ -142,20 +142,33 @@ export const synap = createPodAppAuth({
142
142
  { permission: "entity.post.create", reason: "Save drafts to your pod" },
143
143
  ],
144
144
  secret: process.env.SYNAP_APP_SESSION_SECRET!, // ≥ 32 chars, never NEXT_PUBLIC_
145
+ cloudUrl: process.env.SYNAP_CLOUD_URL, // optional (1.4): https://api.synap.live offers "Continue with Synap Cloud"
145
146
  });
146
147
 
147
148
  // app/.well-known/synap-client.json/route.ts export const GET = synap.handlers.clientMetadata;
148
- // app/api/synap/connect/route.ts export const POST = synap.handlers.connect; // form { pod, next?, space? }
149
+ // app/api/synap/connect/route.ts export const POST = synap.handlers.connect; // form { pod, next?, space?, prompt? }
149
150
  // app/api/synap/callback/route.ts export const GET = synap.handlers.callback;
150
151
  // app/api/synap/disconnect/route.ts export const POST = synap.handlers.disconnect; // form { next? }
152
+ // app/api/synap/cloud/route.ts (1.4) export const POST = synap.handlers.cloudConnect; // form { next?, space?, prompt? }
153
+ // export const GET = synap.handlers.cloudReturn;
151
154
  ```
152
155
 
153
156
  ```typescript
154
157
  // proxy.ts: refresh before render (server components cannot set cookies)
155
158
  const out = await synap.refreshIfNeeded(request);
156
159
  // out.status: none | skipped | fresh | refreshed | kept | expired | revoked | unavailable
160
+ // out.lapsed (with none, 1.4): { podUrl } when this browser WAS connected and the connection lapsed
157
161
  // send out.setCookies on the response; forward out.cookieHeader (when set) as the request's Cookie
158
162
 
163
+ // Next 16 strips its router headers before proxy code runs, so skip prefetches in the MATCHER
164
+ // (it still sees them; matcher values must be literals):
165
+ export const config = {
166
+ matcher: [{
167
+ source: "/((?!_next/static|_next/image|api/synap/|\\.well-known/|.*\\.\\w+$).*)",
168
+ missing: [{ type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }],
169
+ }],
170
+ };
171
+
159
172
  // a server component / action (no Request there: pass the Cookie header)
160
173
  const connection = await synap.readConnection((await headers()).get("cookie"));
161
174
  const pod = synap.client(connection!);
@@ -168,8 +181,7 @@ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupp
168
181
  metadata and requires `issuer` equal to the address, CIMD, `iss` on answers and
169
182
  S256 (`not_a_pod`, `pod_unsupported`, `pod_unreachable`). A browser launch
170
183
  (`?iss=…&target_link_uri=…`) is read with `acceptLaunch` and only pre-fills the
171
- connect page. Synap Cloud discovery is not available yet:
172
- `cloudConnectAvailable()` is `false`.
184
+ connect page.
173
185
  - **Failures** redirect with `?error=<ConnectErrorCode>`: `denied`,
174
186
  `expired_transaction`, `mismatch`, `misconfigured`, `rate_limited`,
175
187
  `pod_unreachable`, … (`parseConnectErrorCode` reads it back). `connect` and
@@ -184,19 +196,81 @@ if (!res.ok) res.error.code; // expired | unauthorized | forbidden | door_unsupp
184
196
  rotation's result instead of reaching the pod. Across server instances only
185
197
  a shared server-side `ConnectionStore` rules that replay out. A concurrent
186
198
  loser (refused softly by the pod) keeps the still-valid access token.
199
+ The pod's reuse grace (PB3b) answers a spent token presented again within
200
+ 30 s with the same pair the first use got; its `expires_in` may be under an
201
+ hour, and the SDK reads it as sent.
187
202
  - **Your own store**: implement `ConnectionStore` (`read`, `write`, `clear`,
188
203
  returning Set-Cookie values) and pass it as `store`.
189
204
  - Lower level: `startConnect`, `handleCallback`, `refreshConnection`,
190
- `revokeConnection`, `buildClientMetadata`, `seal`/`unseal`.
205
+ `revokeConnection`, `buildClientMetadata`, `seal`/`unseal`,
206
+ `startCloudConnect`, `acceptCloudReturn`.
207
+
208
+ ### 1.4.0 (additive)
209
+
210
+ - **Re-consent**: the connect form's `prompt=consent` (or `startConnect({ prompt: "consent" })`)
211
+ goes to the pod's consent screen even when this browser is already connected to
212
+ that pod (to widen or narrow access). It is a normal sign-in: fresh PKCE verifier
213
+ and state, `iss` checked; a denied or forged answer leaves the live connection as it is.
214
+ - **Synap Cloud** (wave C1): with `cloudUrl` set, `synap.cloudConnectAvailable` is
215
+ true and `cloudConnectAvailable({ cloudUrl })` too. POST `handlers.cloudConnect`
216
+ sends the browser to `<cp>/connect/pod?client_id=<client id>&state=<nonce>`, the
217
+ state sealed in an HttpOnly cookie of that browser (10 minutes). The CP returns to
218
+ the CIMD `initiate_login_uri?iss=<pod>&state=<nonce>` (your connect page):
219
+ `acceptLaunch(url).cloudState` is set, so forward the request's query to GET
220
+ `handlers.cloudReturn`, which checks the state against the cookie (constant time,
221
+ single use) BEFORE the pod is used, then runs the same sign-in as a typed address.
222
+ `acceptLaunch` itself never verifies anything; a return without a matching state
223
+ starts no sign-in (`mismatch`, `expired_transaction`). A cancel on the chooser
224
+ (`?state=…&error=access_denied`, no `iss`) also carries `cloudState`: forwarded the
225
+ same way, it lands on the connect page with `?error=denied` once the state matched.
226
+ Security notes: after the state matches, the named pod goes through the same
227
+ discovery as a typed address (metadata, `issuer` equality, CIMD, `iss`, S256)
228
+ before the browser is sent anywhere. The cloud cookie is single-use and cleared on
229
+ every outcome (success, cancel, mismatch). Accepted trade-off: a third-party page
230
+ that sends a visitor's browser to a forged return can cancel that visitor's Cloud
231
+ sign-in in progress (they click again); nothing leaks and nothing is bound, the
232
+ same nuisance any OAuth `state` check allows.
233
+
234
+ ```typescript
235
+ // app/connect/page.tsx (server component)
236
+ const launch = acceptLaunch(url, origin);
237
+ if (launch.cloudState) redirect(`${synap.paths.cloud}${new URL(url).search}`);
238
+ ```
239
+ - **`connection.manageUrl` / `connection.appId`**: the pod's page for this app
240
+ connection (pod-admin `/apps/<appId>`), read from the token response's
241
+ `synap_app: { id, manageUrl }` (https only). Absent when the pod does not send it:
242
+ hide the link, never build one.
243
+ - **`client.listPendingProposals({ workspaceId?, limit?, offset? })`**: "Waiting for
244
+ review: N" (`data.total`, plus summary rows with `reviewUrl`) from the pod's seamed
245
+ `GET /proposals` door. Ask for `proposal.read` in `requests`: without it the answer
246
+ is `forbidden` and nothing is sent (the pod would answer an empty list). It counts
247
+ the person's pending proposals in those spaces, from any agent.
248
+ - **`spaces(connection)` / `client.spaces()`**: the grant by space
249
+ (`{ workspaceId, workspaceName, permissions }`), each permission once.
250
+ - **Expired vs never connected**: each sign-in and refresh also sets a marker cookie
251
+ (`__Host-synap_seen`, 400 days) holding the pod's URL and nothing else.
252
+ `synap.readLapsed(cookieHeader)` and `RefreshOutcome.lapsed` say "this browser was
253
+ connected to <pod>" once the sealed session is gone; a disconnect clears it.
254
+ - **`isPrefetchOrAsset(request)`** is exported, and its limit documented: inside
255
+ Next 16's proxy a router prefetch is invisible (see the matcher above).
256
+ - Not added: `ensureTemplate` on the pod-app client. A pod-app key's grant is pinned
257
+ to the approved spaces, so creating a space is refused by the pod's grant check
258
+ every time; ask for templates with `templates` (CIMD `synap_templates`) instead:
259
+ the pod's consent offers to create the space.
191
260
 
192
261
  Test without a pod: `createMockAuthServer()` from `@synap-core/sdk/testing`
193
262
  (metadata, `authorize()` as the consent screen, `/token` with PKCE, rotating
194
- refresh, `/revoke`, Hub REST doors; `writeMode`, `failures`, `refresh: false`):
263
+ refresh with the 30 s reuse grace, `/revoke`, Hub REST doors: `GET /entities`
264
+ answers an array as the pod does, `GET /proposals` lists what proposed writes
265
+ filed; `synap_app` is `MOCK_APP` unless `app: null`; `writeMode`, `failures`, `refresh: false`, `entities`,
266
+ `refreshGraceSec`), and `createMockCloud()` for the Synap Cloud pod chooser:
195
267
 
196
268
  ```typescript
197
269
  const pod = createMockAuthServer();
198
- const synap = createPodAppAuth({ ...config, fetch: pod.fetch });
270
+ const cloud = createMockCloud();
271
+ const synap = createPodAppAuth({ ...config, fetch: pod.fetch, cloudUrl: cloud.url });
199
272
  // POST connect → Location → pod.authorize(location) → GET callback with that URL
273
+ // POST cloud → Location → cloud.choose(location, { pod: pod.issuer, metadata: synap.metadata }) → forward to GET cloud
200
274
  ```
201
275
 
202
276
  ## Mock pod: `@synap-core/sdk/testing`
@@ -250,4 +250,4 @@ interface PublicClient {
250
250
  }
251
251
  declare function createPublicClient(opts: PublicClientOptions): PublicClient;
252
252
 
253
- export { type CollectionQuery as C, type PublicForm as P, type Revalidate as R, type SubmitResult as S, type PublicShare as a, type PublicRecord as b, type PublicReadError as c, type PublicRecordSummary as d, type PublicValue as e, type PublicCollectionPage as f, type PublicResult as g, type PublicClient as h, type PublicClientOptions as i, type PublicFailure as j, type PublicFormField as k, type PublicFormFieldType as l, type PublicNotModified as m, type PublicSite as n, type PublicSiteCollection as o, type PublicSiteError as p, type PublicSiteResult as q, type PublicSubmitError as r, SITE_MAX_LIMIT as s, SITE_MAX_OFFSET as t, SITE_MAX_SORT_KEYS as u, SITE_PUBLISHED_AT_SORT as v, type SubmitFormInput as w, createPublicClient as x };
253
+ export { type CollectionQuery as C, type PublicForm as P, type Revalidate as R, type SubmitResult as S, type PublicReadError as a, type PublicRecordSummary as b, type PublicValue as c, type PublicCollectionPage as d, type PublicRecord as e, type PublicResult as f, type PublicShare as g, type PublicClient as h, type PublicClientOptions as i, type PublicFailure as j, type PublicFormField as k, type PublicFormFieldType as l, type PublicNotModified as m, type PublicSite as n, type PublicSiteCollection as o, type PublicSiteError as p, type PublicSiteResult as q, type PublicSubmitError as r, SITE_MAX_LIMIT as s, SITE_MAX_OFFSET as t, SITE_MAX_SORT_KEYS as u, SITE_PUBLISHED_AT_SORT as v, type SubmitFormInput as w, createPublicClient as x };
@@ -250,4 +250,4 @@ interface PublicClient {
250
250
  }
251
251
  declare function createPublicClient(opts: PublicClientOptions): PublicClient;
252
252
 
253
- export { type CollectionQuery as C, type PublicForm as P, type Revalidate as R, type SubmitResult as S, type PublicShare as a, type PublicRecord as b, type PublicReadError as c, type PublicRecordSummary as d, type PublicValue as e, type PublicCollectionPage as f, type PublicResult as g, type PublicClient as h, type PublicClientOptions as i, type PublicFailure as j, type PublicFormField as k, type PublicFormFieldType as l, type PublicNotModified as m, type PublicSite as n, type PublicSiteCollection as o, type PublicSiteError as p, type PublicSiteResult as q, type PublicSubmitError as r, SITE_MAX_LIMIT as s, SITE_MAX_OFFSET as t, SITE_MAX_SORT_KEYS as u, SITE_PUBLISHED_AT_SORT as v, type SubmitFormInput as w, createPublicClient as x };
253
+ export { type CollectionQuery as C, type PublicForm as P, type Revalidate as R, type SubmitResult as S, type PublicReadError as a, type PublicRecordSummary as b, type PublicValue as c, type PublicCollectionPage as d, type PublicRecord as e, type PublicResult as f, type PublicShare as g, type PublicClient as h, type PublicClientOptions as i, type PublicFailure as j, type PublicFormField as k, type PublicFormFieldType as l, type PublicNotModified as m, type PublicSite as n, type PublicSiteCollection as o, type PublicSiteError as p, type PublicSiteResult as q, type PublicSubmitError as r, SITE_MAX_LIMIT as s, SITE_MAX_OFFSET as t, SITE_MAX_SORT_KEYS as u, SITE_PUBLISHED_AT_SORT as v, type SubmitFormInput as w, createPublicClient as x };