@synap-core/sdk 1.4.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 (50) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +136 -10
  3. package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
  4. package/dist/chunk-UK2U6SMA.js.map +1 -0
  5. package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
  6. package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
  7. package/dist/connect.cjs +412 -64
  8. package/dist/connect.cjs.map +1 -1
  9. package/dist/connect.d.cts +400 -32
  10. package/dist/connect.d.ts +400 -32
  11. package/dist/connect.js +407 -65
  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 +234 -12
  20. package/dist/testing.cjs.map +1 -1
  21. package/dist/testing.d.cts +122 -27
  22. package/dist/testing.d.ts +122 -27
  23. package/dist/testing.js +230 -12
  24. package/dist/testing.js.map +1 -1
  25. package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
  26. package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
  27. package/package.json +30 -6
  28. package/src/connect/auth.ts +156 -27
  29. package/src/connect/client.ts +374 -34
  30. package/src/connect/cloud.ts +14 -10
  31. package/src/connect/discovery.ts +66 -32
  32. package/src/connect/encoding.ts +1 -1
  33. package/src/connect/errors.ts +7 -0
  34. package/src/connect/flow.ts +42 -14
  35. package/src/connect/http.ts +42 -0
  36. package/src/connect/hub-types.ts +254 -0
  37. package/src/connect/index.ts +60 -42
  38. package/src/connect/rules.ts +18 -0
  39. package/src/connect/store.ts +9 -5
  40. package/src/connect/types.ts +28 -4
  41. package/src/public/client.ts +14 -2
  42. package/src/public/safe-markdown.ts +13 -0
  43. package/src/public/share-model.ts +6 -2
  44. package/src/public/site-model.ts +5 -3
  45. package/src/testing/auth-server.ts +239 -14
  46. package/src/testing/cloud.ts +3 -4
  47. package/src/testing/fixtures.ts +26 -0
  48. package/src/testing/index.ts +8 -6
  49. package/src/testing/seed.ts +120 -0
  50. package/dist/chunk-J7XHAAFJ.js.map +0 -1
@@ -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,20 +14,64 @@ import { assertServerRuntime } from "./guard";
15
14
 
16
15
  assertServerRuntime();
17
16
 
17
+ // What an app on createPodAppAuth uses.
18
18
  export { createPodAppAuth, buildClientMetadata, isPrefetchOrAsset, DEFAULT_PATHS, REFRESH_WINDOW_MS } from "./auth";
