@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +212 -12
  3. package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
  4. package/dist/chunk-UK2U6SMA.js.map +1 -0
  5. package/dist/{client-CVUT1Mcf.d.cts → client-B50KC2aJ.d.cts} +14 -9
  6. package/dist/{client-CVUT1Mcf.d.ts → client-B50KC2aJ.d.ts} +14 -9
  7. package/dist/connect.cjs +790 -161
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +610 -37
  10. package/dist/connect.d.ts +610 -37
  11. package/dist/connect.js +777 -162
  12. package/dist/connect.js.map +1 -1
  13. package/dist/public.cjs +9 -2
  14. package/dist/public.cjs.map +1 -1
  15. package/dist/public.d.cts +19 -4
  16. package/dist/public.d.ts +19 -4
  17. package/dist/public.js +8 -2
  18. package/dist/public.js.map +1 -1
  19. package/dist/testing.cjs +366 -17
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +190 -7
  22. package/dist/testing.d.ts +190 -7
  23. package/dist/testing.js +358 -17
  24. package/dist/testing.js.map +1 -1
  25. package/dist/{types-DpqPNaaa.d.cts → types-DZYNBCq7.d.cts} +47 -5
  26. package/dist/{types-DpqPNaaa.d.ts → types-DZYNBCq7.d.ts} +47 -5
  27. package/package.json +36 -12
  28. package/src/connect/auth.ts +314 -55
  29. package/src/connect/client.ts +472 -26
  30. package/src/connect/cloud.ts +210 -0
  31. package/src/connect/discovery.ts +79 -44
  32. package/src/connect/encoding.ts +1 -1
  33. package/src/connect/errors.ts +8 -1
  34. package/src/connect/flow.ts +80 -12
  35. package/src/connect/http.ts +42 -0
  36. package/src/connect/hub-types.ts +254 -0
  37. package/src/connect/index.ts +69 -23
  38. package/src/connect/rules.ts +18 -0
  39. package/src/connect/seal.ts +1 -1
  40. package/src/connect/store.ts +50 -8
  41. package/src/connect/types.ts +48 -4
  42. package/src/public/client.ts +14 -2
  43. package/src/public/safe-markdown.ts +13 -0
  44. package/src/public/share-model.ts +6 -2
  45. package/src/public/site-model.ts +5 -3
  46. package/src/testing/auth-server.ts +377 -20
  47. package/src/testing/cloud.ts +83 -0
  48. package/src/testing/fixtures.ts +26 -0
  49. package/src/testing/index.ts +10 -6
  50. package/src/testing/seed.ts +120 -0
  51. 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
+ }
@@ -1,11 +1,10 @@
1
1
  /**
2
- * `@synap-core/sdk/connect` — pod-app sign-in, SERVER-ONLY (spec 17 §10.1,
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 18+,
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
- export { createPodAppAuth, buildClientMetadata, DEFAULT_PATHS, REFRESH_WINDOW_MS } from "./auth";
19
- export type { PodAppAuth, PodAppAuthConfig, PodAppPaths, RefreshOutcome, RefreshStatus } from "./auth";
20
- export {
21
- discoverPod,
22
- canonicalPodUrl,
23
- acceptLaunch,
24
- safeNextPath,
25
- cloudConnectAvailable,
26
- startCloudConnect,
27
- } from "./discovery";
28
- export type { DiscoverOptions, LaunchHint } from "./discovery";
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 { createPodClient, can, reviewUrlFor } from "./client";
50
- export type { PodClient, PodClientOptions, PodError, PodErrorCode, PodResult, PodWriteResult, QueryValue } from "./client";
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 { AppSecret, Opened, SealPurpose } from "./seal";
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
+ }
@@ -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[];
@@ -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 { parseCookies, serializeCookie } from "./encoding";
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: Array<[workspaceId: string, workspaceName: string, permissions: string[]]>;
87
+ g: StoredSpace[];
47
88
  }
48
89
 
49
90
  function pack(c: PodConnection): StoredConnection {
50
- const bySpace = new Map<string, [string, string, 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
  }
@@ -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`, spec 17 §6.6). The contract fixture
7
- * `src/__tests__/connect.contract.test.ts` copies the token response; when the
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 CIMD document an app serves at its client_id URL (spec 17 §6.1). */
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;
@@ -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: { format: "markdown"; content: string } | null;
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: { format: "markdown"; content: string } | null;
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
+ }