@synap-core/sdk 1.3.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.
- package/CHANGELOG.md +133 -0
- package/README.md +212 -12
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-CVUT1Mcf.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-CVUT1Mcf.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +790 -161
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +610 -37
- package/dist/connect.d.ts +610 -37
- package/dist/connect.js +777 -162
- package/dist/connect.js.map +1 -1
- package/dist/public.cjs +9 -2
- package/dist/public.cjs.map +1 -1
- package/dist/public.d.cts +19 -4
- package/dist/public.d.ts +19 -4
- package/dist/public.js +8 -2
- package/dist/public.js.map +1 -1
- package/dist/testing.cjs +366 -17
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +190 -7
- package/dist/testing.d.ts +190 -7
- package/dist/testing.js +358 -17
- package/dist/testing.js.map +1 -1
- package/dist/{types-DpqPNaaa.d.cts → types-DZYNBCq7.d.cts} +47 -5
- package/dist/{types-DpqPNaaa.d.ts → types-DZYNBCq7.d.ts} +47 -5
- package/package.json +36 -12
- package/src/connect/auth.ts +314 -55
- package/src/connect/client.ts +472 -26
- package/src/connect/cloud.ts +210 -0
- package/src/connect/discovery.ts +79 -44
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +8 -1
- package/src/connect/flow.ts +80 -12
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +254 -0
- package/src/connect/index.ts +69 -23
- package/src/connect/rules.ts +18 -0
- package/src/connect/seal.ts +1 -1
- package/src/connect/store.ts +50 -8
- package/src/connect/types.ts +48 -4
- package/src/public/client.ts +14 -2
- package/src/public/safe-markdown.ts +13 -0
- package/src/public/share-model.ts +6 -2
- package/src/public/site-model.ts +5 -3
- package/src/testing/auth-server.ts +377 -20
- package/src/testing/cloud.ts +83 -0
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +10 -6
- package/src/testing/seed.ts +120 -0
- package/dist/chunk-J7XHAAFJ.js.map +0 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* is honest (a network blip) — it never says anything about the token.
|
|
9
9
|
*/
|
|
10
10
|
import type { PublicReadError, PublicResult, PublicShare } from "./client";
|
|
11
|
-
import {
|
|
11
|
+
import { bodyOf, type Block } from "./safe-markdown";
|
|
12
12
|
import { formatPublicValue, type FormatOptions } from "./site-model";
|
|
13
13
|
|
|
14
14
|
export const UNAVAILABLE_TITLE = "This link isn't available";
|
|
@@ -20,7 +20,10 @@ export type SharePageModel =
|
|
|
20
20
|
title: string | null;
|
|
21
21
|
/** Property rows exactly as the publisher allowlisted them. */
|
|
22
22
|
properties: Array<{ key: string; value: string }>;
|
|
23
|
+
/** A markdown body as safe blocks (`[]` for an html body or none). */
|
|
23
24
|
blocks: Block[];
|
|
25
|
+
/** An html body: render ONLY as `<iframe sandbox="" srcdoc>`. `null` otherwise. */
|
|
26
|
+
html: string | null;
|
|
24
27
|
publishedOn: string;
|
|
25
28
|
};
|
|
26
29
|
|
|
@@ -41,7 +44,8 @@ export function toSharePageModel(
|
|
|
41
44
|
key,
|
|
42
45
|
value: formatPublicValue(value, opts),
|
|
43
46
|
})),
|
|
44
|
-
|
|
47
|
+
...bodyOf(share.body),
|
|
45
48
|
publishedOn: share.publishedOn,
|
|
46
49
|
};
|
|
47
50
|
}
|
|
51
|
+
|
package/src/public/site-model.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type PublicRecordSummary,
|
|
15
15
|
type PublicValue,
|
|
16
16
|
} from "./client";
|
|
17
|
-
import {
|
|
17
|
+
import { bodyOf, type Block } from "./safe-markdown";
|
|
18
18
|
|
|
19
19
|
export interface FormatOptions {
|
|
20
20
|
/** BCP 47 locale (default `en`). */
|
|
@@ -197,8 +197,10 @@ export interface RecordViewModel {
|
|
|
197
197
|
slug: string | null;
|
|
198
198
|
/** Rows exactly as the publisher allowlisted them, formatted. */
|
|
199
199
|
properties: Array<{ key: string; value: string }>;
|
|
200
|
-
/**
|
|
200
|
+
/** A markdown body as safe blocks (`[]` for an html body or none). */
|
|
201
201
|
blocks: Block[];
|
|
202
|
+
/** An html body: render ONLY as `<iframe sandbox="" srcdoc>`. `null` otherwise. */
|
|
203
|
+
html: string | null;
|
|
202
204
|
publishedOn: string;
|
|
203
205
|
}
|
|
204
206
|
|
|
@@ -211,7 +213,7 @@ export function toRecordViewModel(record: PublicRecord, opts: FormatOptions = {}
|
|
|
211
213
|
key,
|
|
212
214
|
value: formatPublicValue(value, opts),
|
|
213
215
|
})),
|
|
214
|
-
|
|
216
|
+
...bodyOf(record.body),
|
|
215
217
|
publishedOn: record.publishedOn,
|
|
216
218
|
};
|
|
217
219
|
}
|
|
@@ -8,22 +8,47 @@
|
|
|
8
8
|
* Wire behaviour mirrors synap-backend `routers/oauth/routes.ts` for a CIMD
|
|
9
9
|
* client: `iss` on every authorization answer (RFC 9207), PKCE S256 checked at
|
|
10
10
|
* /token, single-use codes bound to client_id + redirect_uri + resource, the
|
|
11
|
-
* token response's `synap_grant` / `synap_user`. Refresh mirrors
|
|
11
|
+
* token response's `synap_grant` / `synap_user`. Refresh mirrors the pod's
|
|
12
12
|
* (`routers/oauth/refresh.ts`): rotating and single-use, 30 days from issue;
|
|
13
13
|
* a token presented AGAIN after its use is a reuse that revokes its whole
|
|
14
14
|
* family AND the live access key; a new code ends the client's older
|
|
15
15
|
* families; /revoke with a refresh token kills its family and access key.
|
|
16
|
-
*
|
|
16
|
+
* The reuse grace: within 30 s of its use, a spent token presented
|
|
17
|
+
* again with its own client_id gets the SAME pair its first use got (the
|
|
18
|
+
* successor refresh token and the live key, `expires_in` = what that key has
|
|
19
|
+
* left), as long as the successor is unused; past it, a reuse.
|
|
20
|
+
* `refresh: false` is a pod without refresh tokens (no refresh_token at all).
|
|
21
|
+
*
|
|
22
|
+
* Hub REST: `GET /users/me`, `GET /entities` (an ARRAY of entities, as the pod
|
|
23
|
+
* answers; the seeds plus what applied writes created), `GET /proposals`
|
|
24
|
+
* (`{ proposals, limit, offset, total, hasMore }`, rows only in spaces where
|
|
25
|
+
* the token holds `proposal.read`, as the pod's grant clause; `proposalIds`),
|
|
26
|
+
* governed writes. 1.5, from `seed` (one JSON shape, ./seed.ts):
|
|
27
|
+
* `GET /entities/:id` (404 with an empty body, as the pod), `GET
|
|
28
|
+
* /entities/:id/connections` (`{ connections, counts }`: seeded relations and
|
|
29
|
+
* the sessions about it), `GET /documents/:id` (`{ document }`), `GET
|
|
30
|
+
* /focus-sessions` (an ARRAY; `workspaceId` or `projectId` required; status,
|
|
31
|
+
* limit 1-50, lens, kind, subjectEntityId), `POST /proposals/:id/approve`.
|
|
32
|
+
*
|
|
33
|
+
* Who the token acts as: `actsAs: "person"` answers
|
|
34
|
+
* `synap_acts_as: "person"`, applies writes directly (unless `writeMode`
|
|
35
|
+
* says otherwise) and lets the token approve where it holds
|
|
36
|
+
* `proposal.approve`; `"agent"` answers `"agent"`; omitted, the field is not
|
|
37
|
+
* sent (a pod that does not name it) and the token is an agent's. An agent's approve is
|
|
38
|
+
* refused 403, as the pod's `rejectAgentReviewer`.
|
|
17
39
|
*
|
|
18
40
|
* mock.authorize(authorizeUrl) → the callback URL (approved)
|
|
19
41
|
* mock.authorize(authorizeUrl, { deny: true }) → error=access_denied
|
|
20
42
|
* mock.authorize(authorizeUrl, { iss: "https://evil.example" }) → a mix-up attempt
|
|
21
|
-
* mock.loseNextRefreshRace() → the next redeem is a concurrent loser (refused, no revoke)
|
|
22
|
-
* mock.
|
|
43
|
+
* mock.loseNextRefreshRace() → the next redeem is a concurrent loser that found no successor (refused, no revoke)
|
|
44
|
+
* mock.redeemElsewhere(t) → another app instance rotated t (presenting it within 30 s gets that pair)
|
|
45
|
+
* mock.consumeRefreshToken(t) → the token was used elsewhere with no successor (presenting it is a reuse)
|
|
23
46
|
* mock.revokeAll() → the person removed the app in their pod
|
|
47
|
+
* mock.revokeAccessKeys() → a narrow or a pod-side "Revoke key": the live keys stop, the connection (its refresh token) lives
|
|
24
48
|
*/
|
|
25
49
|
import { pkceChallenge, randomToken } from "../connect/encoding";
|
|
26
|
-
import type { PodGrant, PodUser } from "../connect/types";
|
|
50
|
+
import type { PodActsAs, PodGrant, PodUser } from "../connect/types";
|
|
51
|
+
import type { MockPodSeed, MockSeedDocument, MockSeedRelation, MockSeedSession } from "./seed";
|
|
27
52
|
|
|
28
53
|
/** A placeholder pod origin; never contacted. */
|
|
29
54
|
export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
@@ -39,7 +64,7 @@ export type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" |
|
|
|
39
64
|
|
|
40
65
|
export interface MockAuthServerOptions {
|
|
41
66
|
issuer?: string;
|
|
42
|
-
/** Issue rotating refresh tokens
|
|
67
|
+
/** Issue rotating refresh tokens. Default true; false = a pod without refresh tokens. */
|
|
43
68
|
refresh?: boolean;
|
|
44
69
|
/** Default 3600 (the pod's 1 h). */
|
|
45
70
|
accessTtlSec?: number;
|
|
@@ -47,12 +72,35 @@ export interface MockAuthServerOptions {
|
|
|
47
72
|
refreshIdleSec?: number;
|
|
48
73
|
/** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
|
|
49
74
|
advertiseRefreshExpiry?: boolean;
|
|
75
|
+
/** The reuse grace, seconds. Default 30 (the pod's); 0 = off (a pod without the grace). */
|
|
76
|
+
refreshGraceSec?: number;
|
|
77
|
+
/**
|
|
78
|
+
* `synap_app` on token responses: the connection's id and its page on the
|
|
79
|
+
* pod console. Default `MOCK_APP`; null = a pod that does not send it.
|
|
80
|
+
*/
|
|
81
|
+
app?: { id: string; manageUrl: string | null } | null;
|
|
82
|
+
/** Entities `GET /entities` answers (with `workspaceId` / `profileSlug` filters). */
|
|
83
|
+
entities?: Array<Record<string, unknown>>;
|
|
50
84
|
grant?: PodGrant[];
|
|
51
85
|
user?: PodUser;
|
|
52
86
|
/** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
|
|
53
87
|
metadata?: Record<string, unknown>;
|
|
54
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Hub writes: "proposed" (ask-first, the default for an agent token) or
|
|
90
|
+
* "applied" (the default for `actsAs: "person"`).
|
|
91
|
+
*/
|
|
55
92
|
writeMode?: "proposed" | "applied";
|
|
93
|
+
/**
|
|
94
|
+
* `synap_acts_as` on token responses. Omitted = not sent (a pod that does
|
|
95
|
+
* not name it; the SDK reads an agent connection).
|
|
96
|
+
*/
|
|
97
|
+
actsAs?: PodActsAs;
|
|
98
|
+
/**
|
|
99
|
+
* The pod's data as one JSON shape (`parseMockPodSeed`). `seed.entities` come
|
|
100
|
+
* before `entities`; `seed.spaces` put a `templateSlug` on the grant rows of
|
|
101
|
+
* those spaces.
|
|
102
|
+
*/
|
|
103
|
+
seed?: MockPodSeed;
|
|
56
104
|
/** Hub paths (after /api/hub) that answer one failure. */
|
|
57
105
|
failures?: Record<string, MockHubFailure>;
|
|
58
106
|
now?: () => number;
|
|
@@ -76,12 +124,43 @@ export interface AuthorizeDecision {
|
|
|
76
124
|
state?: string;
|
|
77
125
|
}
|
|
78
126
|
|
|
127
|
+
/** A proposal a governed write filed (`GET /proposals` lists the pending ones). */
|
|
128
|
+
export interface MockProposal {
|
|
129
|
+
id: string;
|
|
130
|
+
proposalType: string;
|
|
131
|
+
targetType: string;
|
|
132
|
+
targetId: string | null;
|
|
133
|
+
status: "pending" | "approved" | "rejected";
|
|
134
|
+
workspaceId: string | null;
|
|
135
|
+
createdAt: string;
|
|
136
|
+
correlationId: string | null;
|
|
137
|
+
sessionId: string | null;
|
|
138
|
+
agentUserId: string | null;
|
|
139
|
+
summary?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The connection's `synap_app` the mock pod answers by default (backend `appTokenResponse`). */
|
|
143
|
+
export const MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
|
|
144
|
+
|
|
145
|
+
/** The app's agent on the mock pod: who files its proposals. */
|
|
146
|
+
export const MOCK_APP_AGENT_ID = "agent_mock_app";
|
|
147
|
+
|
|
79
148
|
export interface MockAuthServer {
|
|
80
149
|
issuer: string;
|
|
81
150
|
fetch: typeof fetch;
|
|
82
151
|
requests: MockAuthRequest[];
|
|
83
152
|
/** Hub writes seen, in order. */
|
|
84
153
|
writes: Array<{ method: string; path: string; body: unknown }>;
|
|
154
|
+
/** Proposals: the seeded ones, then those filed by proposed writes, in order. */
|
|
155
|
+
proposals: MockProposal[];
|
|
156
|
+
/** Proposals approved through `POST /proposals/:id/approve`, in order. */
|
|
157
|
+
approvals: string[];
|
|
158
|
+
/** The person approved or rejected one: it leaves the pending list. */
|
|
159
|
+
settleProposal(id: string, status: "approved" | "rejected"): void;
|
|
160
|
+
/** Another instance of the app rotated `token` (its own refresh): returns the pair the pod answered it. */
|
|
161
|
+
redeemElsewhere(token: string): Promise<{ access_token: string; refresh_token: string; expires_in: number }>;
|
|
162
|
+
/** Refresh redeems answered within the reuse grace (the same pair again). */
|
|
163
|
+
graceAnswers: number;
|
|
85
164
|
/** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
|
|
86
165
|
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
87
166
|
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
@@ -92,6 +171,12 @@ export interface MockAuthServer {
|
|
|
92
171
|
reuses: number;
|
|
93
172
|
/** The person revoked the app: every token stops working. */
|
|
94
173
|
revokeAll(): void;
|
|
174
|
+
/**
|
|
175
|
+
* The live access keys stop working, the refresh tokens do not: what a
|
|
176
|
+
* narrow (the slot's key is revoked at once) or a pod-side "Revoke key"
|
|
177
|
+
* does. The next refresh mints a new key.
|
|
178
|
+
*/
|
|
179
|
+
revokeAccessKeys(): void;
|
|
95
180
|
/** Live (unrevoked) refresh tokens. */
|
|
96
181
|
liveRefreshTokens(): string[];
|
|
97
182
|
reset(): void;
|
|
@@ -109,6 +194,7 @@ interface AccessRow {
|
|
|
109
194
|
expiresAt: number;
|
|
110
195
|
revoked: boolean;
|
|
111
196
|
family: string | null;
|
|
197
|
+
scope: string[];
|
|
112
198
|
}
|
|
113
199
|
|
|
114
200
|
interface RefreshRow {
|
|
@@ -118,6 +204,10 @@ interface RefreshRow {
|
|
|
118
204
|
used: boolean;
|
|
119
205
|
revoked: boolean;
|
|
120
206
|
scope: string[];
|
|
207
|
+
/** When it was spent, and the pair that use was answered (the grace re-answers it). */
|
|
208
|
+
usedAt?: number;
|
|
209
|
+
successor?: string;
|
|
210
|
+
pairAccess?: string;
|
|
121
211
|
}
|
|
122
212
|
|
|
123
213
|
function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
|
@@ -144,7 +234,34 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
144
234
|
const refreshTokens = new Map<string, RefreshRow>();
|
|
145
235
|
const requests: MockAuthRequest[] = [];
|
|
146
236
|
const writes: Array<{ method: string; path: string; body: unknown }> = [];
|
|
237
|
+
const proposals: MockProposal[] = [];
|
|
238
|
+
const seed = options.seed ?? {};
|
|
239
|
+
const entities: Array<Record<string, unknown>> = [...(seed.entities ?? []), ...(options.entities ?? [])];
|
|
240
|
+
const sessions: MockSeedSession[] = [...(seed.sessions ?? [])];
|
|
241
|
+
const documents: MockSeedDocument[] = [...(seed.documents ?? [])];
|
|
242
|
+
const relations: MockSeedRelation[] = [...(seed.relations ?? [])];
|
|
243
|
+
const templateOf = new Map((seed.spaces ?? []).filter((sp) => sp.templateSlug).map((sp) => [sp.id, sp.templateSlug!]));
|
|
244
|
+
const approvals: string[] = [];
|
|
245
|
+
const seededAt = new Date(now()).toISOString();
|
|
246
|
+
for (const p of seed.proposals ?? []) {
|
|
247
|
+
proposals.push({
|
|
248
|
+
proposalType: "create",
|
|
249
|
+
targetType: "entity",
|
|
250
|
+
targetId: null,
|
|
251
|
+
status: "pending",
|
|
252
|
+
workspaceId: null,
|
|
253
|
+
createdAt: seededAt,
|
|
254
|
+
correlationId: null,
|
|
255
|
+
sessionId: null,
|
|
256
|
+
agentUserId: null,
|
|
257
|
+
...p,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const writeMode = options.writeMode ?? (options.actsAs === "person" ? "applied" : "proposed");
|
|
261
|
+
const graceMs = (options.refreshGraceSec ?? 30) * 1000;
|
|
262
|
+
const app = options.app === undefined ? MOCK_APP : options.app;
|
|
147
263
|
let proposalSeq = 0;
|
|
264
|
+
let graceAnswers = 0;
|
|
148
265
|
|
|
149
266
|
const metadata = (): Record<string, unknown> => {
|
|
150
267
|
const base: Record<string, unknown> = {
|
|
@@ -175,18 +292,29 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
175
292
|
for (const a of access.values()) if (a.family === family) a.revoked = true;
|
|
176
293
|
};
|
|
177
294
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
295
|
+
/** The token response; `pair` re-answers an existing access key + refresh token (the grace). */
|
|
296
|
+
const tokenBody = (scope: string[], clientId: string, family: string, pair?: { accessToken: string; refreshToken: string }) => {
|
|
297
|
+
const accessToken = pair?.accessToken ?? `synap_hub_mock_${randomToken()}`;
|
|
298
|
+
if (!pair) access.set(accessToken, { expiresAt: now() + accessTtl * 1000, revoked: false, family: refresh ? family : null, scope });
|
|
299
|
+
const row = access.get(accessToken)!;
|
|
181
300
|
const body: Record<string, unknown> = {
|
|
182
301
|
access_token: accessToken,
|
|
183
302
|
token_type: "Bearer",
|
|
184
|
-
|
|
303
|
+
// As the pod: what the key has left, never an assumed hour.
|
|
304
|
+
expires_in: Math.max(0, Math.floor((row.expiresAt - now()) / 1000)),
|
|
185
305
|
scope: scope.join(" "),
|
|
186
|
-
|
|
306
|
+
// Each grant row names its space's template when it came from one.
|
|
307
|
+
synap_grant: grant
|
|
308
|
+
.filter((g) => scope.includes(g.permission))
|
|
309
|
+
.map((g) => (g.templateSlug || !templateOf.has(g.workspaceId) ? g : { ...g, templateSlug: templateOf.get(g.workspaceId)! })),
|
|
187
310
|
synap_user: user,
|
|
311
|
+
...(app ? { synap_app: app } : {}),
|
|
312
|
+
...(options.actsAs ? { synap_acts_as: options.actsAs } : {}),
|
|
188
313
|
};
|
|
189
|
-
if (
|
|
314
|
+
if (pair) {
|
|
315
|
+
body.refresh_token = pair.refreshToken;
|
|
316
|
+
if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = Math.floor((refreshTokens.get(pair.refreshToken)!.idleUntil - now()) / 1000);
|
|
317
|
+
} else if (refresh) {
|
|
190
318
|
const refreshToken = randomToken();
|
|
191
319
|
refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1000, used: false, revoked: false, scope });
|
|
192
320
|
body.refresh_token = refreshToken;
|
|
@@ -220,6 +348,14 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
220
348
|
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
221
349
|
}
|
|
222
350
|
if (row.used) {
|
|
351
|
+
// The same pair again, inside the grace, while the successor is unused and its key lives.
|
|
352
|
+
const successor = row.successor ? refreshTokens.get(row.successor) : undefined;
|
|
353
|
+
const key = row.pairAccess ? access.get(row.pairAccess) : undefined;
|
|
354
|
+
const inGrace = graceMs > 0 && row.usedAt !== undefined && now() - row.usedAt < graceMs;
|
|
355
|
+
if (inGrace && successor && !successor.used && !successor.revoked && key && !key.revoked && key.expiresAt > now()) {
|
|
356
|
+
graceAnswers++;
|
|
357
|
+
return json(200, tokenBody(row.scope, row.clientId, row.family, { accessToken: row.pairAccess!, refreshToken: row.successor! }));
|
|
358
|
+
}
|
|
223
359
|
reuses++;
|
|
224
360
|
revokeFamily(row.family);
|
|
225
361
|
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
@@ -230,12 +366,151 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
230
366
|
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
231
367
|
}
|
|
232
368
|
row.used = true;
|
|
233
|
-
|
|
369
|
+
row.usedAt = now();
|
|
370
|
+
// One live key per connection: the rotation retires the family's older keys.
|
|
371
|
+
for (const a of access.values()) if (a.family === row.family) a.revoked = true;
|
|
372
|
+
const body = tokenBody(row.scope, row.clientId, row.family);
|
|
373
|
+
row.successor = body.refresh_token as string;
|
|
374
|
+
row.pairAccess = body.access_token as string;
|
|
375
|
+
return json(200, body);
|
|
234
376
|
}
|
|
235
377
|
return oauthError("unsupported_grant_type", "Unsupported grant_type");
|
|
236
378
|
}
|
|
237
379
|
|
|
238
|
-
|
|
380
|
+
/** A focus-session row as the list door answers it: the table's columns (defaults filled), `triage`, `kind`. */
|
|
381
|
+
const sessionRow = (s: MockSeedSession): Record<string, unknown> => ({
|
|
382
|
+
workspaceId: null,
|
|
383
|
+
projectId: null,
|
|
384
|
+
userId: user.id,
|
|
385
|
+
correlationId: null,
|
|
386
|
+
title: null,
|
|
387
|
+
goal: "",
|
|
388
|
+
status: "active",
|
|
389
|
+
templateId: null,
|
|
390
|
+
expectedOutputs: [],
|
|
391
|
+
channelId: null,
|
|
392
|
+
progress: null,
|
|
393
|
+
currentStage: null,
|
|
394
|
+
agentIds: [],
|
|
395
|
+
closedAt: null,
|
|
396
|
+
verificationReport: null,
|
|
397
|
+
metadata: {},
|
|
398
|
+
startedAt: seededAt,
|
|
399
|
+
createdAt: seededAt,
|
|
400
|
+
updatedAt: seededAt,
|
|
401
|
+
kind: "work",
|
|
402
|
+
triage: { pending: false, acceptedAt: null, acceptedBy: null },
|
|
403
|
+
...s,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/** The seeded read doors (1.5); null when `path` is none of them. */
|
|
407
|
+
function readSeeded(path: string, query: URLSearchParams): Response | null {
|
|
408
|
+
const conn = /^\/entities\/([^/]+)\/connections$/.exec(path);
|
|
409
|
+
if (conn) {
|
|
410
|
+
const id = decodeURIComponent(conn[1]);
|
|
411
|
+
const ws = query.get("workspaceId");
|
|
412
|
+
const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
|
|
413
|
+
const inLens = (e: Record<string, unknown>) => !ws || e.workspaceId === ws || e.workspaceId == null;
|
|
414
|
+
const connections: Array<Record<string, unknown>> = [];
|
|
415
|
+
const graph = relations
|
|
416
|
+
.filter((r) => r.sourceEntityId === id || r.targetEntityId === id)
|
|
417
|
+
.sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")))
|
|
418
|
+
.slice(0, limit);
|
|
419
|
+
for (const r of graph) {
|
|
420
|
+
const outgoing = r.sourceEntityId === id;
|
|
421
|
+
const otherId = outgoing ? r.targetEntityId : r.sourceEntityId;
|
|
422
|
+
const other = entities.find((e) => e.id === otherId);
|
|
423
|
+
// As the pod: an edge whose far end is gone or out of the lens is not a neighbour.
|
|
424
|
+
if (!other || !inLens(other)) continue;
|
|
425
|
+
connections.push({
|
|
426
|
+
entityId: otherId,
|
|
427
|
+
entity: { ...other, type: other.profileSlug ?? other.type ?? null, facetSlugs: other.facetSlugs ?? [] },
|
|
428
|
+
label: r.type,
|
|
429
|
+
direction: outgoing ? "outgoing" : "incoming",
|
|
430
|
+
source: "graph",
|
|
431
|
+
relationId: r.id,
|
|
432
|
+
relationType: r.type,
|
|
433
|
+
createdAt: r.createdAt ?? null,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
for (const s of sessions.filter((x) => x.subjectEntityId === id).slice(0, limit)) {
|
|
437
|
+
const row = sessionRow(s);
|
|
438
|
+
connections.push({
|
|
439
|
+
entityId: s.id,
|
|
440
|
+
entity: null,
|
|
441
|
+
label: (row.title as string | null) || (row.goal as string),
|
|
442
|
+
direction: "incoming",
|
|
443
|
+
source: "focus_session",
|
|
444
|
+
focusSessionId: s.id,
|
|
445
|
+
focusSessionGoal: row.goal,
|
|
446
|
+
focusSessionStatus: row.status,
|
|
447
|
+
focusSessionWorkspaceId: row.workspaceId,
|
|
448
|
+
createdAt: row.createdAt,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
const n = (source: string) => connections.filter((c) => c.source === source).length;
|
|
452
|
+
return json(200, {
|
|
453
|
+
connections,
|
|
454
|
+
counts: { total: connections.length, graph: n("graph"), structural: n("property"), threads: n("thread"), contextChannels: n("context_channel"), focusSessions: n("focus_session") },
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const entity = /^\/entities\/([^/]+)$/.exec(path);
|
|
458
|
+
if (entity) {
|
|
459
|
+
const found = entities.find((e) => e.id === decodeURIComponent(entity[1]));
|
|
460
|
+
// The pod answers a miss with an EMPTY 404 body (rest/entities.ts `c.body(null, 404)`).
|
|
461
|
+
return found ? json(200, found) : new Response(null, { status: 404 });
|
|
462
|
+
}
|
|
463
|
+
const doc = /^\/documents\/([^/]+)$/.exec(path);
|
|
464
|
+
if (doc) {
|
|
465
|
+
const format = query.get("format") ?? "raw";
|
|
466
|
+
if (format !== "raw" && format !== "readable") return json(400, { error: "format must be raw or readable" });
|
|
467
|
+
const d = documents.find((x) => x.id === decodeURIComponent(doc[1]));
|
|
468
|
+
if (!d) return json(404, { error: "Document not found" });
|
|
469
|
+
// projectAgentDocument; the mock has no embeds, so `readable` = `raw`, and no sections are parsed.
|
|
470
|
+
return json(200, {
|
|
471
|
+
document: {
|
|
472
|
+
id: d.id,
|
|
473
|
+
title: d.title,
|
|
474
|
+
type: d.type ?? "markdown",
|
|
475
|
+
language: d.language ?? null,
|
|
476
|
+
workspaceId: d.workspaceId ?? null,
|
|
477
|
+
format,
|
|
478
|
+
content: d.content,
|
|
479
|
+
revision: d.revision ?? 1,
|
|
480
|
+
version: d.version ?? 1,
|
|
481
|
+
sections: [],
|
|
482
|
+
diagnostics: [],
|
|
483
|
+
updatedAt: d.updatedAt ?? seededAt,
|
|
484
|
+
createdAt: d.createdAt ?? seededAt,
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (path === "/focus-sessions") {
|
|
489
|
+
const ws = query.get("workspaceId");
|
|
490
|
+
const project = query.get("projectId");
|
|
491
|
+
if (!ws && !project) return json(400, { error: "workspaceId or projectId is required" });
|
|
492
|
+
const status = query.get("status") ?? "all";
|
|
493
|
+
const lens = query.get("lens") ?? "all";
|
|
494
|
+
const kind = query.get("kind") ?? "all";
|
|
495
|
+
const subject = query.get("subjectEntityId");
|
|
496
|
+
const limit = Math.min(Math.max(Number(query.get("limit") ?? 20) || 20, 1), 50);
|
|
497
|
+
const rows = sessions
|
|
498
|
+
.map(sessionRow)
|
|
499
|
+
.filter((r) => (!ws || r.workspaceId === ws) && (!project || r.projectId === project))
|
|
500
|
+
.filter((r) => status === "all" || r.status === status)
|
|
501
|
+
.filter((r) => {
|
|
502
|
+
const pending = (r.triage as { pending?: boolean } | undefined)?.pending === true;
|
|
503
|
+
return lens === "triage" ? pending : lens === "default" ? !pending : true;
|
|
504
|
+
})
|
|
505
|
+
.filter((r) => kind === "all" || r.kind === kind)
|
|
506
|
+
.filter((r) => !subject || r.subjectEntityId === subject)
|
|
507
|
+
.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
|
|
508
|
+
return json(200, rows.slice(0, limit));
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function hub(method: string, path: string, query: URLSearchParams, headers: Record<string, string>, body: unknown): Response | "reject" {
|
|
239
514
|
const failure = options.failures?.[path];
|
|
240
515
|
if (failure === "unreachable") return "reject";
|
|
241
516
|
const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
|
|
@@ -253,16 +528,78 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
253
528
|
case "unavailable":
|
|
254
529
|
return json(503, { error: "Unavailable" });
|
|
255
530
|
}
|
|
256
|
-
// The grant fence
|
|
531
|
+
// The grant fence: aggregate reads are refused to a space-scoped token.
|
|
257
532
|
if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
|
|
258
533
|
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
259
534
|
}
|
|
260
535
|
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
261
|
-
if (method === "GET" && path === "/entities")
|
|
536
|
+
if (method === "GET" && path === "/entities") {
|
|
537
|
+
// The pod answers an ARRAY (rest/entities.ts), not a page object.
|
|
538
|
+
const ws = query.get("workspaceId");
|
|
539
|
+
const kind = query.get("profileSlug");
|
|
540
|
+
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
541
|
+
const limit = Number(query.get("limit") ?? 50) || 50;
|
|
542
|
+
const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
|
|
543
|
+
return json(200, rows.slice(offset, offset + limit));
|
|
544
|
+
}
|
|
545
|
+
if (method === "GET" && path === "/proposals") {
|
|
546
|
+
// The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
|
|
547
|
+
const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
|
|
548
|
+
const status = query.get("status") ?? "pending";
|
|
549
|
+
const ws = query.get("workspaceId");
|
|
550
|
+
const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
|
|
551
|
+
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
552
|
+
// proposalIds: comma-separated and/or repeated; present but empty matches nothing.
|
|
553
|
+
const idsRaw = query.getAll("proposalIds");
|
|
554
|
+
const ids = idsRaw.length > 0 ? new Set(idsRaw.flatMap((v) => v.split(",").map((x) => x.trim()).filter(Boolean))) : null;
|
|
555
|
+
const rows = proposals
|
|
556
|
+
.filter((p) => p.workspaceId !== null && readable.has(p.workspaceId))
|
|
557
|
+
.filter((p) => (status === "all" || p.status === status) && (!ws || p.workspaceId === ws))
|
|
558
|
+
.filter((p) => !ids || ids.has(p.id))
|
|
559
|
+
.reverse(); // newest first
|
|
560
|
+
const page = rows.slice(offset, offset + limit);
|
|
561
|
+
return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
|
|
562
|
+
}
|
|
563
|
+
if (method === "GET") {
|
|
564
|
+
const read = readSeeded(path, query);
|
|
565
|
+
if (read) return read;
|
|
566
|
+
}
|
|
567
|
+
const approve = method === "POST" ? /^\/proposals\/([^/]+)\/approve$/.exec(path) : null;
|
|
568
|
+
if (approve) {
|
|
569
|
+
// rejectAgentReviewer: an agent credential never approves, before anything is read.
|
|
570
|
+
if (options.actsAs !== "person") {
|
|
571
|
+
return json(403, { error: "An agent credential cannot approve proposals — approve is human review. Approve from a human session." });
|
|
572
|
+
}
|
|
573
|
+
const p = proposals.find((x) => x.id === decodeURIComponent(approve[1]));
|
|
574
|
+
if (!p) return json(404, { error: "Proposal not found" });
|
|
575
|
+
// Only where the person granted proposal.approve, for the proposal's own space.
|
|
576
|
+
if (!grant.some((g) => g.permission === "proposal.approve" && row.scope.includes(g.permission) && g.workspaceId === p.workspaceId)) {
|
|
577
|
+
return json(403, { error: "You may not approve changes here" });
|
|
578
|
+
}
|
|
579
|
+
if (p.status !== "pending") return json(409, { error: `The proposal is ${p.status}, not pending` });
|
|
580
|
+
p.status = "approved";
|
|
581
|
+
approvals.push(p.id);
|
|
582
|
+
return json(200, { success: true, proposalId: p.id, proposal: { ...p } });
|
|
583
|
+
}
|
|
262
584
|
if (method !== "GET") {
|
|
263
585
|
writes.push({ method, path, body });
|
|
264
|
-
|
|
586
|
+
const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
587
|
+
const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
|
|
588
|
+
if (writeMode === "proposed") {
|
|
265
589
|
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
590
|
+
proposals.push({
|
|
591
|
+
id: proposalId,
|
|
592
|
+
proposalType: method === "POST" ? "create" : method === "DELETE" ? "delete" : "update",
|
|
593
|
+
targetType: path.split("/")[1]?.replace(/s$/, "") ?? "entity",
|
|
594
|
+
targetId: null,
|
|
595
|
+
status: "pending",
|
|
596
|
+
workspaceId,
|
|
597
|
+
createdAt: new Date(now()).toISOString(),
|
|
598
|
+
correlationId: null,
|
|
599
|
+
sessionId: null,
|
|
600
|
+
agentUserId: MOCK_APP_AGENT_ID,
|
|
601
|
+
...(typeof fields.title === "string" ? { summary: `Create "${fields.title}"` } : {}),
|
|
602
|
+
});
|
|
266
603
|
return json(200, {
|
|
267
604
|
status: "proposed",
|
|
268
605
|
message: "Sent for review",
|
|
@@ -270,7 +607,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
270
607
|
reviewUrl: `${issuer}/open/${proposalId}`,
|
|
271
608
|
});
|
|
272
609
|
}
|
|
273
|
-
|
|
610
|
+
const id = `ent_mock_${writes.length}`;
|
|
611
|
+
if (method === "POST" && path === "/entities") entities.push({ id, ...fields });
|
|
612
|
+
return json(200, { status: "created", id });
|
|
274
613
|
}
|
|
275
614
|
return json(404, { error: "Not found" });
|
|
276
615
|
}
|
|
@@ -311,7 +650,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
311
650
|
} catch {
|
|
312
651
|
body = rawBody;
|
|
313
652
|
}
|
|
314
|
-
const res = hub(method, hubPath, headers, body);
|
|
653
|
+
const res = hub(method, hubPath, url.searchParams, headers, body);
|
|
315
654
|
if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
|
|
316
655
|
return res;
|
|
317
656
|
}
|
|
@@ -323,6 +662,21 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
323
662
|
fetch: mockFetch as typeof fetch,
|
|
324
663
|
requests,
|
|
325
664
|
writes,
|
|
665
|
+
proposals,
|
|
666
|
+
approvals,
|
|
667
|
+
settleProposal(id, status) {
|
|
668
|
+
const p = proposals.find((x) => x.id === id);
|
|
669
|
+
if (p) p.status = status;
|
|
670
|
+
},
|
|
671
|
+
async redeemElsewhere(t) {
|
|
672
|
+
const row = refreshTokens.get(t);
|
|
673
|
+
const res = await token({ grant_type: "refresh_token", refresh_token: t, client_id: row?.clientId ?? "" });
|
|
674
|
+
if (res.status !== 200) throw new Error(`redeemElsewhere: the pod answered ${res.status}`);
|
|
675
|
+
return (await res.json()) as { access_token: string; refresh_token: string; expires_in: number };
|
|
676
|
+
},
|
|
677
|
+
get graceAnswers() {
|
|
678
|
+
return graceAnswers;
|
|
679
|
+
},
|
|
326
680
|
authorize(authorizeUrl, decision = {}) {
|
|
327
681
|
const url = new URL(authorizeUrl);
|
|
328
682
|
if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
|
|
@@ -369,6 +723,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
369
723
|
for (const row of access.values()) row.revoked = true;
|
|
370
724
|
for (const row of refreshTokens.values()) row.revoked = true;
|
|
371
725
|
},
|
|
726
|
+
revokeAccessKeys() {
|
|
727
|
+
for (const row of access.values()) row.revoked = true;
|
|
728
|
+
},
|
|
372
729
|
liveRefreshTokens() {
|
|
373
730
|
return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
|
|
374
731
|
},
|