@synap-core/sdk 1.5.1 → 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.
- package/AGENTS.md +250 -0
- package/CHANGELOG.md +83 -17
- package/README.md +3 -3
- package/dist/connect.cjs +29 -5
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +39 -3
- package/dist/connect.d.ts +39 -3
- package/dist/connect.js +29 -5
- package/dist/connect.js.map +1 -1
- package/dist/testing.cjs +68 -9
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +19 -1
- package/dist/testing.d.ts +19 -1
- package/dist/testing.js +68 -9
- package/dist/testing.js.map +1 -1
- package/package.json +10 -9
- package/src/connect/auth.ts +14 -4
- package/src/connect/client.ts +55 -2
- package/src/connect/flow.ts +23 -2
- package/src/connect/index.ts +1 -0
- package/src/testing/auth-server.ts +118 -14
package/dist/testing.d.cts
CHANGED
|
@@ -172,7 +172,13 @@ declare function parseMockPodSeed(input: string | unknown): MockPodSeed;
|
|
|
172
172
|
declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
173
173
|
declare const MOCK_GRANT: PodGrant[];
|
|
174
174
|
declare const MOCK_USER: PodUser;
|
|
175
|
-
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
|
|
175
|
+
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
|
|
176
|
+
/** 403 `{ error: <the pod's step-up message> }`: a stale device at a credential door (the Hub REST body has no reasonCode). */
|
|
177
|
+
| "step_up_required"
|
|
178
|
+
/** 409 problem `CP_ACCOUNT_EMAIL_REQUIRED` (Control Plane: no verified email). */
|
|
179
|
+
| "email_unverified"
|
|
180
|
+
/** 409 problem `FEDERATED_APPLICATION_ORIGIN_INSECURE` (Control Plane: an app origin that is not HTTPS). */
|
|
181
|
+
| "origin_insecure" | "unreachable" | "server" | "unavailable";
|
|
176
182
|
interface MockAuthServerOptions {
|
|
177
183
|
issuer?: string;
|
|
178
184
|
/** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
|
|
@@ -209,6 +215,14 @@ interface MockAuthServerOptions {
|
|
|
209
215
|
* not name it; the SDK reads an agent connection).
|
|
210
216
|
*/
|
|
211
217
|
actsAs?: PodActsAs;
|
|
218
|
+
/**
|
|
219
|
+
* A Synap app's device starts STALE: `POST /proposals/:id/approve` is refused
|
|
220
|
+
* (403 `{ error: <step-up message> }`, the Hub REST shape) until the person
|
|
221
|
+
* re-confirms at the pod: `/authorize?prompt=login`, the code redeemed with the
|
|
222
|
+
* device's refresh token as `synap_device_refresh_token`. Needs `actsAs: "person"`.
|
|
223
|
+
* `expireStepUp()` makes it stale again (the 15-minute window).
|
|
224
|
+
*/
|
|
225
|
+
requireStepUp?: boolean;
|
|
212
226
|
/**
|
|
213
227
|
* The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
|
|
214
228
|
* before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
|
|
@@ -284,6 +298,10 @@ interface MockAuthServer {
|
|
|
284
298
|
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
285
299
|
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
286
300
|
consumeRefreshToken(token: string): void;
|
|
301
|
+
/** The pod now says `synap_acts_as: v` (null = says nothing): the app left, or joined, the Synap list. */
|
|
302
|
+
setActsAs(v: PodActsAs | null): void;
|
|
303
|
+
/** The device's last real sign-in is older than the step-up window. */
|
|
304
|
+
expireStepUp(): void;
|
|
287
305
|
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
288
306
|
loseNextRefreshRace(): void;
|
|
289
307
|
/** Refresh-token redeems that were reuses (each revoked a family). */
|
package/dist/testing.d.ts
CHANGED
|
@@ -172,7 +172,13 @@ declare function parseMockPodSeed(input: string | unknown): MockPodSeed;
|
|
|
172
172
|
declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
173
173
|
declare const MOCK_GRANT: PodGrant[];
|
|
174
174
|
declare const MOCK_USER: PodUser;
|
|
175
|
-
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
|
|
175
|
+
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported"
|
|
176
|
+
/** 403 `{ error: <the pod's step-up message> }`: a stale device at a credential door (the Hub REST body has no reasonCode). */
|
|
177
|
+
| "step_up_required"
|
|
178
|
+
/** 409 problem `CP_ACCOUNT_EMAIL_REQUIRED` (Control Plane: no verified email). */
|
|
179
|
+
| "email_unverified"
|
|
180
|
+
/** 409 problem `FEDERATED_APPLICATION_ORIGIN_INSECURE` (Control Plane: an app origin that is not HTTPS). */
|
|
181
|
+
| "origin_insecure" | "unreachable" | "server" | "unavailable";
|
|
176
182
|
interface MockAuthServerOptions {
|
|
177
183
|
issuer?: string;
|
|
178
184
|
/** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
|
|
@@ -209,6 +215,14 @@ interface MockAuthServerOptions {
|
|
|
209
215
|
* not name it; the SDK reads an agent connection).
|
|
210
216
|
*/
|
|
211
217
|
actsAs?: PodActsAs;
|
|
218
|
+
/**
|
|
219
|
+
* A Synap app's device starts STALE: `POST /proposals/:id/approve` is refused
|
|
220
|
+
* (403 `{ error: <step-up message> }`, the Hub REST shape) until the person
|
|
221
|
+
* re-confirms at the pod: `/authorize?prompt=login`, the code redeemed with the
|
|
222
|
+
* device's refresh token as `synap_device_refresh_token`. Needs `actsAs: "person"`.
|
|
223
|
+
* `expireStepUp()` makes it stale again (the 15-minute window).
|
|
224
|
+
*/
|
|
225
|
+
requireStepUp?: boolean;
|
|
212
226
|
/**
|
|
213
227
|
* The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
|
|
214
228
|
* before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
|
|
@@ -284,6 +298,10 @@ interface MockAuthServer {
|
|
|
284
298
|
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
285
299
|
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
286
300
|
consumeRefreshToken(token: string): void;
|
|
301
|
+
/** The pod now says `synap_acts_as: v` (null = says nothing): the app left, or joined, the Synap list. */
|
|
302
|
+
setActsAs(v: PodActsAs | null): void;
|
|
303
|
+
/** The device's last real sign-in is older than the step-up window. */
|
|
304
|
+
expireStepUp(): void;
|
|
287
305
|
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
288
306
|
loseNextRefreshRace(): void;
|
|
289
307
|
/** Refresh-token redeems that were reuses (each revoked a family). */
|
package/dist/testing.js
CHANGED
|
@@ -140,6 +140,7 @@ var MOCK_GRANT = [
|
|
|
140
140
|
{ permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
141
141
|
{ permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" }
|
|
142
142
|
];
|
|
143
|
+
var STEP_UP_MESSAGE = "Confirm it's you before changing who or what can reach this pod.";
|
|
143
144
|
var MOCK_USER = { id: "user_mock_ada", name: "Ada Lovelace" };
|
|
144
145
|
var MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
|
|
145
146
|
var MOCK_APP_AGENT_ID = "agent_mock_app";
|
|
@@ -178,16 +179,32 @@ function createMockAuthServer(options = {}) {
|
|
|
178
179
|
const seededAt = new Date(now()).toISOString();
|
|
179
180
|
const rolesOf = (e) => Array.isArray(e.facets) ? e.facets.filter((f) => typeof f?.slug === "string") : [];
|
|
180
181
|
function readableEntity(e, spaces, only) {
|
|
181
|
-
if (typeof e.workspaceId === "string" &&
|
|
182
|
+
if (typeof e.workspaceId === "string" && kindReadable(e, spaces, e.workspaceId) && (!only || e.workspaceId === only)) return true;
|
|
182
183
|
return facetsOf(e, spaces, only ?? void 0).some((f) => e.workspaceId == null || !f.sharedRole);
|
|
183
184
|
}
|
|
184
|
-
const facetsOf = (e, spaces, lens) => rolesOf(e).filter((f) => typeof f.workspaceId === "string" && spaces
|
|
185
|
+
const facetsOf = (e, spaces, lens) => rolesOf(e).filter((f) => typeof f.workspaceId === "string" && kindReadable(e, spaces, f.workspaceId) && (lens === void 0 || f.workspaceId === lens));
|
|
185
186
|
const facetRow = (f) => ({ facetId: f.facetId, slug: f.slug, properties: f.properties ?? {}, status: f.status ?? null });
|
|
186
187
|
const entityWire = (e) => {
|
|
187
188
|
const { facets: _facets, facetSlugs: _facetSlugs, ...wire } = e;
|
|
188
189
|
return wire;
|
|
189
190
|
};
|
|
190
|
-
const readSpaces = (scope) =>
|
|
191
|
+
const readSpaces = (scope) => {
|
|
192
|
+
const out = /* @__PURE__ */ new Map();
|
|
193
|
+
for (const g of grant) {
|
|
194
|
+
const m = /^entity\.(?:(.+)\.)?read$/.exec(g.permission);
|
|
195
|
+
if (!m || !scope.includes(g.permission)) continue;
|
|
196
|
+
const kind = m[1];
|
|
197
|
+
if (kind === void 0 || kind === "*") out.set(g.workspaceId, null);
|
|
198
|
+
else if (out.get(g.workspaceId) !== null) out.set(g.workspaceId, (out.get(g.workspaceId) ?? /* @__PURE__ */ new Set()).add(kind));
|
|
199
|
+
else continue;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
};
|
|
203
|
+
const kindReadable = (e, spaces, space) => {
|
|
204
|
+
if (!spaces.has(space)) return false;
|
|
205
|
+
const kinds = spaces.get(space);
|
|
206
|
+
return kinds === null || kinds === void 0 ? kinds === null : kinds.has(String(e.profileSlug ?? e.type ?? ""));
|
|
207
|
+
};
|
|
191
208
|
for (const p of seed.proposals ?? []) {
|
|
192
209
|
proposals.push({
|
|
193
210
|
proposalType: "create",
|
|
@@ -202,7 +219,9 @@ function createMockAuthServer(options = {}) {
|
|
|
202
219
|
...p
|
|
203
220
|
});
|
|
204
221
|
}
|
|
205
|
-
|
|
222
|
+
let actsAs = options.actsAs;
|
|
223
|
+
const writeMode = () => options.writeMode ?? (actsAs === "person" ? "applied" : "proposed");
|
|
224
|
+
let deviceFresh = !options.requireStepUp;
|
|
206
225
|
const graceMs = (options.refreshGraceSec ?? 30) * 1e3;
|
|
207
226
|
const app = options.app === void 0 ? MOCK_APP : options.app;
|
|
208
227
|
let proposalSeq = 0;
|
|
@@ -248,7 +267,7 @@ function createMockAuthServer(options = {}) {
|
|
|
248
267
|
synap_grant: grant.filter((g) => scope.includes(g.permission)).map((g) => g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId) }),
|
|
249
268
|
synap_user: user,
|
|
250
269
|
...app ? { synap_app: app } : {},
|
|
251
|
-
...
|
|
270
|
+
...actsAs ? { synap_acts_as: actsAs } : {}
|
|
252
271
|
};
|
|
253
272
|
if (pair) {
|
|
254
273
|
body.refresh_token = pair.refreshToken;
|
|
@@ -274,6 +293,21 @@ function createMockAuthServer(options = {}) {
|
|
|
274
293
|
if (form.resource !== void 0 && row.resource && form.resource !== row.resource) {
|
|
275
294
|
return oauthError("invalid_target", "resource does not match the authorization request");
|
|
276
295
|
}
|
|
296
|
+
if (actsAs === "person" && row.stepUp !== Boolean(form.synap_device_refresh_token)) {
|
|
297
|
+
return oauthError(
|
|
298
|
+
"invalid_grant",
|
|
299
|
+
row.stepUp ? "This code re-confirms a device: send its refresh token as synap_device_refresh_token" : "This code signs a new device in; a re-confirmation asks with prompt=login"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (row.stepUp) {
|
|
303
|
+
const device = refreshTokens.get(form.synap_device_refresh_token);
|
|
304
|
+
if (!device || device.clientId !== row.clientId || device.used || device.revoked) {
|
|
305
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
306
|
+
}
|
|
307
|
+
device.used = true;
|
|
308
|
+
deviceFresh = true;
|
|
309
|
+
return json(200, tokenBody(row.scope, row.clientId, device.family));
|
|
310
|
+
}
|
|
277
311
|
for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
|
|
278
312
|
return json(200, tokenBody(row.scope, row.clientId, randomToken()));
|
|
279
313
|
}
|
|
@@ -350,6 +384,8 @@ function createMockAuthServer(options = {}) {
|
|
|
350
384
|
const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
|
|
351
385
|
const other = entities.find((e) => e.id === otherId);
|
|
352
386
|
if (!other || !inLens(other) || !readableEntity(other, spaces)) continue;
|
|
387
|
+
const self = entities.find((e) => e.id === id);
|
|
388
|
+
if (self && self.workspaceId == null && other.workspaceId == null) continue;
|
|
353
389
|
connections.push({
|
|
354
390
|
entityId: otherId,
|
|
355
391
|
// relations.ts: the raw row plus its role slugs under the read's facet lens (a 1.5.0 seed's own facetSlugs otherwise).
|
|
@@ -478,6 +514,12 @@ function createMockAuthServer(options = {}) {
|
|
|
478
514
|
return json(403, { error: "Forbidden" });
|
|
479
515
|
case "door_unsupported":
|
|
480
516
|
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
517
|
+
case "step_up_required":
|
|
518
|
+
return json(403, { error: STEP_UP_MESSAGE });
|
|
519
|
+
case "email_unverified":
|
|
520
|
+
return json(409, { error: "Add and verify an email on your Synap account to connect a pod.", code: "CP_ACCOUNT_EMAIL_REQUIRED", recovery: { action: "update_control_plane_profile" } });
|
|
521
|
+
case "origin_insecure":
|
|
522
|
+
return json(409, { error: "This application must be served over HTTPS to connect to a Pod.", code: "FEDERATED_APPLICATION_ORIGIN_INSECURE" });
|
|
481
523
|
case "server":
|
|
482
524
|
return json(500, { error: "Internal error" });
|
|
483
525
|
case "unavailable":
|
|
@@ -528,9 +570,10 @@ function createMockAuthServer(options = {}) {
|
|
|
528
570
|
}
|
|
529
571
|
const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
|
|
530
572
|
if (approve) {
|
|
531
|
-
if (
|
|
573
|
+
if (actsAs !== "person") {
|
|
532
574
|
return json(403, { error: "An agent credential cannot approve proposals \u2014 approve is human review. Approve from a human session." });
|
|
533
575
|
}
|
|
576
|
+
if (!deviceFresh) return json(403, { error: STEP_UP_MESSAGE });
|
|
534
577
|
const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
|
|
535
578
|
if (!p) return json(404, { error: "Proposal not found" });
|
|
536
579
|
if (!grant.some((g) => g.permission === "proposal.approve" && row.scope.includes(g.permission) && g.workspaceId === p.workspaceId)) {
|
|
@@ -541,16 +584,26 @@ function createMockAuthServer(options = {}) {
|
|
|
541
584
|
approvals.push(p.id);
|
|
542
585
|
return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
|
|
543
586
|
}
|
|
587
|
+
const attach = method === "POST" ? /^\/entities\/([^/]+)\/facets$/.exec(path) : null;
|
|
588
|
+
const facetPatch = method === "PATCH" ? /^\/facets\/([^/]+)$/.exec(path) : null;
|
|
589
|
+
if (attach) return json(403, { error: "grant does not allow" });
|
|
590
|
+
if (facetPatch) {
|
|
591
|
+
const fid = decodeURIComponent(facetPatch[1]);
|
|
592
|
+
const parent = entities.find((e) => rolesOf(e).some((f) => f.facetId === fid));
|
|
593
|
+
if (parent && parent.workspaceId == null) return json(403, { error: "grant does not allow" });
|
|
594
|
+
}
|
|
544
595
|
const patched = method === "PATCH" ? /^\/entities\/([^/]+)$/.exec(path) : null;
|
|
545
596
|
if (patched) {
|
|
546
597
|
const target = entities.find((e) => e.id === decodeURIComponent(patched[1]));
|
|
547
|
-
if (target && target.workspaceId == null)
|
|
598
|
+
if (target && target.workspaceId == null) {
|
|
599
|
+
return typeof target.userId === "string" && target.userId !== user.id ? new Response(null, { status: 404 }) : json(403, { error: "grant does not allow" });
|
|
600
|
+
}
|
|
548
601
|
}
|
|
549
602
|
if (method !== "GET") {
|
|
550
603
|
writes.push({ method, path, body });
|
|
551
604
|
const fields = body && typeof body === "object" ? body : {};
|
|
552
605
|
const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
|
|
553
|
-
if (writeMode === "proposed") {
|
|
606
|
+
if (writeMode() === "proposed") {
|
|
554
607
|
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
555
608
|
proposals.push({
|
|
556
609
|
id: proposalId,
|
|
@@ -667,9 +720,15 @@ function createMockAuthServer(options = {}) {
|
|
|
667
720
|
if (scope.length === 0) return finish({ error: "invalid_scope" });
|
|
668
721
|
if (decision.deny) return finish({ error: "access_denied" });
|
|
669
722
|
const code = randomToken();
|
|
670
|
-
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope });
|
|
723
|
+
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope, stepUp: actsAs === "person" && q.get("prompt") === "login" });
|
|
671
724
|
return finish({ code });
|
|
672
725
|
},
|
|
726
|
+
setActsAs(v) {
|
|
727
|
+
actsAs = v ?? void 0;
|
|
728
|
+
},
|
|
729
|
+
expireStepUp() {
|
|
730
|
+
deviceFresh = false;
|
|
731
|
+
},
|
|
673
732
|
consumeRefreshToken(t) {
|
|
674
733
|
const row = refreshTokens.get(t);
|
|
675
734
|
if (row) row.used = true;
|