@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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading answers from hosts the SDK does not control: a pod a visitor typed,
|
|
3
|
+
* its token endpoint, its Hub API. Bounded in time (`timeoutSignal`) and in
|
|
4
|
+
* bytes (`readCapped`): the bytes are counted on the stream, so an answer past
|
|
5
|
+
* the cap is cancelled there, never buffered whole first.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** An abort signal after `ms`, where the runtime has `AbortSignal.timeout` (workers and edge runtimes may not). */
|
|
9
|
+
export function timeoutSignal(ms: number): AbortSignal | undefined {
|
|
10
|
+
return typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type CappedRead = { ok: true; text: string } | { ok: false; reason: "too_large" | "unreadable" };
|
|
14
|
+
|
|
15
|
+
/** The body as UTF-8 text, at most `maxBytes` bytes. */
|
|
16
|
+
export async function readCapped(res: Response, maxBytes: number): Promise<CappedRead> {
|
|
17
|
+
const declared = Number(res.headers.get("content-length"));
|
|
18
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
19
|
+
await res.body?.cancel().catch(() => {});
|
|
20
|
+
return { ok: false, reason: "too_large" };
|
|
21
|
+
}
|
|
22
|
+
if (!res.body) return { ok: true, text: "" };
|
|
23
|
+
const reader = res.body.getReader();
|
|
24
|
+
const decoder = new TextDecoder();
|
|
25
|
+
let total = 0;
|
|
26
|
+
let text = "";
|
|
27
|
+
try {
|
|
28
|
+
for (;;) {
|
|
29
|
+
const { done, value } = await reader.read();
|
|
30
|
+
if (done) break;
|
|
31
|
+
total += value.byteLength;
|
|
32
|
+
if (total > maxBytes) {
|
|
33
|
+
await reader.cancel().catch(() => {});
|
|
34
|
+
return { ok: false, reason: "too_large" };
|
|
35
|
+
}
|
|
36
|
+
text += decoder.decode(value, { stream: true });
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
return { ok: false, reason: "unreadable" };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, text: text + decoder.decode() };
|
|
42
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire shapes of the Hub REST doors `createPodClient`'s typed methods read.
|
|
3
|
+
*
|
|
4
|
+
* DRIFT RISK: hand-written from synap-backend main (2026-10-22), each from the
|
|
5
|
+
* route itself, never from `@synap-core/hub-rest-client` alone:
|
|
6
|
+
* - entities: `routers/hub-protocol/rest/entities.ts` (`GET /entities`,
|
|
7
|
+
* `GET /entities/:id` → `entityToWire`, `_codecs/entity.ts`
|
|
8
|
+
* `WireEntitySchema`; `GET /entities/:id/connections` → `relations.ts`
|
|
9
|
+
* `getConnections`, `services/object-graph/entity-connections.ts`);
|
|
10
|
+
* - documents: `rest/documents.ts` `GET /documents/:id` →
|
|
11
|
+
* `services/document-patch/read-document.ts` `projectAgentDocument`;
|
|
12
|
+
* - focus sessions: `rest/focus-sessions.ts` `GET /focus-sessions` (raw rows +
|
|
13
|
+
* `triage` + `kind`), slots `services/focus-sessions/update-session.ts`
|
|
14
|
+
* `expectedOutputWireSchema`;
|
|
15
|
+
* - files: `rest/entities.ts` `GET /files/:id/url` → `file-upload.ts`
|
|
16
|
+
* `signFileEntityUrl`;
|
|
17
|
+
* - approve: `rest/proposals.ts` `POST /proposals/:id/approve`.
|
|
18
|
+
* Fields the SDK does not read stay on the objects (index signatures): the pod
|
|
19
|
+
* sends more than is typed here. The mock pod (`@synap-core/sdk/testing`)
|
|
20
|
+
* answers these shapes; `src/__tests__/connect.typed.test.ts` drives both.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One entity, as `GET /entities` and `GET /entities/:id` answer it (`WireEntitySchema`). */
|
|
24
|
+
export interface PodEntity {
|
|
25
|
+
id: string;
|
|
26
|
+
profileSlug: string;
|
|
27
|
+
/** Deprecated alias of `profileSlug`, still sent. */
|
|
28
|
+
type?: string;
|
|
29
|
+
title?: string | null;
|
|
30
|
+
preview?: string | null;
|
|
31
|
+
description?: string | null;
|
|
32
|
+
properties: Record<string, unknown>;
|
|
33
|
+
systemData?: Record<string, unknown>;
|
|
34
|
+
userId: string;
|
|
35
|
+
workspaceId?: string | null;
|
|
36
|
+
documentId?: string | null;
|
|
37
|
+
version?: number;
|
|
38
|
+
/** ISO 8601. */
|
|
39
|
+
createdAt?: string;
|
|
40
|
+
updatedAt?: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ListEntitiesOptions {
|
|
45
|
+
/** One granted space. Omitted: every space the connection can read. */
|
|
46
|
+
workspaceId?: string;
|
|
47
|
+
/** One kind, e.g. `post`. */
|
|
48
|
+
profileSlug?: string;
|
|
49
|
+
/** 1-1000 (the pod's bounds); the pod's default is 20. */
|
|
50
|
+
limit?: number;
|
|
51
|
+
offset?: number;
|
|
52
|
+
/** e.g. `updatedAt:desc`. */
|
|
53
|
+
sort?: string;
|
|
54
|
+
/** With `workspaceId`: also the person's pod-wide rows (the pod's default: true). */
|
|
55
|
+
includePodWide?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type PodConnectionSource = "graph" | "property" | "thread" | "context_channel" | "focus_session";
|
|
59
|
+
|
|
60
|
+
/** One edge of `GET /entities/:id/connections` (`EntityConnection`). */
|
|
61
|
+
export interface PodEntityConnection {
|
|
62
|
+
/** The other end: an entity, or the channel / session for those sources. */
|
|
63
|
+
entityId: string;
|
|
64
|
+
/** The other entity's row (title, `type` = its kind, workspaceId, facetSlugs, …); null for channels and sessions. */
|
|
65
|
+
entity: {
|
|
66
|
+
title?: string | null;
|
|
67
|
+
type?: string | null;
|
|
68
|
+
workspaceId?: string | null;
|
|
69
|
+
facetSlugs?: string[] | null;
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
} | null;
|
|
72
|
+
label: string;
|
|
73
|
+
direction: "outgoing" | "incoming" | "structural";
|
|
74
|
+
source: PodConnectionSource;
|
|
75
|
+
relationId?: string;
|
|
76
|
+
relationType?: string;
|
|
77
|
+
storedAs?: "link";
|
|
78
|
+
propertySlug?: string;
|
|
79
|
+
propertyLabel?: string;
|
|
80
|
+
channelId?: string;
|
|
81
|
+
channelRelationshipType?: string;
|
|
82
|
+
channelTitle?: string | null;
|
|
83
|
+
channelWorkspaceId?: string | null;
|
|
84
|
+
focusSessionId?: string;
|
|
85
|
+
focusSessionGoal?: string;
|
|
86
|
+
focusSessionStatus?: string;
|
|
87
|
+
focusSessionWorkspaceId?: string | null;
|
|
88
|
+
/** ISO 8601, or null (a property link has no date). */
|
|
89
|
+
createdAt?: string | null;
|
|
90
|
+
[key: string]: unknown;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface PodEntityConnections {
|
|
94
|
+
connections: PodEntityConnection[];
|
|
95
|
+
counts: {
|
|
96
|
+
total: number;
|
|
97
|
+
graph: number;
|
|
98
|
+
structural: number;
|
|
99
|
+
threads: number;
|
|
100
|
+
contextChannels: number;
|
|
101
|
+
focusSessions: number;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface GetEntityConnectionsOptions {
|
|
106
|
+
/** A space lens; omitted = everything the connection can read. */
|
|
107
|
+
workspaceId?: string;
|
|
108
|
+
/** Per source, 1-200 (the pod's default: 50). */
|
|
109
|
+
limit?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A document's top-level section (`listDocumentSections`). */
|
|
113
|
+
export interface PodDocumentSection {
|
|
114
|
+
id: string;
|
|
115
|
+
/** Who owns it (a person's section is not an agent's to change). */
|
|
116
|
+
owner: string;
|
|
117
|
+
title: string | null;
|
|
118
|
+
author: string | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `GET /documents/:id` → `document` (`projectAgentDocument`). */
|
|
122
|
+
export interface PodDocument {
|
|
123
|
+
id: string;
|
|
124
|
+
title: string;
|
|
125
|
+
/** `markdown`, `text`, `html`, … */
|
|
126
|
+
type: string;
|
|
127
|
+
language: string | null;
|
|
128
|
+
workspaceId: string | null;
|
|
129
|
+
format: "raw" | "readable";
|
|
130
|
+
/** Markdown; `readable` replaces embeds by their fallback text. */
|
|
131
|
+
content: string;
|
|
132
|
+
/** Pass back as `baseRevision` on a guarded edit. */
|
|
133
|
+
revision: number;
|
|
134
|
+
version: number;
|
|
135
|
+
sections: PodDocumentSection[];
|
|
136
|
+
/** What will not render; null when the check could not run (`diagnosticsError`). */
|
|
137
|
+
diagnostics: unknown[] | null;
|
|
138
|
+
diagnosticsError?: string;
|
|
139
|
+
updatedAt: string;
|
|
140
|
+
createdAt: string;
|
|
141
|
+
[key: string]: unknown;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface GetDocumentOptions {
|
|
145
|
+
/** `raw` (the stored markdown, the pod's default) or `readable`. */
|
|
146
|
+
format?: "raw" | "readable";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `focus_sessions.status` (`@synap-core/types/focus-sessions` SESSION_STATUSES). */
|
|
150
|
+
export type PodSessionStatus = "active" | "paused" | "forming" | "scheduled" | "closed" | "failed" | "cancelled" | "stale";
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One declared deliverable of a session (`expectedOutputWireSchema`). A slot
|
|
154
|
+
* with `owner: "human"` is waiting on the person: `blockedReason` and `why` say
|
|
155
|
+
* for what, `ask` how they can answer (confirm, choose, form, act, provide).
|
|
156
|
+
*/
|
|
157
|
+
export interface PodSessionSlot {
|
|
158
|
+
kind: string;
|
|
159
|
+
label: string;
|
|
160
|
+
key?: string;
|
|
161
|
+
optional?: boolean;
|
|
162
|
+
status?: "pending" | "done";
|
|
163
|
+
/** Absent = `agent`. */
|
|
164
|
+
owner?: "human" | "agent";
|
|
165
|
+
blockedReason?: string;
|
|
166
|
+
why?: string;
|
|
167
|
+
/** ISO 8601, stamped by the pod with `owner: "human"`. */
|
|
168
|
+
owedSince?: string;
|
|
169
|
+
/** How the person can answer (`@synap-core/types/ask`), or null. */
|
|
170
|
+
ask?: unknown;
|
|
171
|
+
answer?: {
|
|
172
|
+
text: string;
|
|
173
|
+
messageId: string | null;
|
|
174
|
+
answeredBy: string;
|
|
175
|
+
answeredAt: string;
|
|
176
|
+
question?: string;
|
|
177
|
+
value?: unknown;
|
|
178
|
+
[key: string]: unknown;
|
|
179
|
+
};
|
|
180
|
+
retiredAt?: string;
|
|
181
|
+
[key: string]: unknown;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One row of `GET /focus-sessions`. `expectedOutputs` is always an array here (the SDK drops anything else). */
|
|
185
|
+
export interface PodFocusSession {
|
|
186
|
+
id: string;
|
|
187
|
+
workspaceId: string | null;
|
|
188
|
+
projectId: string | null;
|
|
189
|
+
userId: string;
|
|
190
|
+
title: string | null;
|
|
191
|
+
goal: string;
|
|
192
|
+
status: PodSessionStatus | (string & {});
|
|
193
|
+
templateId: string | null;
|
|
194
|
+
expectedOutputs: PodSessionSlot[];
|
|
195
|
+
channelId: string | null;
|
|
196
|
+
progress: number | null;
|
|
197
|
+
currentStage: string | null;
|
|
198
|
+
agentIds: string[];
|
|
199
|
+
closedAt: string | null;
|
|
200
|
+
metadata: unknown;
|
|
201
|
+
startedAt: string;
|
|
202
|
+
createdAt: string;
|
|
203
|
+
updatedAt: string;
|
|
204
|
+
/** Projected by the list door: `work`, `run` (a playbook or automation ran it) or `receipt`. */
|
|
205
|
+
kind?: "work" | "run" | "receipt";
|
|
206
|
+
/** Projected by the list door: an agent-started session the person has not accepted yet. */
|
|
207
|
+
triage?: { pending: boolean; acceptedAt: string | null; acceptedBy: string | null };
|
|
208
|
+
[key: string]: unknown;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ListFocusSessionsOptions {
|
|
212
|
+
/** A space; `workspaceId` or `projectId` is required (the pod's rule). */
|
|
213
|
+
workspaceId?: string;
|
|
214
|
+
projectId?: string;
|
|
215
|
+
/** Default `all` (the pod's default on this door). */
|
|
216
|
+
status?: PodSessionStatus | "all";
|
|
217
|
+
/** 1-50 (the pod's bounds); the pod's default is 20. */
|
|
218
|
+
limit?: number;
|
|
219
|
+
/** Triage lens: `all` (default here), `default` (hides unaccepted agent sessions), `triage`. */
|
|
220
|
+
lens?: "all" | "default" | "triage";
|
|
221
|
+
/** Population lens: `all` (default here), `work`, `run`, `receipt`. */
|
|
222
|
+
kind?: "all" | "work" | "run" | "receipt";
|
|
223
|
+
/** Sessions about one thing. */
|
|
224
|
+
subjectEntityId?: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `GET /files/:id/url`: a signed GET for a file entity's blob. */
|
|
228
|
+
export interface PodFileUrl {
|
|
229
|
+
/** Served as an `attachment`, with the stored type. */
|
|
230
|
+
url: string;
|
|
231
|
+
/** ISO 8601; the signature's end (about an hour). */
|
|
232
|
+
expiresAt: string;
|
|
233
|
+
/** Decide drawability on this, never on the URL; null when unknown. */
|
|
234
|
+
mimeType: string | null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `POST /proposals/:id/approve`. */
|
|
238
|
+
export interface PodApproval {
|
|
239
|
+
proposalId: string;
|
|
240
|
+
/** The row the approval produced or linked, when it names one. */
|
|
241
|
+
primaryId?: string;
|
|
242
|
+
linked?: number;
|
|
243
|
+
/** The proposal after the approval (status, execution result), when the pod could read it back. */
|
|
244
|
+
proposal: Record<string, unknown> | null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface ApproveProposalOptions {
|
|
248
|
+
/**
|
|
249
|
+
* The proposal's space, when known: refused locally unless the connection
|
|
250
|
+
* holds `proposal.approve` there. Omitted: needs it in some space, and the
|
|
251
|
+
* pod checks the proposal's own space.
|
|
252
|
+
*/
|
|
253
|
+
workspaceId?: string;
|
|
254
|
+
}
|
package/src/connect/index.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@synap-core/sdk/connect` — pod-app sign-in, SERVER-ONLY
|
|
3
|
-
* doc 18 § SDK): an app connects to a person's own pod as an OAuth 2.1 public
|
|
2
|
+
* `@synap-core/sdk/connect` — pod-app sign-in, SERVER-ONLY: an app connects to a person's own pod as an OAuth 2.1 public
|
|
4
3
|
* client (CIMD client_id, PKCE S256, `iss`-checked answers), keeps the pod
|
|
5
4
|
* token on its server in a sealed HttpOnly cookie, refreshes it in `proxy.ts`,
|
|
6
5
|
* and reads/writes the pod's Hub API with typed results.
|
|
7
6
|
*
|
|
8
|
-
* Framework-free and Web-standard (Request/Response, WebCrypto): Node
|
|
7
|
+
* Framework-free and Web-standard (Request/Response, WebCrypto): Node 20+,
|
|
9
8
|
* edge runtimes, workers. Throws when loaded in a browser (./guard.ts).
|
|
10
9
|
*
|
|
11
10
|
* Invariant (pinned by src/__tests__/connect.guard.test.ts): every file of
|
|
@@ -15,17 +14,70 @@ import { assertServerRuntime } from "./guard";
|
|
|
15
14
|
|
|
16
15
|
assertServerRuntime();
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
export
|
|
20
|
-
export {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
// What an app on createPodAppAuth uses.
|
|
18
|
+
export { createPodAppAuth, buildClientMetadata, isPrefetchOrAsset, DEFAULT_PATHS, REFRESH_WINDOW_MS } from "./auth";
|
|
19
|
+
export type { PodAppAuth, PodAppAuthConfig, PodAppClientOptions, PodAppPaths, RefreshOptions, RefreshOutcome, RefreshStatus } from "./auth";
|
|
20
|
+
export { acceptLaunch, safeNextPath } from "./discovery";
|
|
21
|
+
export type { LaunchHint } from "./discovery";
|
|
22
|
+
export { cloudConnectAvailable } from "./cloud";
|
|
23
|
+
export type { CloudConnectConfig } from "./cloud";
|
|
24
|
+
export { createPodClient, can, spaces, reviewUrlFor, connectionActsAs, humanSlots } from "./client";
|
|
25
|
+
export type {
|
|
26
|
+
ListPendingProposalsOptions,
|
|
27
|
+
PendingProposal,
|
|
28
|
+
PendingProposals,
|
|
29
|
+
PodClient,
|
|
30
|
+
PodClientOptions,
|
|
31
|
+
PodError,
|
|
32
|
+
PodErrorCode,
|
|
33
|
+
PodRefusalReason,
|
|
34
|
+
PodResult,
|
|
35
|
+
PodWriteResult,
|
|
36
|
+
QueryValue,
|
|
37
|
+
} from "./client";
|
|
38
|
+
export type {
|
|
39
|
+
ApproveProposalOptions,
|
|
40
|
+
GetDocumentOptions,
|
|
41
|
+
GetEntityConnectionsOptions,
|
|
42
|
+
ListEntitiesOptions,
|
|
43
|
+
ListFocusSessionsOptions,
|
|
44
|
+
PodApproval,
|
|
45
|
+
PodConnectionSource,
|
|
46
|
+
PodDocument,
|
|
47
|
+
PodDocumentSection,
|
|
48
|
+
PodEntity,
|
|
49
|
+
PodEntityConnection,
|
|
50
|
+
PodEntityConnections,
|
|
51
|
+
PodFileUrl,
|
|
52
|
+
PodFocusSession,
|
|
53
|
+
PodSessionSlot,
|
|
54
|
+
PodSessionStatus,
|
|
55
|
+
} from "./hub-types";
|
|
56
|
+
export { ConnectError, isConnectError, parseConnectErrorCode, CONNECT_ERROR_CODES } from "./errors";
|
|
57
|
+
export type { ConnectErrorCode } from "./errors";
|
|
58
|
+
export { isBrowserRuntime } from "./guard";
|
|
59
|
+
export type { AppRequest, ClientMetadataDocument, PodActsAs, PodConnection, PodGrant, PodIssuer, PodSpace, PodUser } from "./types";
|
|
60
|
+
export type { ConnectionStore } from "./store";
|
|
61
|
+
export { createSealedCookieStore } from "./store";
|
|
62
|
+
export type { SealedCookieStoreOptions } from "./store";
|
|
63
|
+
export type { AppSecret } from "./seal";
|
|
64
|
+
|
|
65
|
+
// Building blocks: what createPodAppAuth is made of, for another framework or
|
|
66
|
+
// store. INTERNAL, not the supported surface: an app on createPodAppAuth needs
|
|
67
|
+
// none of them, and no new export is added here. They stay exported only for
|
|
68
|
+
// 1.x semver; 2.0 moves them to their own entry. (ConnectionStore and
|
|
69
|
+
// createSealedCookieStore above are the supported custom-store seam.)
|
|
70
|
+
export { discoverPod, canonicalPodUrl } from "./discovery";
|
|
71
|
+
export type { DiscoverOptions } from "./discovery";
|
|
72
|
+
export { startCloudConnect, acceptCloudReturn, cloudBaseUrl, CLOUD_TRANSACTION_TTL_MS } from "./cloud";
|
|
73
|
+
export type {
|
|
74
|
+
AcceptCloudReturnOptions,
|
|
75
|
+
CloudReturn,
|
|
76
|
+
CloudTransaction,
|
|
77
|
+
SealedCloudTransaction,
|
|
78
|
+
StartCloudConnectOptions,
|
|
79
|
+
StartedCloudConnect,
|
|
80
|
+
} from "./cloud";
|
|
29
81
|
export {
|
|
30
82
|
startConnect,
|
|
31
83
|
openTransaction,
|
|
@@ -46,14 +98,8 @@ export type {
|
|
|
46
98
|
StartConnectOptions,
|
|
47
99
|
StartedConnect,
|
|
48
100
|
} from "./flow";
|
|
49
|
-
export {
|
|
50
|
-
export type {
|
|
51
|
-
export { createSealedCookieStore, cookieNames, applySetCookies, connectionExpiresAt } from "./store";
|
|
52
|
-
export type { ConnectionStore, CookieNames, SealedCookieStoreOptions } from "./store";
|
|
101
|
+
export { cookieNames, applySetCookies, connectionExpiresAt, markerCookie, readMarker, MARKER_TTL_SEC } from "./store";
|
|
102
|
+
export type { CookieNames } from "./store";
|
|
53
103
|
export { seal, unseal, MIN_SECRET_LENGTH } from "./seal";
|
|
54
|
-
export type {
|
|
104
|
+
export type { Opened, SealPurpose } from "./seal";
|
|
55
105
|
export { pkceChallenge } from "./encoding";
|
|
56
|
-
export { ConnectError, isConnectError, parseConnectErrorCode, CONNECT_ERROR_CODES } from "./errors";
|
|
57
|
-
export type { ConnectErrorCode } from "./errors";
|
|
58
|
-
export { isBrowserRuntime } from "./guard";
|
|
59
|
-
export type { AppRequest, ClientMetadataDocument, PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape rules several files of this entry (and the mock pod) apply, in
|
|
3
|
+
* one place, so a hint or a state accepted in one door is accepted in all.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Hosts that count as this machine (WHATWG keeps IPv6 brackets: "[::1]"). */
|
|
7
|
+
export const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
8
|
+
|
|
9
|
+
/** A `synap_space_hint`: a space id (uuid, short id), never a path or a query. */
|
|
10
|
+
export const SPACE_HINT_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
11
|
+
|
|
12
|
+
/** The `state` the Synap Cloud pod chooser accepts and echoes; ours is 43 base64url characters. */
|
|
13
|
+
export const CLOUD_STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
|
|
14
|
+
|
|
15
|
+
/** An IP literal host (dotted IPv4 or bracketed IPv6), as WHATWG URL normalises them. */
|
|
16
|
+
export function isIpLiteral(hostname: string): boolean {
|
|
17
|
+
return hostname.startsWith("[") || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
|
|
18
|
+
}
|
package/src/connect/seal.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { ConnectError } from "./errors";
|
|
14
14
|
import { base64url, fromBase64url, fromUtf8, randomBytes, utf8 } from "./encoding";
|
|
15
15
|
|
|
16
|
-
export type SealPurpose = "synap-connect/transaction" | "synap-connect/session";
|
|
16
|
+
export type SealPurpose = "synap-connect/transaction" | "synap-connect/session" | "synap-connect/cloud";
|
|
17
17
|
|
|
18
18
|
/** The app secret: one value, or several to rotate (the first seals, all open). */
|
|
19
19
|
export type AppSecret = string | readonly string[];
|
package/src/connect/store.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Bring your own (a database, a KV) by implementing `ConnectionStore`; it
|
|
8
8
|
* answers with the Set-Cookie values to send (e.g. its own session id).
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { canonicalPodUrl } from "./discovery";
|
|
11
|
+
import { base64url, fromBase64url, fromUtf8, parseCookies, serializeCookie, utf8 } from "./encoding";
|
|
11
12
|
import { ConnectError } from "./errors";
|
|
12
13
|
import { seal, unseal, type AppSecret } from "./seal";
|
|
13
14
|
import type { PodConnection, PodGrant } from "./types";
|
|
@@ -24,13 +25,50 @@ export interface ConnectionStore {
|
|
|
24
25
|
export interface CookieNames {
|
|
25
26
|
session: string;
|
|
26
27
|
transaction: string;
|
|
28
|
+
/** A Synap Cloud sign-in in progress (sealed state, 10 minutes). */
|
|
29
|
+
cloud: string;
|
|
30
|
+
/** "This browser was connected to <pod>": the pod's URL only, no secret (`markerCookie`). */
|
|
31
|
+
marker: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** `__Host-` names on https (Secure, Path=/, no Domain: bound to this exact host). */
|
|
30
35
|
export function cookieNames(secure: boolean): CookieNames {
|
|
31
36
|
return secure
|
|
32
|
-
? { session: "__Host-synap_session", transaction: "__Host-synap_tx" }
|
|
33
|
-
: { session: "synap_session", transaction: "synap_tx" };
|
|
37
|
+
? { session: "__Host-synap_session", transaction: "__Host-synap_tx", cloud: "__Host-synap_cloud", marker: "__Host-synap_seen" }
|
|
38
|
+
: { session: "synap_session", transaction: "synap_tx", cloud: "synap_cloud", marker: "synap_seen" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The marker outlives the connection: as long as browsers keep a cookie (400 days). */
|
|
42
|
+
export const MARKER_TTL_SEC = 400 * 24 * 60 * 60;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The marker cookie: which pod this browser was connected to, so a connection
|
|
46
|
+
* that lapsed (idle past the refresh token's life, its sealed cookie gone)
|
|
47
|
+
* reads as "expired, connect again to <pod>" rather than "never connected".
|
|
48
|
+
* It holds the pod's public URL and NOTHING else: no token, no user, nothing
|
|
49
|
+
* sealed. A forged value can only pre-fill the address the person confirms.
|
|
50
|
+
*/
|
|
51
|
+
export function markerCookie(podUrl: string, opts: { secure: boolean; name?: string }): string {
|
|
52
|
+
return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
|
|
53
|
+
maxAgeSec: MARKER_TTL_SEC,
|
|
54
|
+
secure: opts.secure,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The marker's pod URL, or null (absent, or not a canonical pod address). */
|
|
59
|
+
export function readMarker(
|
|
60
|
+
cookieHeader: string | null,
|
|
61
|
+
opts: { secure: boolean; name?: string; allowInsecureLoopback?: boolean },
|
|
62
|
+
): string | null {
|
|
63
|
+
const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
|
|
64
|
+
const bytes = raw ? fromBase64url(raw) : null;
|
|
65
|
+
if (!bytes || bytes.length > 512) return null;
|
|
66
|
+
const podUrl = fromUtf8(bytes);
|
|
67
|
+
try {
|
|
68
|
+
return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
34
72
|
}
|
|
35
73
|
|
|
36
74
|
/** Browsers cap a cookie near 4096 bytes; a big grant spans several. */
|
|
@@ -41,16 +79,20 @@ function chunkName(name: string, i: number): string {
|
|
|
41
79
|
return i === 0 ? name : `${name}.${i}`;
|
|
42
80
|
}
|
|
43
81
|
|
|
82
|
+
/** One space of the sealed grant; the template slug only when the pod named one (cookies written by 1.4 have none). */
|
|
83
|
+
type StoredSpace = [workspaceId: string, workspaceName: string, permissions: string[], templateSlug?: string];
|
|
84
|
+
|
|
44
85
|
/** The sealed form: the grant grouped per space (it repeats the space otherwise). */
|
|
45
86
|
interface StoredConnection extends Omit<PodConnection, "grant"> {
|
|
46
|
-
g:
|
|
87
|
+
g: StoredSpace[];
|
|
47
88
|
}
|
|
48
89
|
|
|
49
90
|
function pack(c: PodConnection): StoredConnection {
|
|
50
|
-
const bySpace = new Map<string,
|
|
91
|
+
const bySpace = new Map<string, StoredSpace>();
|
|
51
92
|
for (const g of c.grant) {
|
|
52
|
-
const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
|
|
93
|
+
const row: StoredSpace = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
|
|
53
94
|
row[2].push(g.permission);
|
|
95
|
+
if (g.templateSlug && row[3] === undefined) row[3] = g.templateSlug;
|
|
54
96
|
bySpace.set(g.workspaceId, row);
|
|
55
97
|
}
|
|
56
98
|
const { grant: _grant, ...rest } = c;
|
|
@@ -60,8 +102,8 @@ function pack(c: PodConnection): StoredConnection {
|
|
|
60
102
|
function unpack(s: StoredConnection): PodConnection | null {
|
|
61
103
|
if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
|
|
62
104
|
const { g, ...rest } = s;
|
|
63
|
-
const grant: PodGrant[] = g.flatMap(([workspaceId, workspaceName, permissions]) =>
|
|
64
|
-
permissions.map((permission) => ({ permission, workspaceId, workspaceName })),
|
|
105
|
+
const grant: PodGrant[] = g.flatMap(([workspaceId, workspaceName, permissions, templateSlug]) =>
|
|
106
|
+
permissions.map((permission) => ({ permission, workspaceId, workspaceName, ...(typeof templateSlug === "string" ? { templateSlug } : {}) })),
|
|
65
107
|
);
|
|
66
108
|
return { ...rest, grant };
|
|
67
109
|
}
|
package/src/connect/types.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* DRIFT RISK: hand-written from the pod's OAuth AS (synap-backend
|
|
5
5
|
* `routers/oauth/routes.ts` /token for a CIMD client, `protocol.ts`
|
|
6
|
-
* `buildAuthorizationServerMetadata
|
|
7
|
-
* `src/__tests__/connect.contract.test.ts` copies the token
|
|
8
|
-
* backend changes, update both.
|
|
6
|
+
* `buildAuthorizationServerMetadata`). The contract test
|
|
7
|
+
* `src/__tests__/connect.contract.test.ts` holds copies of the pod's token and
|
|
8
|
+
* metadata answers; when the backend changes them, update both.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
/** One permission the app asks for, with the sentence the consent screen shows. */
|
|
@@ -21,8 +21,25 @@ export interface PodGrant {
|
|
|
21
21
|
permission: string;
|
|
22
22
|
workspaceId: string;
|
|
23
23
|
workspaceName: string;
|
|
24
|
+
/**
|
|
25
|
+
* The template the space was made from (`synap_grant[].templateSlug`, e.g.
|
|
26
|
+
* `content-os`), when the pod names one: absent for a space made by hand
|
|
27
|
+
* and from pods that do not send it.
|
|
28
|
+
*/
|
|
29
|
+
templateSlug?: string;
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Who the connection's token acts as (`synap_acts_as` of the token response):
|
|
34
|
+
* - `"person"`: an official Synap app; the token is held by the person who
|
|
35
|
+
* consented, so their clicks write as them, directly, approvals included
|
|
36
|
+
* (where they granted `proposal.approve`);
|
|
37
|
+
* - `"agent"`: every other app; the token is the app's own ask-first agent's,
|
|
38
|
+
* its writes become proposals the person reviews, and it can never approve.
|
|
39
|
+
* A pod that does not say is `"agent"`.
|
|
40
|
+
*/
|
|
41
|
+
export type PodActsAs = "person" | "agent";
|
|
42
|
+
|
|
26
43
|
export interface PodUser {
|
|
27
44
|
id: string;
|
|
28
45
|
name: string;
|
|
@@ -63,9 +80,36 @@ export interface PodConnection {
|
|
|
63
80
|
user: PodUser;
|
|
64
81
|
/** ms since epoch. */
|
|
65
82
|
connectedAt: number;
|
|
83
|
+
/**
|
|
84
|
+
* The pod's id for this app connection (`app_…`, the grant's client id), when
|
|
85
|
+
* the token response names it (`synap_app.id`). Absent from pods that do not.
|
|
86
|
+
*/
|
|
87
|
+
appId?: string;
|
|
88
|
+
/**
|
|
89
|
+
* The pod's own page for this app connection (pod-admin `/apps/<appId>`:
|
|
90
|
+
* what it may touch, narrow it, apply automatically, revoke), when the token
|
|
91
|
+
* response names it (`synap_app.manageUrl`). Absent otherwise: hide the link,
|
|
92
|
+
* never guess one.
|
|
93
|
+
*/
|
|
94
|
+
manageUrl?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Who the token acts as, as the latest token response said
|
|
97
|
+
* (`synap_acts_as`). Absent = `"agent"` (read it with `connectionActsAs`).
|
|
98
|
+
*/
|
|
99
|
+
actsAs?: PodActsAs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One space of the grant, its permissions together (`spaces(connection)`). */
|
|
103
|
+
export interface PodSpace {
|
|
104
|
+
workspaceId: string;
|
|
105
|
+
/** "" when the pod sent no name. */
|
|
106
|
+
workspaceName: string;
|
|
107
|
+
permissions: string[];
|
|
108
|
+
/** The template the space was made from, when the pod names one (`PodGrant.templateSlug`). */
|
|
109
|
+
templateSlug?: string;
|
|
66
110
|
}
|
|
67
111
|
|
|
68
|
-
/** The
|
|
112
|
+
/** The client metadata document (CIMD) an app serves at its client_id URL. */
|
|
69
113
|
export interface ClientMetadataDocument {
|
|
70
114
|
client_id: string;
|
|
71
115
|
client_name: string;
|
package/src/public/client.ts
CHANGED
|
@@ -34,12 +34,24 @@
|
|
|
34
34
|
|
|
35
35
|
// ── Wire types (hand-written; see DRIFT RISK) ────────────────────────────────
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A published body, in the format the pod serves it (backend
|
|
39
|
+
* `PublicBodyFormatSchema`): `markdown` is text to parse with
|
|
40
|
+
* `parseSafeMarkdown`; `html` is a whole HTML document (an estimate, a report)
|
|
41
|
+
* that a page renders ONLY inside `<iframe sandbox="" srcdoc>`, never in its
|
|
42
|
+
* own DOM. `null`: nothing pinned, or a body that is not text (pdf, docx).
|
|
43
|
+
*/
|
|
44
|
+
export interface PublicBody {
|
|
45
|
+
format: "markdown" | "html";
|
|
46
|
+
content: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
export interface PublicShare {
|
|
38
50
|
resourceType: "entity";
|
|
39
51
|
/** Present only when the publisher allowlisted `title`. */
|
|
40
52
|
title?: string;
|
|
41
53
|
properties: Record<string, string | number | boolean>;
|
|
42
|
-
body:
|
|
54
|
+
body: PublicBody | null;
|
|
43
55
|
/** Publication day, YYYY-MM-DD. */
|
|
44
56
|
publishedOn: string;
|
|
45
57
|
}
|
|
@@ -136,7 +148,7 @@ export interface PublicCollectionPage {
|
|
|
136
148
|
|
|
137
149
|
/** One published record, with its pinned body. */
|
|
138
150
|
export interface PublicRecord extends PublicRecordSummary {
|
|
139
|
-
body:
|
|
151
|
+
body: PublicBody | null;
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
/** The collection door's query bounds (backend `parseCollectionQuery`). */
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* Everything else is plain text. Extend here — never by rendering HTML.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import type { PublicBody } from "./client";
|
|
16
|
+
|
|
15
17
|
export type Inline =
|
|
16
18
|
| { type: "text"; text: string }
|
|
17
19
|
| { type: "strong"; text: string }
|
|
@@ -112,3 +114,14 @@ export function parseSafeMarkdown(source: string): Block[] {
|
|
|
112
114
|
flush();
|
|
113
115
|
return blocks;
|
|
114
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A published body as a page renders it: markdown -> safe blocks; html -> the
|
|
120
|
+
* document for a sandboxed frame, never parsed as markdown (its tags would show
|
|
121
|
+
* as text). One rule for the share page and the site record page.
|
|
122
|
+
*/
|
|
123
|
+
export function bodyOf(body: PublicBody | null | undefined): { blocks: Block[]; html: string | null } {
|
|
124
|
+
if (!body) return { blocks: [], html: null };
|
|
125
|
+
if (body.format === "html") return { blocks: [], html: body.content };
|
|
126
|
+
return { blocks: parseSafeMarkdown(body.content), html: null };
|
|
127
|
+
}
|