@synap-core/sdk 1.4.0 → 1.5.1
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 +215 -0
- package/README.md +152 -10
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +419 -64
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +416 -32
- package/dist/connect.d.ts +416 -32
- package/dist/connect.js +414 -65
- 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 +350 -15
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +171 -27
- package/dist/testing.d.ts +171 -27
- package/dist/testing.js +346 -15
- package/dist/testing.js.map +1 -1
- package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
- package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
- package/package.json +30 -6
- package/src/connect/auth.ts +169 -27
- package/src/connect/client.ts +377 -34
- package/src/connect/cloud.ts +14 -10
- package/src/connect/discovery.ts +66 -32
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +7 -0
- package/src/connect/flow.ts +42 -14
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +271 -0
- package/src/connect/index.ts +61 -42
- package/src/connect/rules.ts +18 -0
- package/src/connect/store.ts +9 -5
- package/src/connect/types.ts +28 -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 +350 -17
- package/src/testing/cloud.ts +3 -4
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +8 -6
- package/src/testing/roles.ts +27 -0
- package/src/testing/seed.ts +196 -0
- package/dist/chunk-J7XHAAFJ.js.map +0 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mock pod's data as ONE JSON-serialisable shape, so an app's mock mode,
|
|
3
|
+
* smoke and screenshots replay a recorded snapshot (one file) instead of
|
|
4
|
+
* fixtures built in app code:
|
|
5
|
+
*
|
|
6
|
+
* import { readFileSync } from "node:fs";
|
|
7
|
+
* const seed = parseMockPodSeed(readFileSync("mock/pod-seed.json", "utf8"));
|
|
8
|
+
* const pod = createMockAuthServer({ seed });
|
|
9
|
+
*
|
|
10
|
+
* Rows are the pod's wire shapes (see `@synap-core/sdk/connect` hub-types):
|
|
11
|
+
* `entities` as `GET /entities` answers them, `sessions` as `GET
|
|
12
|
+
* /focus-sessions` rows (missing columns get the pod's defaults), `documents`
|
|
13
|
+
* as `GET /documents/:id` (`sections` and `diagnostics` answered empty),
|
|
14
|
+
* `relations` as graph edges for `GET /entities/:id/connections`, `proposals`
|
|
15
|
+
* as `GET /proposals?view=basic` rows, `spaces` name each granted space's
|
|
16
|
+
* template (`synap_grant[].templateSlug` on the token response).
|
|
17
|
+
*
|
|
18
|
+
* An entity's roles ride on its row as `facets`, the shape `GET /entities?
|
|
19
|
+
* includeFacets=true` answers them (`{ facetId, slug, properties, status }`),
|
|
20
|
+
* plus the seed-only space each is stamped in, so a seed row reads like a pod
|
|
21
|
+
* row read with includeFacets. The mock derives `facetSlugs` and the reach rule
|
|
22
|
+
* from them and serves neither field unless asked, as the pod.
|
|
23
|
+
*/
|
|
24
|
+
import type { MockProposal } from "./auth-server";
|
|
25
|
+
import { desugarSeedRoles } from "./roles";
|
|
26
|
+
|
|
27
|
+
/** One role an entity wears (backend `FacetRowAnnotation`, plus the space it is stamped in). */
|
|
28
|
+
export interface MockSeedFacet {
|
|
29
|
+
facetId: string;
|
|
30
|
+
/** The role's slug, e.g. `lead`. */
|
|
31
|
+
slug: string;
|
|
32
|
+
/** The facet's overlay properties. Default `{}`. */
|
|
33
|
+
properties?: Record<string, unknown>;
|
|
34
|
+
/** Its lifecycle status. Default null. */
|
|
35
|
+
status?: string | null;
|
|
36
|
+
/**
|
|
37
|
+
* Seed only, never served: the space the role is in, i.e. the space whose
|
|
38
|
+
* members see the facet (stamped there, or a pod-wide facet of a role granted
|
|
39
|
+
* to it). A role in a granted space puts a pod-wide entity in the app's
|
|
40
|
+
* reach; a workspace lens sees the roles of that space only.
|
|
41
|
+
*/
|
|
42
|
+
workspaceId: string;
|
|
43
|
+
/**
|
|
44
|
+
* Seed only, never served: the role is SHARED, i.e. stored pod-wide (null
|
|
45
|
+
* `workspaceId`) and granted to `workspaceId`, which then means "the space it
|
|
46
|
+
* is granted to". Default false = stamped in `workspaceId`. The pod treats the
|
|
47
|
+
* two alike for reach, except `includePodWide=false` on a space lens, which
|
|
48
|
+
* drops a pod-wide row reached only through a shared role.
|
|
49
|
+
*/
|
|
50
|
+
sharedRole?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MockSeedEntity {
|
|
54
|
+
id: string;
|
|
55
|
+
profileSlug?: string;
|
|
56
|
+
workspaceId?: string | null;
|
|
57
|
+
title?: string | null;
|
|
58
|
+
properties?: Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* The roles (facets) it wears, in the wire shape plus the space each is in
|
|
61
|
+
* (`MockSeedFacet`). SEED INPUT ONLY: the pod sends them only to
|
|
62
|
+
* `includeFacets`, and so does the mock. A pod-wide entity (`workspaceId:
|
|
63
|
+
* null`) with a role in a granted space is readable by the app, as a member
|
|
64
|
+
* of that space sees it. Absent = none.
|
|
65
|
+
*/
|
|
66
|
+
facets?: MockSeedFacet[];
|
|
67
|
+
/**
|
|
68
|
+
* @deprecated Use `facets`; removed in 2.0. Shorthand for `facets` entries
|
|
69
|
+
* with no id and no overlay: `parseMockPodSeed` turns each into a facet (a
|
|
70
|
+
* generated `facetId`, empty properties, a null status). A role stated in
|
|
71
|
+
* both is refused.
|
|
72
|
+
*/
|
|
73
|
+
seedRoles?: Array<{ profileSlug: string; workspaceId: string }>;
|
|
74
|
+
[key: string]: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MockSeedSession {
|
|
78
|
+
id: string;
|
|
79
|
+
workspaceId?: string | null;
|
|
80
|
+
projectId?: string | null;
|
|
81
|
+
goal?: string;
|
|
82
|
+
status?: string;
|
|
83
|
+
/** Slots; `owner: "human"` ones are what waits on the person. */
|
|
84
|
+
expectedOutputs?: Array<Record<string, unknown>>;
|
|
85
|
+
/** The entity it is about: listed among that entity's connections. */
|
|
86
|
+
subjectEntityId?: string | null;
|
|
87
|
+
/** ISO 8601; the list is newest first by it. */
|
|
88
|
+
startedAt?: string;
|
|
89
|
+
[key: string]: unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MockSeedDocument {
|
|
93
|
+
id: string;
|
|
94
|
+
title: string;
|
|
95
|
+
content: string;
|
|
96
|
+
type?: string;
|
|
97
|
+
language?: string | null;
|
|
98
|
+
workspaceId?: string | null;
|
|
99
|
+
revision?: number;
|
|
100
|
+
version?: number;
|
|
101
|
+
createdAt?: string;
|
|
102
|
+
updatedAt?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface MockSeedRelation {
|
|
106
|
+
id: string;
|
|
107
|
+
sourceEntityId: string;
|
|
108
|
+
targetEntityId: string;
|
|
109
|
+
type: string;
|
|
110
|
+
createdAt?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface MockSeedSpace {
|
|
114
|
+
id: string;
|
|
115
|
+
name: string;
|
|
116
|
+
/** The template it came from (e.g. `content-os`); absent = made by hand. */
|
|
117
|
+
templateSlug?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface MockPodSeed {
|
|
121
|
+
entities?: MockSeedEntity[];
|
|
122
|
+
sessions?: MockSeedSession[];
|
|
123
|
+
documents?: MockSeedDocument[];
|
|
124
|
+
relations?: MockSeedRelation[];
|
|
125
|
+
/** Missing fields default to a pending `create` of an `entity`, with null ids and the seed's load time. */
|
|
126
|
+
proposals?: Array<Partial<MockProposal> & { id: string }>;
|
|
127
|
+
spaces?: MockSeedSpace[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const KEYS = ["entities", "sessions", "documents", "relations", "proposals", "spaces"] as const;
|
|
131
|
+
|
|
132
|
+
/** Required string fields per collection (the rest is free-form, as the pod's rows). */
|
|
133
|
+
const REQUIRED: Record<(typeof KEYS)[number], readonly string[]> = {
|
|
134
|
+
entities: ["id"],
|
|
135
|
+
sessions: ["id"],
|
|
136
|
+
documents: ["id", "title", "content"],
|
|
137
|
+
relations: ["id", "sourceEntityId", "targetEntityId", "type"],
|
|
138
|
+
proposals: ["id"],
|
|
139
|
+
spaces: ["id", "name"],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read a seed (JSON text or an already-parsed value) and check its shape:
|
|
144
|
+
* every collection optional, each an array of objects carrying its required
|
|
145
|
+
* string fields; unknown top-level keys are refused (a typo would otherwise
|
|
146
|
+
* seed nothing). Throws `TypeError` naming the first bad path.
|
|
147
|
+
*/
|
|
148
|
+
export function parseMockPodSeed(input: string | unknown): MockPodSeed {
|
|
149
|
+
const value: unknown = typeof input === "string" ? JSON.parse(input) : input;
|
|
150
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("seed: expected an object");
|
|
151
|
+
const obj = value as Record<string, unknown>;
|
|
152
|
+
for (const key of Object.keys(obj)) {
|
|
153
|
+
if (!(KEYS as readonly string[]).includes(key)) throw new TypeError(`seed.${key}: unknown collection (expected ${KEYS.join(", ")})`);
|
|
154
|
+
}
|
|
155
|
+
for (const key of KEYS) {
|
|
156
|
+
const rows = obj[key];
|
|
157
|
+
if (rows === undefined) continue;
|
|
158
|
+
if (!Array.isArray(rows)) throw new TypeError(`seed.${key}: expected an array`);
|
|
159
|
+
rows.forEach((row, i) => {
|
|
160
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) throw new TypeError(`seed.${key}[${i}]: expected an object`);
|
|
161
|
+
for (const field of REQUIRED[key]) {
|
|
162
|
+
if (typeof (row as Record<string, unknown>)[field] !== "string") throw new TypeError(`seed.${key}[${i}].${field}: expected a string`);
|
|
163
|
+
}
|
|
164
|
+
if (key === "entities") checkFacets((row as Record<string, unknown>).facets, `seed.entities[${i}].facets`);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
(obj.entities as Array<Record<string, unknown>> | undefined)?.forEach((row, i) => {
|
|
168
|
+
const roles = row.seedRoles;
|
|
169
|
+
if (roles === undefined) return;
|
|
170
|
+
if (!Array.isArray(roles)) throw new TypeError(`seed.entities[${i}].seedRoles: expected an array`);
|
|
171
|
+
roles.forEach((r, j) => {
|
|
172
|
+
for (const field of ["profileSlug", "workspaceId"]) {
|
|
173
|
+
if (!r || typeof r !== "object" || typeof (r as Record<string, unknown>)[field] !== "string") {
|
|
174
|
+
throw new TypeError(`seed.entities[${i}].seedRoles[${j}].${field}: expected a string`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
// A JSON round trip: what the mock serves is what a file could hold. Then ONE role model: seedRoles → facets.
|
|
180
|
+
const out = JSON.parse(JSON.stringify(obj)) as MockPodSeed;
|
|
181
|
+
if (out.entities) out.entities = out.entities.map((row, i) => desugarSeedRoles(row, `seed.entities[${i}]`) as MockSeedEntity);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function checkFacets(facets: unknown, at: string): void {
|
|
186
|
+
if (facets === undefined) return;
|
|
187
|
+
if (!Array.isArray(facets)) throw new TypeError(`${at}: expected an array`);
|
|
188
|
+
facets.forEach((f, j) => {
|
|
189
|
+
if (!f || typeof f !== "object" || Array.isArray(f)) throw new TypeError(`${at}[${j}]: expected an object`);
|
|
190
|
+
for (const field of ["facetId", "slug", "workspaceId"]) {
|
|
191
|
+
if (typeof (f as Record<string, unknown>)[field] !== "string") throw new TypeError(`${at}[${j}].${field}: expected a string`);
|
|
192
|
+
}
|
|
193
|
+
const shared = (f as Record<string, unknown>).sharedRole;
|
|
194
|
+
if (shared !== undefined && typeof shared !== "boolean") throw new TypeError(`${at}[${j}].sharedRole: expected a boolean`);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/connect/encoding.ts"],"sourcesContent":["/**\n * Web-standard primitives (WebCrypto, TextEncoder, btoa/atob): the same code\n * runs on Node 18+, edge runtimes and workers. No Node import.\n */\n\nconst encoder = new TextEncoder();\nconst decoder = new TextDecoder();\n\n/** `Uint8Array<ArrayBuffer>`: what WebCrypto's `BufferSource` takes under TS 5.7+ lib types. */\nexport type Bytes = Uint8Array<ArrayBuffer>;\n\nexport function utf8(text: string): Bytes {\n return encoder.encode(text);\n}\n\nexport function fromUtf8(bytes: Uint8Array): string {\n return decoder.decode(bytes);\n}\n\nexport function base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** Null when `text` is not base64url. */\nexport function fromBase64url(text: string): Bytes | null {\n if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;\n const padded = text.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"===\".slice((text.length + 3) % 4);\n let binary: string;\n try {\n binary = atob(padded);\n } catch {\n return null;\n }\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);\n return out;\n}\n\nexport function randomBytes(length: number): Bytes {\n const out = new Uint8Array(length);\n crypto.getRandomValues(out);\n return out;\n}\n\n/** 32 random bytes, base64url (43 chars): a state value or a PKCE verifier. */\nexport function randomToken(): string {\n return base64url(randomBytes(32));\n}\n\n/** RFC 7636 §4.2: `BASE64URL(SHA256(ASCII(code_verifier)))`. */\nexport async function pkceChallenge(verifier: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", utf8(verifier));\n return base64url(new Uint8Array(digest));\n}\n\n/** Compare two strings without an early exit on the first differing byte. */\nexport function constantTimeEqual(a: string, b: string): boolean {\n const x = utf8(a);\n const y = utf8(b);\n let diff = x.length ^ y.length;\n for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);\n return diff === 0;\n}\n\n/** The request's cookies by name (the first value wins, as browsers order the most specific first). */\nexport function parseCookies(header: string | null): Map<string, string> {\n const out = new Map<string, string>();\n if (!header) return out;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq < 0) continue;\n const name = part.slice(0, eq).trim();\n if (!name || out.has(name)) continue;\n let value = part.slice(eq + 1).trim();\n if (value.startsWith('\"') && value.endsWith('\"')) value = value.slice(1, -1);\n out.set(name, value);\n }\n return out;\n}\n\nexport interface CookieAttributes {\n maxAgeSec: number;\n secure: boolean;\n}\n\n/** `HttpOnly; SameSite=Lax; Path=/` always; `Secure` on https. Never a `Domain`. */\nexport function serializeCookie(name: string, value: string, attrs: CookieAttributes): string {\n const parts = [`${name}=${value}`, \"Path=/\", \"HttpOnly\", \"SameSite=Lax\", `Max-Age=${Math.max(0, Math.floor(attrs.maxAgeSec))}`];\n if (attrs.secure) parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n"],"mappings":";AAKA,IAAM,UAAU,IAAI,YAAY;AAChC,IAAM,UAAU,IAAI,YAAY;AAKzB,SAAS,KAAK,MAAqB;AACxC,SAAO,QAAQ,OAAO,IAAI;AAC5B;AAEO,SAAS,SAAS,OAA2B;AAClD,SAAO,QAAQ,OAAO,KAAK;AAC7B;AAEO,SAAS,UAAU,OAA2B;AACnD,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,IAAK,WAAU,OAAO,aAAa,MAAM,CAAC,CAAC;AAC7E,SAAO,KAAK,MAAM,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AAC/E;AAGO,SAAS,cAAc,MAA4B;AACxD,MAAI,CAAC,mBAAmB,KAAK,IAAI,EAAG,QAAO;AAC3C,QAAM,SAAS,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,IAAI,MAAM,OAAO,KAAK,SAAS,KAAK,CAAC;AAC7F,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM;AAAA,EACtB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,MAAM,IAAI,WAAW,OAAO,MAAM;AACxC,WAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,IAAK,KAAI,CAAC,IAAI,OAAO,WAAW,CAAC;AACpE,SAAO;AACT;AAEO,SAAS,YAAY,QAAuB;AACjD,QAAM,MAAM,IAAI,WAAW,MAAM;AACjC,SAAO,gBAAgB,GAAG;AAC1B,SAAO;AACT;AAGO,SAAS,cAAsB;AACpC,SAAO,UAAU,YAAY,EAAE,CAAC;AAClC;AAGA,eAAsB,cAAc,UAAmC;AACrE,QAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,KAAK,QAAQ,CAAC;AACnE,SAAO,UAAU,IAAI,WAAW,MAAM,CAAC;AACzC;AAGO,SAAS,kBAAkB,GAAW,GAAoB;AAC/D,QAAM,IAAI,KAAK,CAAC;AAChB,QAAM,IAAI,KAAK,CAAC;AAChB,MAAI,OAAO,EAAE,SAAS,EAAE;AACxB,WAAS,IAAI,GAAG,IAAI,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAK,UAAS,EAAE,CAAC,KAAK,MAAM,EAAE,CAAC,KAAK;AACtF,SAAO,SAAS;AAClB;AAGO,SAAS,aAAa,QAA4C;AACvE,QAAM,MAAM,oBAAI,IAAoB;AACpC,MAAI,CAAC,OAAQ,QAAO;AACpB,aAAW,QAAQ,OAAO,MAAM,GAAG,GAAG;AACpC,UAAM,KAAK,KAAK,QAAQ,GAAG;AAC3B,QAAI,KAAK,EAAG;AACZ,UAAM,OAAO,KAAK,MAAM,GAAG,EAAE,EAAE,KAAK;AACpC,QAAI,CAAC,QAAQ,IAAI,IAAI,IAAI,EAAG;AAC5B,QAAI,QAAQ,KAAK,MAAM,KAAK,CAAC,EAAE,KAAK;AACpC,QAAI,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,EAAG,SAAQ,MAAM,MAAM,GAAG,EAAE;AAC3E,QAAI,IAAI,MAAM,KAAK;AAAA,EACrB;AACA,SAAO;AACT;AAQO,SAAS,gBAAgB,MAAc,OAAe,OAAiC;AAC5F,QAAM,QAAQ,CAAC,GAAG,IAAI,IAAI,KAAK,IAAI,UAAU,YAAY,gBAAgB,WAAW,KAAK,IAAI,GAAG,KAAK,MAAM,MAAM,SAAS,CAAC,CAAC,EAAE;AAC9H,MAAI,MAAM,OAAQ,OAAM,KAAK,QAAQ;AACrC,SAAO,MAAM,KAAK,IAAI;AACxB;","names":[]}
|