@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 +81 -7
- package/dist/{client-CVUT1Mcf.d.cts → client-C5sF20f1.d.cts} +1 -1
- package/dist/{client-CVUT1Mcf.d.ts → client-C5sF20f1.d.ts} +1 -1
- package/dist/connect.cjs +397 -116
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +220 -15
- package/dist/connect.d.ts +220 -15
- package/dist/connect.js +389 -116
- package/dist/connect.js.map +1 -1
- package/dist/public.d.cts +2 -2
- package/dist/public.d.ts +2 -2
- package/dist/testing.cjs +138 -11
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +92 -4
- package/dist/testing.d.ts +92 -4
- package/dist/testing.js +134 -11
- package/dist/testing.js.map +1 -1
- package/dist/{types-DpqPNaaa.d.cts → types-C2KGNXEn.d.cts} +20 -1
- package/dist/{types-DpqPNaaa.d.ts → types-C2KGNXEn.d.ts} +20 -1
- package/package.json +8 -8
- package/src/connect/auth.ts +161 -31
- package/src/connect/client.ts +107 -1
- package/src/connect/cloud.ts +206 -0
- package/src/connect/discovery.ts +15 -14
- package/src/connect/errors.ts +1 -1
- package/src/connect/flow.ts +41 -1
- package/src/connect/index.ts +39 -11
- package/src/connect/seal.ts +1 -1
- package/src/connect/store.ts +41 -3
- package/src/connect/types.ts +20 -0
- package/src/testing/auth-server.ts +144 -12
- package/src/testing/cloud.ts +84 -0
- package/src/testing/index.ts +3 -1
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 };
|