19
- export type { PodAppAuth, PodAppAuthConfig, PodAppPaths, RefreshOutcome, RefreshStatus } from "./auth";
20
- export { discoverPod, canonicalPodUrl, acceptLaunch, safeNextPath } from "./discovery";
21
- export type { DiscoverOptions, LaunchHint } from "./discovery";
22
- export {
23
- cloudConnectAvailable,
24
- startCloudConnect,
25
- acceptCloudReturn,
26
- cloudBaseUrl,
27
- CLOUD_TRANSACTION_TTL_MS,
28
- } from "./cloud";
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";
29
73
  export type {
30
74
  AcceptCloudReturnOptions,
31
- CloudConnectConfig,
32
75
  CloudReturn,
33
76
  CloudTransaction,
34
77
  SealedCloudTransaction,
@@ -55,33 +98,8 @@ export type {
55
98
  StartConnectOptions,
56
99
  StartedConnect,
57
100
  } from "./flow";
58
- export { createPodClient, can, spaces, reviewUrlFor } from "./client";
59
- export type {
60
- ListPendingProposalsOptions,
61
- PendingProposal,
62
- PendingProposals,
63
- PodClient,
64
- PodClientOptions,
65
- PodError,
66
- PodErrorCode,
67
- PodResult,
68
- PodWriteResult,
69
- QueryValue,
70
- } from "./client";
71
- export {
72
- createSealedCookieStore,
73
- cookieNames,
74
- applySetCookies,
75
- connectionExpiresAt,
76
- markerCookie,
77
- readMarker,
78
- MARKER_TTL_SEC,
79
- } from "./store";
80
- 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";
81
103
  export { seal, unseal, MIN_SECRET_LENGTH } from "./seal";
82
- export type { AppSecret, Opened, SealPurpose } from "./seal";
104
+ export type { Opened, SealPurpose } from "./seal";
83
105
  export { pkceChallenge } from "./encoding";
84
- export { ConnectError, isConnectError, parseConnectErrorCode, CONNECT_ERROR_CODES } from "./errors";
85
- export type { ConnectErrorCode } from "./errors";
86
- export { isBrowserRuntime } from "./guard";
87
- export type { AppRequest, ClientMetadataDocument, PodConnection, PodGrant, PodIssuer, PodSpace, 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
+ }
@@ -79,16 +79,20 @@ function chunkName(name: string, i: number): string {
79
79
  return i === 0 ? name : `${name}.${i}`;
80
80
  }
81
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
+
82
85
  /** The sealed form: the grant grouped per space (it repeats the space otherwise). */
83
86
  interface StoredConnection extends Omit<PodConnection, "grant"> {
84
- g: Array<[workspaceId: string, workspaceName: string, permissions: string[]]>;
87
+ g: StoredSpace[];
85
88
  }
86
89
 
87
90
  function pack(c: PodConnection): StoredConnection {
88
- const bySpace = new Map<string, [string, string, string[]]>();
91
+ const bySpace = new Map<string, StoredSpace>();
89
92
  for (const g of c.grant) {
90
- const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
93
+ const row: StoredSpace = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
91
94
  row[2].push(g.permission);
95
+ if (g.templateSlug && row[3] === undefined) row[3] = g.templateSlug;
92
96
  bySpace.set(g.workspaceId, row);
93
97
  }
94
98
  const { grant: _grant, ...rest } = c;
@@ -98,8 +102,8 @@ function pack(c: PodConnection): StoredConnection {
98
102
  function unpack(s: StoredConnection): PodConnection | null {
99
103
  if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
100
104
  const { g, ...rest } = s;
101
- const grant: PodGrant[] = g.flatMap(([workspaceId, workspaceName, permissions]) =>
102
- 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 } : {}) })),
103
107
  );
104
108
  return { ...rest, grant };
105
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;
@@ -75,6 +92,11 @@ export interface PodConnection {
75
92
  * never guess one.
76
93
  */
77
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;
78
100
  }
79
101
 
80
102
  /** One space of the grant, its permissions together (`spaces(connection)`). */
@@ -83,9 +105,11 @@ export interface PodSpace {
83
105
  /** "" when the pod sent no name. */
84
106
  workspaceName: string;
85
107
  permissions: string[];
108
+ /** The template the space was made from, when the pod names one (`PodGrant.templateSlug`). */
109
+ templateSlug?: string;
86
110
  }
87
111
 
88
- /** 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. */
89
113
  export interface ClientMetadataDocument {
90
114
  client_id: string;
91
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
+ }
@@ -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 { parseSafeMarkdown, type Block } from "./safe-markdown";
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
- blocks: share.body ? parseSafeMarkdown(share.body.content) : [],
47
+ ...bodyOf(share.body),
45
48
  publishedOn: share.publishedOn,
46
49
  };
47
50
  }
51
+
@@ -14,7 +14,7 @@ import {
14
14
  type PublicRecordSummary,
15
15
  type PublicValue,
16
16
  } from "./client";
17
- import { parseSafeMarkdown, type Block } from "./safe-markdown";
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
- /** The body as safe blocks (never HTML). */
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
- blocks: record.body ? parseSafeMarkdown(record.body.content) : [],
216
+ ...bodyOf(record.body),
215
217
  publishedOn: record.publishedOn,
216
218
  };
217
219
  }