@mindees/updates 0.1.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/LICENSE +31 -0
- package/README.md +125 -0
- package/dist/client.d.ts +74 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +227 -0
- package/dist/client.js.map +1 -0
- package/dist/crypto.d.ts +42 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +63 -0
- package/dist/crypto.js.map +1 -0
- package/dist/delta.d.ts +50 -0
- package/dist/delta.d.ts.map +1 -0
- package/dist/delta.js +238 -0
- package/dist/delta.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +83 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +134 -0
- package/dist/manifest.js.map +1 -0
- package/dist/sdui.d.ts +80 -0
- package/dist/sdui.d.ts.map +1 -0
- package/dist/sdui.js +275 -0
- package/dist/sdui.js.map +1 -0
- package/dist/server.d.ts +83 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +109 -0
- package/dist/server.js.map +1 -0
- package/dist/signing.d.ts +54 -0
- package/dist/signing.d.ts.map +1 -0
- package/dist/signing.js +64 -0
- package/dist/signing.js.map +1 -0
- package/dist/store.d.ts +61 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +55 -0
- package/dist/store.js.map +1 -0
- package/package.json +45 -0
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { UpdateError } from "./errors.js";
|
|
2
|
+
//#region src/manifest.ts
|
|
3
|
+
/**
|
|
4
|
+
* The Pulse update manifest: a versioned description of a bundle's files, each
|
|
5
|
+
* addressed by SHA-256. One signature over the manifest's canonical bytes
|
|
6
|
+
* transitively secures every listed file.
|
|
7
|
+
*
|
|
8
|
+
* This module owns the manifest types, a **deterministic** serializer
|
|
9
|
+
* ({@link canonicalManifestJson}) used as the signing input, and a validating
|
|
10
|
+
* parser ({@link parseManifest}) for untrusted input.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Every distinct file the manifest references: the launch asset followed by the
|
|
16
|
+
* remaining assets, de-duplicated by SHA-256 (so a launch asset also listed in
|
|
17
|
+
* `assets` is only downloaded/verified once).
|
|
18
|
+
*/
|
|
19
|
+
function allAssets(manifest) {
|
|
20
|
+
const seen = /* @__PURE__ */ new Set();
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const asset of [manifest.launchAsset, ...manifest.assets]) {
|
|
23
|
+
if (seen.has(asset.sha256)) continue;
|
|
24
|
+
seen.add(asset.sha256);
|
|
25
|
+
out.push(asset);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Serialize a manifest to **canonical** JSON: object keys sorted recursively,
|
|
31
|
+
* compact (no whitespace), `undefined` fields omitted. The same manifest always
|
|
32
|
+
* produces byte-identical output, so signing is reproducible. All numeric fields
|
|
33
|
+
* are integers (no float-formatting ambiguity).
|
|
34
|
+
*/
|
|
35
|
+
function canonicalManifestJson(manifest) {
|
|
36
|
+
return stableStringify(manifest);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Recursively serialize a JSON value with object keys sorted and `undefined`
|
|
40
|
+
* properties omitted, so the same logical value always yields identical bytes.
|
|
41
|
+
*/
|
|
42
|
+
function stableStringify(value) {
|
|
43
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
44
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
45
|
+
const obj = value;
|
|
46
|
+
return `{${Object.keys(obj).filter((k) => obj[k] !== void 0).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
|
47
|
+
}
|
|
48
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
49
|
+
/**
|
|
50
|
+
* Validate that `input` is a well-formed {@link UpdateManifest} and return it
|
|
51
|
+
* typed. Throws {@link UpdateError} (`MANIFEST_MALFORMED`) for invalid JSON or any
|
|
52
|
+
* shape violation. Used on untrusted input, so validation is strict.
|
|
53
|
+
*/
|
|
54
|
+
function parseManifest(input) {
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(input);
|
|
58
|
+
} catch {
|
|
59
|
+
throw malformed("not valid JSON");
|
|
60
|
+
}
|
|
61
|
+
if (!isObject(raw)) throw malformed("expected an object");
|
|
62
|
+
if (raw.schema !== 1) throw malformed("schema must be 1");
|
|
63
|
+
if (!isNonEmptyString(raw.id)) throw malformed("id must be a non-empty string");
|
|
64
|
+
if (!isNonNegativeInteger(raw.version)) throw malformed("version must be a non-negative integer");
|
|
65
|
+
if (!isNonEmptyString(raw.runtimeVersion)) throw malformed("runtimeVersion must be a non-empty string");
|
|
66
|
+
if (!isIsoDate(raw.createdAt)) throw malformed("createdAt must be an ISO-8601 date");
|
|
67
|
+
if (raw.expires !== void 0 && !isIsoDate(raw.expires)) throw malformed("expires, if present, must be an ISO-8601 date");
|
|
68
|
+
validateAsset(raw.launchAsset, "launchAsset");
|
|
69
|
+
if (!Array.isArray(raw.assets)) throw malformed("assets must be an array");
|
|
70
|
+
raw.assets.forEach((a, i) => {
|
|
71
|
+
validateAsset(a, `assets[${i}]`);
|
|
72
|
+
});
|
|
73
|
+
if (raw.metadata !== void 0) validateMetadata(raw.metadata);
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Assert `value` is a well-formed {@link AssetEntry}; throw `MANIFEST_MALFORMED`
|
|
78
|
+
* otherwise. `allowPatch` is false when validating a nested delta asset — a delta
|
|
79
|
+
* blob is fetched whole and must not itself carry a `patch` (no delta-of-a-delta).
|
|
80
|
+
*/
|
|
81
|
+
function validateAsset(value, where, allowPatch = true) {
|
|
82
|
+
if (!isObject(value)) throw malformed(`${where} must be an object`);
|
|
83
|
+
if (!isNonEmptyString(value.path)) throw malformed(`${where}.path must be a non-empty string`);
|
|
84
|
+
if (!isNonNegativeInteger(value.size)) throw malformed(`${where}.size must be a non-negative integer`);
|
|
85
|
+
if (typeof value.sha256 !== "string" || !SHA256_HEX.test(value.sha256)) throw malformed(`${where}.sha256 must be lowercase hex SHA-256`);
|
|
86
|
+
if (value.patch !== void 0) {
|
|
87
|
+
if (!allowPatch) throw malformed(`${where}.patch is not allowed on a delta asset`);
|
|
88
|
+
validatePatch(value.patch, `${where}.patch`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Assert `value` is a well-formed {@link PatchDescriptor}; throw `MANIFEST_MALFORMED` otherwise. */
|
|
92
|
+
function validatePatch(value, where) {
|
|
93
|
+
if (!isObject(value)) throw malformed(`${where} must be an object`);
|
|
94
|
+
if (typeof value.base !== "string" || !SHA256_HEX.test(value.base)) throw malformed(`${where}.base must be lowercase hex SHA-256`);
|
|
95
|
+
validateAsset(value.delta, `${where}.delta`, false);
|
|
96
|
+
}
|
|
97
|
+
/** Assert `value` is a string→string map; throw `MANIFEST_MALFORMED` otherwise. */
|
|
98
|
+
function validateMetadata(value) {
|
|
99
|
+
if (!isObject(value)) throw malformed("metadata must be an object");
|
|
100
|
+
for (const [k, v] of Object.entries(value)) if (typeof v !== "string") throw malformed(`metadata.${k} must be a string`);
|
|
101
|
+
}
|
|
102
|
+
/** Build a `MANIFEST_MALFORMED` {@link UpdateError} with a uniform message prefix. */
|
|
103
|
+
function malformed(detail) {
|
|
104
|
+
return new UpdateError("MANIFEST_MALFORMED", `malformed update manifest: ${detail}`);
|
|
105
|
+
}
|
|
106
|
+
/** Narrow to a plain (non-array, non-null) object. */
|
|
107
|
+
function isObject(value) {
|
|
108
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
109
|
+
}
|
|
110
|
+
/** Narrow to a non-empty string. */
|
|
111
|
+
function isNonEmptyString(value) {
|
|
112
|
+
return typeof value === "string" && value.length > 0;
|
|
113
|
+
}
|
|
114
|
+
/** Narrow to an integer ≥ 0. */
|
|
115
|
+
function isNonNegativeInteger(value) {
|
|
116
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
117
|
+
}
|
|
118
|
+
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
119
|
+
/**
|
|
120
|
+
* Strict canonical-ISO check: exactly `YYYY-MM-DDTHH:mm:ss.sssZ` (UTC, millisecond
|
|
121
|
+
* precision) **and** a real calendar date. `Date.parse` alone is deliberately
|
|
122
|
+
* avoided — ECMAScript lets it accept implementation-defined formats, so a manifest
|
|
123
|
+
* deemed "valid" (or an `expires` boundary) could differ across Node/browser/Hermes.
|
|
124
|
+
* Round-tripping through `toISOString()` pins one representation on every runtime.
|
|
125
|
+
*/
|
|
126
|
+
function isIsoDate(value) {
|
|
127
|
+
if (typeof value !== "string" || !ISO_UTC.test(value)) return false;
|
|
128
|
+
const ms = Date.parse(value);
|
|
129
|
+
return !Number.isNaN(ms) && new Date(ms).toISOString() === value;
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
export { allAssets, canonicalManifestJson, parseManifest };
|
|
133
|
+
|
|
134
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.js","names":[],"sources":["../src/manifest.ts"],"sourcesContent":["/**\n * The Pulse update manifest: a versioned description of a bundle's files, each\n * addressed by SHA-256. One signature over the manifest's canonical bytes\n * transitively secures every listed file.\n *\n * This module owns the manifest types, a **deterministic** serializer\n * ({@link canonicalManifestJson}) used as the signing input, and a validating\n * parser ({@link parseManifest}) for untrusted input.\n *\n * @module\n */\n\nimport { UpdateError } from './errors'\n\n/** One file in an update, addressed by content hash. */\nexport interface AssetEntry {\n /** Logical path of the file within the bundle (e.g. `\"index.js\"`). */\n readonly path: string\n /** Size in bytes. */\n readonly size: number\n /** Lowercase hex SHA-256 of the file's bytes. */\n readonly sha256: string\n /**\n * Optional differential-download hint: reconstruct this asset by applying a delta\n * to a base blob the client likely already has, instead of fetching it whole. Purely\n * an optimization — the reconstructed bytes are still verified against\n * {@link AssetEntry.sha256}, so a bad or forged delta can never install unverified\n * bytes (the client falls back to a full download). See `delta.ts`.\n */\n readonly patch?: PatchDescriptor\n}\n\n/** A differential-download descriptor (see {@link AssetEntry.patch}). */\nexport interface PatchDescriptor {\n /** Lowercase hex SHA-256 of the base blob the delta applies to. */\n readonly base: string\n /** The delta blob, itself a content-addressed {@link AssetEntry} (never nested again). */\n readonly delta: AssetEntry\n}\n\n/**\n * A versioned description of an update's files. Because every {@link AssetEntry}\n * carries its own SHA-256, a single signature over the manifest secures the whole\n * bundle: verify the signature, then verify each downloaded file against its hash.\n */\nexport interface UpdateManifest {\n /** Manifest schema version. */\n readonly schema: 1\n /** Unique id for this update (used as the generation id on the device). */\n readonly id: string\n /** Monotonic version; a strictly higher value is newer. Drives rollback protection. */\n readonly version: number\n /** Native-compatibility token; must match the app's runtime version exactly. */\n readonly runtimeVersion: string\n /** ISO-8601 creation timestamp. */\n readonly createdAt: string\n /** Optional ISO-8601 expiry; a past value makes the manifest stale (rejected). */\n readonly expires?: string\n /** The entry-point asset to launch (typically the JS bundle). */\n readonly launchAsset: AssetEntry\n /** Additional assets (images, fonts, …). The launch asset need not be repeated here. */\n readonly assets: readonly AssetEntry[]\n /** Free-form string metadata (channel, release notes, …). */\n readonly metadata?: Readonly<Record<string, string>>\n}\n\n/**\n * Every distinct file the manifest references: the launch asset followed by the\n * remaining assets, de-duplicated by SHA-256 (so a launch asset also listed in\n * `assets` is only downloaded/verified once).\n */\nexport function allAssets(manifest: UpdateManifest): AssetEntry[] {\n const seen = new Set<string>()\n const out: AssetEntry[] = []\n for (const asset of [manifest.launchAsset, ...manifest.assets]) {\n if (seen.has(asset.sha256)) continue\n seen.add(asset.sha256)\n out.push(asset)\n }\n return out\n}\n\n/**\n * Serialize a manifest to **canonical** JSON: object keys sorted recursively,\n * compact (no whitespace), `undefined` fields omitted. The same manifest always\n * produces byte-identical output, so signing is reproducible. All numeric fields\n * are integers (no float-formatting ambiguity).\n */\nexport function canonicalManifestJson(manifest: UpdateManifest): string {\n return stableStringify(manifest)\n}\n\n/**\n * Recursively serialize a JSON value with object keys sorted and `undefined`\n * properties omitted, so the same logical value always yields identical bytes.\n */\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value)\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`\n const obj = value as Record<string, unknown>\n const keys = Object.keys(obj)\n .filter((k) => obj[k] !== undefined)\n .sort()\n return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`\n}\n\nconst SHA256_HEX = /^[0-9a-f]{64}$/\n\n/**\n * Validate that `input` is a well-formed {@link UpdateManifest} and return it\n * typed. Throws {@link UpdateError} (`MANIFEST_MALFORMED`) for invalid JSON or any\n * shape violation. Used on untrusted input, so validation is strict.\n */\nexport function parseManifest(input: string): UpdateManifest {\n let raw: unknown\n try {\n raw = JSON.parse(input)\n } catch {\n throw malformed('not valid JSON')\n }\n if (!isObject(raw)) throw malformed('expected an object')\n\n if (raw.schema !== 1) throw malformed('schema must be 1')\n if (!isNonEmptyString(raw.id)) throw malformed('id must be a non-empty string')\n if (!isNonNegativeInteger(raw.version)) throw malformed('version must be a non-negative integer')\n if (!isNonEmptyString(raw.runtimeVersion))\n throw malformed('runtimeVersion must be a non-empty string')\n if (!isIsoDate(raw.createdAt)) throw malformed('createdAt must be an ISO-8601 date')\n if (raw.expires !== undefined && !isIsoDate(raw.expires)) {\n throw malformed('expires, if present, must be an ISO-8601 date')\n }\n validateAsset(raw.launchAsset, 'launchAsset')\n if (!Array.isArray(raw.assets)) throw malformed('assets must be an array')\n raw.assets.forEach((a, i) => {\n validateAsset(a, `assets[${i}]`)\n })\n if (raw.metadata !== undefined) validateMetadata(raw.metadata)\n\n return raw as unknown as UpdateManifest\n}\n\n/**\n * Assert `value` is a well-formed {@link AssetEntry}; throw `MANIFEST_MALFORMED`\n * otherwise. `allowPatch` is false when validating a nested delta asset — a delta\n * blob is fetched whole and must not itself carry a `patch` (no delta-of-a-delta).\n */\nfunction validateAsset(\n value: unknown,\n where: string,\n allowPatch = true,\n): asserts value is AssetEntry {\n if (!isObject(value)) throw malformed(`${where} must be an object`)\n if (!isNonEmptyString(value.path)) throw malformed(`${where}.path must be a non-empty string`)\n if (!isNonNegativeInteger(value.size))\n throw malformed(`${where}.size must be a non-negative integer`)\n if (typeof value.sha256 !== 'string' || !SHA256_HEX.test(value.sha256)) {\n throw malformed(`${where}.sha256 must be lowercase hex SHA-256`)\n }\n if (value.patch !== undefined) {\n if (!allowPatch) throw malformed(`${where}.patch is not allowed on a delta asset`)\n validatePatch(value.patch, `${where}.patch`)\n }\n}\n\n/** Assert `value` is a well-formed {@link PatchDescriptor}; throw `MANIFEST_MALFORMED` otherwise. */\nfunction validatePatch(value: unknown, where: string): asserts value is PatchDescriptor {\n if (!isObject(value)) throw malformed(`${where} must be an object`)\n if (typeof value.base !== 'string' || !SHA256_HEX.test(value.base)) {\n throw malformed(`${where}.base must be lowercase hex SHA-256`)\n }\n validateAsset(value.delta, `${where}.delta`, false)\n}\n\n/** Assert `value` is a string→string map; throw `MANIFEST_MALFORMED` otherwise. */\nfunction validateMetadata(value: unknown): void {\n if (!isObject(value)) throw malformed('metadata must be an object')\n for (const [k, v] of Object.entries(value)) {\n if (typeof v !== 'string') throw malformed(`metadata.${k} must be a string`)\n }\n}\n\n/** Build a `MANIFEST_MALFORMED` {@link UpdateError} with a uniform message prefix. */\nfunction malformed(detail: string): UpdateError {\n return new UpdateError('MANIFEST_MALFORMED', `malformed update manifest: ${detail}`)\n}\n\n/** Narrow to a plain (non-array, non-null) object. */\nfunction isObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** Narrow to a non-empty string. */\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0\n}\n\n/** Narrow to an integer ≥ 0. */\nfunction isNonNegativeInteger(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value >= 0\n}\n\nconst ISO_UTC = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/\n\n/**\n * Strict canonical-ISO check: exactly `YYYY-MM-DDTHH:mm:ss.sssZ` (UTC, millisecond\n * precision) **and** a real calendar date. `Date.parse` alone is deliberately\n * avoided — ECMAScript lets it accept implementation-defined formats, so a manifest\n * deemed \"valid\" (or an `expires` boundary) could differ across Node/browser/Hermes.\n * Round-tripping through `toISOString()` pins one representation on every runtime.\n */\nfunction isIsoDate(value: unknown): value is string {\n if (typeof value !== 'string' || !ISO_UTC.test(value)) return false\n const ms = Date.parse(value)\n return !Number.isNaN(ms) && new Date(ms).toISOString() === value\n}\n"],"mappings":"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"}
|
package/dist/sdui.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Component, MindeesNode } from "@mindees/core";
|
|
2
|
+
|
|
3
|
+
//#region src/sdui.d.ts
|
|
4
|
+
/** A plain JSON value. SDUI markers (`$action`/`$bind`) are never interpreted inside one. */
|
|
5
|
+
type SduiJson = string | number | boolean | null | SduiJson[] | {
|
|
6
|
+
[key: string]: SduiJson;
|
|
7
|
+
};
|
|
8
|
+
/** A reference to a pre-registered action handler (a prop's direct value). */
|
|
9
|
+
interface SduiActionRef {
|
|
10
|
+
readonly $action: string;
|
|
11
|
+
readonly args?: SduiJson;
|
|
12
|
+
}
|
|
13
|
+
/** A reference to a data binding resolved at compile time (a prop's direct value). */
|
|
14
|
+
interface SduiBindRef {
|
|
15
|
+
readonly $bind: string;
|
|
16
|
+
}
|
|
17
|
+
/** A compiled prop's source value: plain JSON, or an action/bind marker. */
|
|
18
|
+
type SduiPropValue = SduiJson | SduiActionRef | SduiBindRef;
|
|
19
|
+
/** A server-driven UI node: a versioned, allowlisted element description. */
|
|
20
|
+
interface SduiNode {
|
|
21
|
+
readonly schema: 1;
|
|
22
|
+
readonly tag: string;
|
|
23
|
+
readonly props?: Readonly<Record<string, SduiPropValue>>;
|
|
24
|
+
readonly children?: ReadonlyArray<SduiNode | string>;
|
|
25
|
+
readonly key?: string;
|
|
26
|
+
}
|
|
27
|
+
/** A registered action handler. `args` come from the node; `event` from the renderer. */
|
|
28
|
+
type SduiActionHandler = (args: SduiJson | undefined, ...event: unknown[]) => unknown;
|
|
29
|
+
/** Hard caps that bound a malicious or runaway payload. */
|
|
30
|
+
interface SduiLimits {
|
|
31
|
+
readonly maxDepth: number;
|
|
32
|
+
readonly maxNodes: number;
|
|
33
|
+
readonly maxStringLength: number;
|
|
34
|
+
readonly maxProps: number;
|
|
35
|
+
}
|
|
36
|
+
/** Injected allowlist + handlers + bindings for {@link compileSdui}. */
|
|
37
|
+
interface SduiRegistry {
|
|
38
|
+
/** Allowlist: SDUI tag → a host-tag string or a {@link Component}. Unknown tags are rejected. */
|
|
39
|
+
readonly components: Readonly<Record<string, string | Component<never>>>;
|
|
40
|
+
/** Named action handlers (resolved for `{ $action }` props). */
|
|
41
|
+
readonly actions?: Readonly<Record<string, SduiActionHandler>>;
|
|
42
|
+
/** Resolver for `{ $bind }` props — may return a `() => value` accessor for reactivity. */
|
|
43
|
+
readonly bindings?: (path: string) => unknown;
|
|
44
|
+
/** Overrides for the default {@link SduiLimits}. */
|
|
45
|
+
readonly limits?: Partial<SduiLimits>;
|
|
46
|
+
}
|
|
47
|
+
/** Stable code identifying why an SDUI operation failed. */
|
|
48
|
+
type SduiErrorCode = 'SDUI_INVALID' | 'SDUI_UNKNOWN_TAG' | 'SDUI_UNKNOWN_ACTION' | 'SDUI_NO_BINDINGS' | 'SDUI_LIMIT' | 'SDUI_FORBIDDEN_KEY' | 'SDUI_PATCH_INVALID';
|
|
49
|
+
/** An SDUI error carrying a stable {@link SduiErrorCode}. */
|
|
50
|
+
declare class SduiError extends Error {
|
|
51
|
+
readonly code: SduiErrorCode;
|
|
52
|
+
constructor(code: SduiErrorCode, message: string);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Validate an untrusted SDUI tree against `registry` and compile it to a `MindeesNode`.
|
|
56
|
+
* Throws {@link SduiError} on any violation (fail closed).
|
|
57
|
+
*/
|
|
58
|
+
declare function compileSdui(node: unknown, registry: SduiRegistry): MindeesNode;
|
|
59
|
+
/**
|
|
60
|
+
* Apply an RFC 7396 JSON Merge Patch, returning a new value (the input is untouched).
|
|
61
|
+
* `null` members delete keys; objects merge recursively; arrays/primitives replace.
|
|
62
|
+
* Prototype-pollution keys are rejected.
|
|
63
|
+
*/
|
|
64
|
+
declare function applyMergePatch(target: SduiJson, patch: SduiJson): SduiJson;
|
|
65
|
+
/** One operation of the safe RFC 6902 subset (`add` / `remove` / `replace`). */
|
|
66
|
+
interface JsonPatchOp {
|
|
67
|
+
readonly op: 'add' | 'remove' | 'replace';
|
|
68
|
+
readonly path: string;
|
|
69
|
+
readonly value?: SduiJson;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Apply a safe RFC 6902 patch (`add`/`remove`/`replace` only) to `doc`, returning a new
|
|
73
|
+
* value (the input is untouched). `move`/`copy`/`test` are intentionally unsupported and
|
|
74
|
+
* throw. Prototype-pollution keys/segments are rejected. The result must still be
|
|
75
|
+
* re-validated via {@link compileSdui} before render.
|
|
76
|
+
*/
|
|
77
|
+
declare function applyJsonPatch(doc: SduiJson, ops: readonly JsonPatchOp[]): SduiJson;
|
|
78
|
+
//#endregion
|
|
79
|
+
export { JsonPatchOp, SduiActionHandler, SduiActionRef, SduiBindRef, SduiError, SduiErrorCode, SduiJson, SduiLimits, SduiNode, SduiPropValue, SduiRegistry, applyJsonPatch, applyMergePatch, compileSdui };
|
|
80
|
+
//# sourceMappingURL=sdui.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sdui.d.ts","names":[],"sources":["../src/sdui.ts"],"mappings":";;;;KA0BY,QAAA,sCAA8C,QAAA;EAAA,CAAgB,GAAA,WAAc,QAAQ;AAAA;;UAG/E,aAAA;EAAA,SACN,OAAA;EAAA,SACA,IAAA,GAAO,QAAQ;AAAA;;UAIT,WAAA;EAAA,SACN,KAAK;AAAA;;KAIJ,aAAA,GAAgB,QAAA,GAAW,aAAA,GAAgB,WAAA;AAAW;AAAA,UAGjD,QAAA;EAAA,SACN,MAAA;EAAA,SACA,GAAA;EAAA,SACA,KAAA,GAAQ,QAAA,CAAS,MAAA,SAAe,aAAA;EAAA,SAChC,QAAA,GAAW,aAAA,CAAc,QAAA;EAAA,SACzB,GAAA;AAAA;;KAIC,iBAAA,IAAqB,IAAA,EAAM,QAAQ,iBAAiB,KAAA;;UAG/C,UAAA;EAAA,SACN,QAAA;EAAA,SACA,QAAA;EAAA,SACA,eAAA;EAAA,SACA,QAAA;AAAA;;UAIM,YAAA;EAhBmB;EAAA,SAkBzB,UAAA,EAAY,QAAA,CAAS,MAAA,kBAAwB,SAAA;EAjB1C;EAAA,SAmBH,OAAA,GAAU,QAAA,CAAS,MAAA,SAAe,iBAAA;EAfjC;EAAA,SAiBD,QAAA,IAAY,IAAA;;WAEZ,MAAA,GAAS,OAAA,CAAQ,UAAA;AAAA;;KAIhB,aAAA;;cAUC,SAAA,SAAkB,KAAA;EAAA,SACpB,IAAA,EAAM,aAAA;cACH,IAAA,EAAM,aAAA,EAAe,OAAA;AAAA;;;;;iBAgCnB,WAAA,CAAY,IAAA,WAAe,QAAA,EAAU,YAAA,GAAe,WAAW;;AA5D5D;AAInB;;;iBAqQgB,eAAA,CAAgB,MAAA,EAAQ,QAAA,EAAU,KAAA,EAAO,QAAA,GAAW,QAAA;;UAKnD,WAAA;EAAA,SACN,EAAA;EAAA,SACA,IAAA;EAAA,SACA,KAAA,GAAQ,QAAQ;AAAA;;;;;;;iBAkCX,cAAA,CAAe,GAAA,EAAK,QAAA,EAAU,GAAA,WAAc,WAAA,KAAgB,QAAA"}
|
package/dist/sdui.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { createElement } from "@mindees/core";
|
|
2
|
+
//#region src/sdui.ts
|
|
3
|
+
/**
|
|
4
|
+
* Pulse **server-driven UI (SDUI)** — ship UI as *data* over OTA and render it through
|
|
5
|
+
* `@mindees/core`'s `createElement` + signals. The headline risk is injection, so this
|
|
6
|
+
* module is **allowlist-first** and **never evaluates** any transported string:
|
|
7
|
+
*
|
|
8
|
+
* - {@link compileSdui} validates an untrusted {@link SduiNode} tree (fail-closed, like
|
|
9
|
+
* `parseManifest`) against an injected {@link SduiRegistry} allowlist and compiles it
|
|
10
|
+
* to a `MindeesNode`. Unknown tags/actions, missing bindings, limit breaches, and
|
|
11
|
+
* dangerous keys all throw {@link SduiError}.
|
|
12
|
+
* - Named **actions** (`{ "$action": "name", "args"? }`) compile to a function calling a
|
|
13
|
+
* pre-registered handler; **bindings** (`{ "$bind": "path" }`) resolve to a value or a
|
|
14
|
+
* `() => value` accessor (a reactive region). Neither ever transports code.
|
|
15
|
+
* - {@link applyMergePatch} (RFC 7396) and {@link applyJsonPatch} (a safe RFC 6902
|
|
16
|
+
* subset — `add`/`remove`/`replace`) patch the JSON tree; the result MUST be re-run
|
|
17
|
+
* through {@link compileSdui} before render, so a delta can never bypass the allowlist.
|
|
18
|
+
*
|
|
19
|
+
* Exported from the **`@mindees/updates/sdui`** subpath; depends only on `@mindees/core`
|
|
20
|
+
* (the renderer is an optional peer the consumer mounts). See
|
|
21
|
+
* `docs/adr/0011-pulse-sdui.md`.
|
|
22
|
+
*
|
|
23
|
+
* @module
|
|
24
|
+
*/
|
|
25
|
+
/** An SDUI error carrying a stable {@link SduiErrorCode}. */
|
|
26
|
+
var SduiError = class extends Error {
|
|
27
|
+
code;
|
|
28
|
+
constructor(code, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "SduiError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const DEFAULT_LIMITS = {
|
|
35
|
+
maxDepth: 50,
|
|
36
|
+
maxNodes: 5e3,
|
|
37
|
+
maxStringLength: 1e5,
|
|
38
|
+
maxProps: 100
|
|
39
|
+
};
|
|
40
|
+
/** Keys that must never be set from untrusted input (prototype-pollution vectors). */
|
|
41
|
+
const FORBIDDEN_KEYS = new Set([
|
|
42
|
+
"__proto__",
|
|
43
|
+
"constructor",
|
|
44
|
+
"prototype"
|
|
45
|
+
]);
|
|
46
|
+
const NODE_KEYS = new Set([
|
|
47
|
+
"schema",
|
|
48
|
+
"tag",
|
|
49
|
+
"props",
|
|
50
|
+
"children",
|
|
51
|
+
"key"
|
|
52
|
+
]);
|
|
53
|
+
/** Build an {@link SduiError} with a stable code. */
|
|
54
|
+
function err(code, message) {
|
|
55
|
+
return new SduiError(code, message);
|
|
56
|
+
}
|
|
57
|
+
/** Narrow to a plain (non-array, non-null) object. */
|
|
58
|
+
function isPlainObject(value) {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validate an untrusted SDUI tree against `registry` and compile it to a `MindeesNode`.
|
|
63
|
+
* Throws {@link SduiError} on any violation (fail closed).
|
|
64
|
+
*/
|
|
65
|
+
function compileSdui(node, registry) {
|
|
66
|
+
const limits = {
|
|
67
|
+
...DEFAULT_LIMITS,
|
|
68
|
+
...registry.limits
|
|
69
|
+
};
|
|
70
|
+
let nodeCount = 0;
|
|
71
|
+
const checkString = (s) => {
|
|
72
|
+
if (s.length > limits.maxStringLength) throw err("SDUI_LIMIT", `string exceeds max length ${limits.maxStringLength}`);
|
|
73
|
+
};
|
|
74
|
+
const compileJson = (value, depth) => {
|
|
75
|
+
if (depth > limits.maxDepth) throw err("SDUI_LIMIT", "value exceeds max depth");
|
|
76
|
+
if (++nodeCount > limits.maxNodes) throw err("SDUI_LIMIT", `payload exceeds max nodes ${limits.maxNodes}`);
|
|
77
|
+
if (value === null) return null;
|
|
78
|
+
const t = typeof value;
|
|
79
|
+
if (t === "string") {
|
|
80
|
+
checkString(value);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
if (t === "number") {
|
|
84
|
+
if (!Number.isFinite(value)) throw err("SDUI_INVALID", "numbers must be finite");
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
if (t === "boolean") return value;
|
|
88
|
+
if (Array.isArray(value)) return value.map((v) => compileJson(v, depth + 1));
|
|
89
|
+
if (isPlainObject(value)) {
|
|
90
|
+
const keys = Object.keys(value);
|
|
91
|
+
if (keys.length > limits.maxProps) throw err("SDUI_LIMIT", `object exceeds max keys ${limits.maxProps}`);
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const k of keys) {
|
|
94
|
+
if (FORBIDDEN_KEYS.has(k)) throw err("SDUI_FORBIDDEN_KEY", `forbidden key "${k}"`);
|
|
95
|
+
out[k] = compileJson(value[k], depth + 1);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
throw err("SDUI_INVALID", `unsupported value of type ${t}`);
|
|
100
|
+
};
|
|
101
|
+
const compilePropValue = (value) => {
|
|
102
|
+
if (isPlainObject(value) && Object.hasOwn(value, "$action")) {
|
|
103
|
+
if (Object.keys(value).some((k) => k !== "$action" && k !== "args")) throw err("SDUI_INVALID", "an $action ref may only contain $action and args");
|
|
104
|
+
const name = value.$action;
|
|
105
|
+
if (typeof name !== "string") throw err("SDUI_INVALID", "$action must be a string");
|
|
106
|
+
const handler = registry.actions && Object.hasOwn(registry.actions, name) ? registry.actions[name] : void 0;
|
|
107
|
+
if (typeof handler !== "function") throw err("SDUI_UNKNOWN_ACTION", `action "${name}" is not registered`);
|
|
108
|
+
const args = value.args !== void 0 ? compileJson(value.args, 0) : void 0;
|
|
109
|
+
return (...event) => handler(args, ...event);
|
|
110
|
+
}
|
|
111
|
+
if (isPlainObject(value) && Object.hasOwn(value, "$bind")) {
|
|
112
|
+
if (Object.keys(value).some((k) => k !== "$bind")) throw err("SDUI_INVALID", "a $bind ref may only contain $bind");
|
|
113
|
+
if (typeof value.$bind !== "string") throw err("SDUI_INVALID", "$bind must be a string");
|
|
114
|
+
checkString(value.$bind);
|
|
115
|
+
if (!registry.bindings) throw err("SDUI_NO_BINDINGS", `$bind "${value.$bind}" but no bindings provider`);
|
|
116
|
+
return registry.bindings(value.$bind);
|
|
117
|
+
}
|
|
118
|
+
return compileJson(value, 0);
|
|
119
|
+
};
|
|
120
|
+
const compileProps = (rawProps) => {
|
|
121
|
+
const out = Object.create(null);
|
|
122
|
+
if (rawProps === void 0) return out;
|
|
123
|
+
if (!isPlainObject(rawProps)) throw err("SDUI_INVALID", "node.props must be an object");
|
|
124
|
+
const keys = Object.keys(rawProps);
|
|
125
|
+
if (keys.length > limits.maxProps) throw err("SDUI_LIMIT", `node has more than ${limits.maxProps} props`);
|
|
126
|
+
for (const key of keys) {
|
|
127
|
+
if (FORBIDDEN_KEYS.has(key)) throw err("SDUI_FORBIDDEN_KEY", `forbidden prop key "${key}"`);
|
|
128
|
+
if (key === "key" || key === "children") throw err("SDUI_INVALID", `"${key}" is reserved — use node.${key}, not a prop`);
|
|
129
|
+
out[key] = compilePropValue(rawProps[key]);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
};
|
|
133
|
+
const compileNode = (raw, depth) => {
|
|
134
|
+
if (depth > limits.maxDepth) throw err("SDUI_LIMIT", `tree exceeds max depth ${limits.maxDepth}`);
|
|
135
|
+
if (++nodeCount > limits.maxNodes) throw err("SDUI_LIMIT", `tree exceeds max nodes ${limits.maxNodes}`);
|
|
136
|
+
if (!isPlainObject(raw)) throw err("SDUI_INVALID", "node must be an object");
|
|
137
|
+
for (const k of Object.keys(raw)) if (!NODE_KEYS.has(k)) throw err("SDUI_INVALID", `unknown node key "${k}"`);
|
|
138
|
+
if (raw.schema !== 1) throw err("SDUI_INVALID", "node.schema must be 1");
|
|
139
|
+
if (typeof raw.tag !== "string" || raw.tag.length === 0) throw err("SDUI_INVALID", "node.tag must be a non-empty string");
|
|
140
|
+
if (!Object.hasOwn(registry.components, raw.tag)) throw err("SDUI_UNKNOWN_TAG", `tag "${raw.tag}" is not in the component allowlist`);
|
|
141
|
+
const component = registry.components[raw.tag];
|
|
142
|
+
if (component === void 0) throw err("SDUI_UNKNOWN_TAG", `tag "${raw.tag}" is not in the component allowlist`);
|
|
143
|
+
const props = compileProps(raw.props);
|
|
144
|
+
if (raw.key !== void 0) {
|
|
145
|
+
if (typeof raw.key !== "string") throw err("SDUI_INVALID", "node.key must be a string");
|
|
146
|
+
checkString(raw.key);
|
|
147
|
+
props.key = raw.key;
|
|
148
|
+
}
|
|
149
|
+
const children = [];
|
|
150
|
+
if (raw.children !== void 0) {
|
|
151
|
+
if (!Array.isArray(raw.children)) throw err("SDUI_INVALID", "node.children must be an array");
|
|
152
|
+
for (const child of raw.children) if (typeof child === "string") {
|
|
153
|
+
if (++nodeCount > limits.maxNodes) throw err("SDUI_LIMIT", `tree exceeds max nodes ${limits.maxNodes}`);
|
|
154
|
+
checkString(child);
|
|
155
|
+
children.push(child);
|
|
156
|
+
} else children.push(compileNode(child, depth + 1));
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
return createElement(component, props, ...children);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
if (e instanceof RangeError) throw err("SDUI_LIMIT", "too many children to construct");
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
return compileNode(node, 0);
|
|
166
|
+
}
|
|
167
|
+
/** Recursion cap for the patch helpers — well below a native stack overflow, generous for real JSON. */
|
|
168
|
+
const MAX_PATCH_DEPTH = 1e3;
|
|
169
|
+
/** Deep-clone a JSON value, rejecting any prototype-pollution keys (fail closed). */
|
|
170
|
+
function cloneJson(value, depth = 0) {
|
|
171
|
+
if (depth > MAX_PATCH_DEPTH) throw err("SDUI_PATCH_INVALID", "value exceeds max depth");
|
|
172
|
+
if (Array.isArray(value)) return value.map((v) => cloneJson(v, depth + 1));
|
|
173
|
+
if (isPlainObject(value)) {
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const k of Object.keys(value)) {
|
|
176
|
+
if (FORBIDDEN_KEYS.has(k)) throw err("SDUI_FORBIDDEN_KEY", `forbidden key "${k}"`);
|
|
177
|
+
out[k] = cloneJson(value[k], depth + 1);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
/** Recursive RFC 7396 merge (internal; depth-guarded, prototype-pollution-safe). */
|
|
184
|
+
function mergePatch(target, patch, depth) {
|
|
185
|
+
if (depth > MAX_PATCH_DEPTH) throw err("SDUI_PATCH_INVALID", "patch exceeds max depth");
|
|
186
|
+
if (!isPlainObject(patch)) return cloneJson(patch, depth);
|
|
187
|
+
const base = isPlainObject(target) ? target : {};
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const k of Object.keys(base)) {
|
|
190
|
+
if (FORBIDDEN_KEYS.has(k)) throw err("SDUI_FORBIDDEN_KEY", `forbidden key "${k}"`);
|
|
191
|
+
if (!Object.hasOwn(patch, k)) out[k] = cloneJson(base[k], depth + 1);
|
|
192
|
+
}
|
|
193
|
+
for (const k of Object.keys(patch)) {
|
|
194
|
+
if (FORBIDDEN_KEYS.has(k)) throw err("SDUI_FORBIDDEN_KEY", `forbidden key "${k}"`);
|
|
195
|
+
const pv = patch[k];
|
|
196
|
+
if (pv === null) continue;
|
|
197
|
+
out[k] = mergePatch(isPlainObject(base) ? base[k] : void 0, pv, depth + 1);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Apply an RFC 7396 JSON Merge Patch, returning a new value (the input is untouched).
|
|
203
|
+
* `null` members delete keys; objects merge recursively; arrays/primitives replace.
|
|
204
|
+
* Prototype-pollution keys are rejected.
|
|
205
|
+
*/
|
|
206
|
+
function applyMergePatch(target, patch) {
|
|
207
|
+
return mergePatch(target, patch, 0);
|
|
208
|
+
}
|
|
209
|
+
/** Parse an RFC 6901 JSON Pointer into its decoded tokens; reject dangerous segments. */
|
|
210
|
+
function parsePointer(path) {
|
|
211
|
+
if (path === "") return [];
|
|
212
|
+
if (!path.startsWith("/")) throw err("SDUI_PATCH_INVALID", `invalid JSON Pointer "${path}"`);
|
|
213
|
+
return path.slice(1).split("/").map((raw) => {
|
|
214
|
+
const token = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
215
|
+
if (FORBIDDEN_KEYS.has(token)) throw err("SDUI_FORBIDDEN_KEY", `forbidden pointer segment "${token}"`);
|
|
216
|
+
return token;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/** Parse + bounds-check an array-index pointer token (`allowEnd` permits `== length` for add). */
|
|
220
|
+
function toArrayIndex(token, length, allowEnd) {
|
|
221
|
+
if (!/^\d+$/.test(token)) throw err("SDUI_PATCH_INVALID", `invalid array index "${token}"`);
|
|
222
|
+
const idx = Number(token);
|
|
223
|
+
if (idx > length || !allowEnd && idx >= length) throw err("SDUI_PATCH_INVALID", `array index ${idx} out of range`);
|
|
224
|
+
return idx;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Apply a safe RFC 6902 patch (`add`/`remove`/`replace` only) to `doc`, returning a new
|
|
228
|
+
* value (the input is untouched). `move`/`copy`/`test` are intentionally unsupported and
|
|
229
|
+
* throw. Prototype-pollution keys/segments are rejected. The result must still be
|
|
230
|
+
* re-validated via {@link compileSdui} before render.
|
|
231
|
+
*/
|
|
232
|
+
function applyJsonPatch(doc, ops) {
|
|
233
|
+
if (!Array.isArray(ops)) throw err("SDUI_PATCH_INVALID", "ops must be an array");
|
|
234
|
+
let root = cloneJson(doc);
|
|
235
|
+
for (const op of ops) {
|
|
236
|
+
if (!isPlainObject(op) || typeof op.path !== "string") throw err("SDUI_PATCH_INVALID", "each op must be an object with a string path");
|
|
237
|
+
if (op.op !== "add" && op.op !== "remove" && op.op !== "replace") throw err("SDUI_PATCH_INVALID", `unsupported op "${String(op.op)}"`);
|
|
238
|
+
if (op.op !== "remove" && op.value === void 0) throw err("SDUI_PATCH_INVALID", `op "${op.op}" requires a value`);
|
|
239
|
+
const tokens = parsePointer(op.path);
|
|
240
|
+
if (tokens.length === 0) {
|
|
241
|
+
if (op.op === "remove") throw err("SDUI_PATCH_INVALID", "cannot remove the whole document");
|
|
242
|
+
root = cloneJson(op.value);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
let parent = root;
|
|
246
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
247
|
+
const token = tokens[i];
|
|
248
|
+
if (Array.isArray(parent)) parent = parent[toArrayIndex(token, parent.length, false)];
|
|
249
|
+
else if (isPlainObject(parent) && Object.hasOwn(parent, token)) parent = parent[token];
|
|
250
|
+
else throw err("SDUI_PATCH_INVALID", `path not found: ${op.path}`);
|
|
251
|
+
}
|
|
252
|
+
const last = tokens[tokens.length - 1];
|
|
253
|
+
if (Array.isArray(parent)) if (op.op === "add") {
|
|
254
|
+
const idx = last === "-" ? parent.length : toArrayIndex(last, parent.length, true);
|
|
255
|
+
parent.splice(idx, 0, cloneJson(op.value));
|
|
256
|
+
} else {
|
|
257
|
+
const idx = toArrayIndex(last, parent.length, false);
|
|
258
|
+
if (op.op === "replace") parent[idx] = cloneJson(op.value);
|
|
259
|
+
else parent.splice(idx, 1);
|
|
260
|
+
}
|
|
261
|
+
else if (isPlainObject(parent)) {
|
|
262
|
+
if (FORBIDDEN_KEYS.has(last)) throw err("SDUI_FORBIDDEN_KEY", `forbidden key "${last}"`);
|
|
263
|
+
if (op.op === "remove" || op.op === "replace") {
|
|
264
|
+
if (!Object.hasOwn(parent, last)) throw err("SDUI_PATCH_INVALID", `target not found: ${op.path}`);
|
|
265
|
+
}
|
|
266
|
+
if (op.op === "remove") delete parent[last];
|
|
267
|
+
else parent[last] = cloneJson(op.value);
|
|
268
|
+
} else throw err("SDUI_PATCH_INVALID", `cannot apply at ${op.path}`);
|
|
269
|
+
}
|
|
270
|
+
return root;
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
export { SduiError, applyJsonPatch, applyMergePatch, compileSdui };
|
|
274
|
+
|
|
275
|
+
//# sourceMappingURL=sdui.js.map
|
package/dist/sdui.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sdui.js","names":[],"sources":["../src/sdui.ts"],"sourcesContent":["/**\n * Pulse **server-driven UI (SDUI)** — ship UI as *data* over OTA and render it through\n * `@mindees/core`'s `createElement` + signals. The headline risk is injection, so this\n * module is **allowlist-first** and **never evaluates** any transported string:\n *\n * - {@link compileSdui} validates an untrusted {@link SduiNode} tree (fail-closed, like\n * `parseManifest`) against an injected {@link SduiRegistry} allowlist and compiles it\n * to a `MindeesNode`. Unknown tags/actions, missing bindings, limit breaches, and\n * dangerous keys all throw {@link SduiError}.\n * - Named **actions** (`{ \"$action\": \"name\", \"args\"? }`) compile to a function calling a\n * pre-registered handler; **bindings** (`{ \"$bind\": \"path\" }`) resolve to a value or a\n * `() => value` accessor (a reactive region). Neither ever transports code.\n * - {@link applyMergePatch} (RFC 7396) and {@link applyJsonPatch} (a safe RFC 6902\n * subset — `add`/`remove`/`replace`) patch the JSON tree; the result MUST be re-run\n * through {@link compileSdui} before render, so a delta can never bypass the allowlist.\n *\n * Exported from the **`@mindees/updates/sdui`** subpath; depends only on `@mindees/core`\n * (the renderer is an optional peer the consumer mounts). See\n * `docs/adr/0011-pulse-sdui.md`.\n *\n * @module\n */\n\nimport { type Component, createElement, type MindeesNode } from '@mindees/core'\n\n/** A plain JSON value. SDUI markers (`$action`/`$bind`) are never interpreted inside one. */\nexport type SduiJson = string | number | boolean | null | SduiJson[] | { [key: string]: SduiJson }\n\n/** A reference to a pre-registered action handler (a prop's direct value). */\nexport interface SduiActionRef {\n readonly $action: string\n readonly args?: SduiJson\n}\n\n/** A reference to a data binding resolved at compile time (a prop's direct value). */\nexport interface SduiBindRef {\n readonly $bind: string\n}\n\n/** A compiled prop's source value: plain JSON, or an action/bind marker. */\nexport type SduiPropValue = SduiJson | SduiActionRef | SduiBindRef\n\n/** A server-driven UI node: a versioned, allowlisted element description. */\nexport interface SduiNode {\n readonly schema: 1\n readonly tag: string\n readonly props?: Readonly<Record<string, SduiPropValue>>\n readonly children?: ReadonlyArray<SduiNode | string>\n readonly key?: string\n}\n\n/** A registered action handler. `args` come from the node; `event` from the renderer. */\nexport type SduiActionHandler = (args: SduiJson | undefined, ...event: unknown[]) => unknown\n\n/** Hard caps that bound a malicious or runaway payload. */\nexport interface SduiLimits {\n readonly maxDepth: number\n readonly maxNodes: number\n readonly maxStringLength: number\n readonly maxProps: number\n}\n\n/** Injected allowlist + handlers + bindings for {@link compileSdui}. */\nexport interface SduiRegistry {\n /** Allowlist: SDUI tag → a host-tag string or a {@link Component}. Unknown tags are rejected. */\n readonly components: Readonly<Record<string, string | Component<never>>>\n /** Named action handlers (resolved for `{ $action }` props). */\n readonly actions?: Readonly<Record<string, SduiActionHandler>>\n /** Resolver for `{ $bind }` props — may return a `() => value` accessor for reactivity. */\n readonly bindings?: (path: string) => unknown\n /** Overrides for the default {@link SduiLimits}. */\n readonly limits?: Partial<SduiLimits>\n}\n\n/** Stable code identifying why an SDUI operation failed. */\nexport type SduiErrorCode =\n | 'SDUI_INVALID'\n | 'SDUI_UNKNOWN_TAG'\n | 'SDUI_UNKNOWN_ACTION'\n | 'SDUI_NO_BINDINGS'\n | 'SDUI_LIMIT'\n | 'SDUI_FORBIDDEN_KEY'\n | 'SDUI_PATCH_INVALID'\n\n/** An SDUI error carrying a stable {@link SduiErrorCode}. */\nexport class SduiError extends Error {\n readonly code: SduiErrorCode\n constructor(code: SduiErrorCode, message: string) {\n super(message)\n this.name = 'SduiError'\n this.code = code\n }\n}\n\nconst DEFAULT_LIMITS: SduiLimits = {\n maxDepth: 50,\n maxNodes: 5000,\n maxStringLength: 100_000,\n maxProps: 100,\n}\n\n/** Keys that must never be set from untrusted input (prototype-pollution vectors). */\nconst FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])\nconst NODE_KEYS = new Set(['schema', 'tag', 'props', 'children', 'key'])\n\n/** Build an {@link SduiError} with a stable code. */\nfunction err(code: SduiErrorCode, message: string): SduiError {\n return new SduiError(code, message)\n}\n\n/** Narrow to a plain (non-array, non-null) object. */\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Validate an untrusted SDUI tree against `registry` and compile it to a `MindeesNode`.\n * Throws {@link SduiError} on any violation (fail closed).\n */\nexport function compileSdui(node: unknown, registry: SduiRegistry): MindeesNode {\n const limits: SduiLimits = { ...DEFAULT_LIMITS, ...registry.limits }\n let nodeCount = 0\n\n const checkString = (s: string): void => {\n if (s.length > limits.maxStringLength) {\n throw err('SDUI_LIMIT', `string exceeds max length ${limits.maxStringLength}`)\n }\n }\n\n // Deep-validate + clone plain JSON. Markers are NOT interpreted here (only data).\n // Every value counts against the shared node budget so a single node's props/args\n // can't carry an unbounded payload (DoS amplification).\n const compileJson = (value: unknown, depth: number): SduiJson => {\n if (depth > limits.maxDepth) throw err('SDUI_LIMIT', 'value exceeds max depth')\n if (++nodeCount > limits.maxNodes)\n throw err('SDUI_LIMIT', `payload exceeds max nodes ${limits.maxNodes}`)\n if (value === null) return null\n const t = typeof value\n if (t === 'string') {\n checkString(value as string)\n return value as string\n }\n if (t === 'number') {\n if (!Number.isFinite(value)) throw err('SDUI_INVALID', 'numbers must be finite')\n return value as number\n }\n if (t === 'boolean') return value as boolean\n if (Array.isArray(value)) return value.map((v) => compileJson(v, depth + 1))\n if (isPlainObject(value)) {\n const keys = Object.keys(value)\n if (keys.length > limits.maxProps)\n throw err('SDUI_LIMIT', `object exceeds max keys ${limits.maxProps}`)\n const out: Record<string, SduiJson> = {}\n for (const k of keys) {\n if (FORBIDDEN_KEYS.has(k)) throw err('SDUI_FORBIDDEN_KEY', `forbidden key \"${k}\"`)\n out[k] = compileJson(value[k], depth + 1)\n }\n return out\n }\n throw err('SDUI_INVALID', `unsupported value of type ${t}`)\n }\n\n // Compile a prop's DIRECT value: action/bind markers are recognized only here, never\n // inside args or nested data (so untrusted data cannot promote itself to a handler).\n const compilePropValue = (value: unknown): unknown => {\n if (isPlainObject(value) && Object.hasOwn(value, '$action')) {\n const keys = Object.keys(value)\n if (keys.some((k) => k !== '$action' && k !== 'args')) {\n throw err('SDUI_INVALID', 'an $action ref may only contain $action and args')\n }\n const name = value.$action\n if (typeof name !== 'string') throw err('SDUI_INVALID', '$action must be a string')\n const handler =\n registry.actions && Object.hasOwn(registry.actions, name)\n ? registry.actions[name]\n : undefined\n if (typeof handler !== 'function') {\n throw err('SDUI_UNKNOWN_ACTION', `action \"${name}\" is not registered`)\n }\n const args = value.args !== undefined ? compileJson(value.args, 0) : undefined\n return (...event: unknown[]) => handler(args, ...event)\n }\n if (isPlainObject(value) && Object.hasOwn(value, '$bind')) {\n const keys = Object.keys(value)\n if (keys.some((k) => k !== '$bind'))\n throw err('SDUI_INVALID', 'a $bind ref may only contain $bind')\n if (typeof value.$bind !== 'string') throw err('SDUI_INVALID', '$bind must be a string')\n checkString(value.$bind)\n if (!registry.bindings)\n throw err('SDUI_NO_BINDINGS', `$bind \"${value.$bind}\" but no bindings provider`)\n return registry.bindings(value.$bind)\n }\n return compileJson(value, 0)\n }\n\n const compileProps = (rawProps: unknown): Record<string, unknown> => {\n const out: Record<string, unknown> = Object.create(null)\n if (rawProps === undefined) return out\n if (!isPlainObject(rawProps)) throw err('SDUI_INVALID', 'node.props must be an object')\n const keys = Object.keys(rawProps)\n if (keys.length > limits.maxProps) {\n throw err('SDUI_LIMIT', `node has more than ${limits.maxProps} props`)\n }\n for (const key of keys) {\n if (FORBIDDEN_KEYS.has(key)) throw err('SDUI_FORBIDDEN_KEY', `forbidden prop key \"${key}\"`)\n // `key` and `children` are structural: they must come through the validated\n // node.key (string) / node.children paths, never an arbitrary-typed prop value.\n if (key === 'key' || key === 'children') {\n throw err('SDUI_INVALID', `\"${key}\" is reserved — use node.${key}, not a prop`)\n }\n out[key] = compilePropValue(rawProps[key])\n }\n return out\n }\n\n const compileNode = (raw: unknown, depth: number): MindeesNode => {\n if (depth > limits.maxDepth)\n throw err('SDUI_LIMIT', `tree exceeds max depth ${limits.maxDepth}`)\n if (++nodeCount > limits.maxNodes)\n throw err('SDUI_LIMIT', `tree exceeds max nodes ${limits.maxNodes}`)\n if (!isPlainObject(raw)) throw err('SDUI_INVALID', 'node must be an object')\n for (const k of Object.keys(raw)) {\n if (!NODE_KEYS.has(k)) throw err('SDUI_INVALID', `unknown node key \"${k}\"`)\n }\n if (raw.schema !== 1) throw err('SDUI_INVALID', 'node.schema must be 1')\n if (typeof raw.tag !== 'string' || raw.tag.length === 0) {\n throw err('SDUI_INVALID', 'node.tag must be a non-empty string')\n }\n if (!Object.hasOwn(registry.components, raw.tag)) {\n throw err('SDUI_UNKNOWN_TAG', `tag \"${raw.tag}\" is not in the component allowlist`)\n }\n const component = registry.components[raw.tag]\n if (component === undefined) {\n throw err('SDUI_UNKNOWN_TAG', `tag \"${raw.tag}\" is not in the component allowlist`)\n }\n\n const props = compileProps(raw.props)\n if (raw.key !== undefined) {\n if (typeof raw.key !== 'string') throw err('SDUI_INVALID', 'node.key must be a string')\n checkString(raw.key)\n props.key = raw.key\n }\n\n const children: MindeesNode[] = []\n if (raw.children !== undefined) {\n if (!Array.isArray(raw.children)) throw err('SDUI_INVALID', 'node.children must be an array')\n for (const child of raw.children) {\n if (typeof child === 'string') {\n // String children also count against the node budget (so a wide child list\n // can't slip past maxNodes and overflow the argument spread below).\n if (++nodeCount > limits.maxNodes) {\n throw err('SDUI_LIMIT', `tree exceeds max nodes ${limits.maxNodes}`)\n }\n checkString(child)\n children.push(child)\n } else {\n children.push(compileNode(child, depth + 1))\n }\n }\n }\n\n // The child count is bounded by maxNodes above; the try/catch is a fail-closed net\n // so even a pathological config surfaces as SduiError, never an uncatchable RangeError.\n try {\n return createElement(component, props, ...children)\n } catch (e) {\n if (e instanceof RangeError) throw err('SDUI_LIMIT', 'too many children to construct')\n throw e\n }\n }\n\n return compileNode(node, 0)\n}\n\n// ---------------------------------------------------------------------------\n// Incremental updates (the result MUST be re-validated via compileSdui)\n// ---------------------------------------------------------------------------\n\n/** Recursion cap for the patch helpers — well below a native stack overflow, generous for real JSON. */\nconst MAX_PATCH_DEPTH = 1000\n\n/** Deep-clone a JSON value, rejecting any prototype-pollution keys (fail closed). */\nfunction cloneJson(value: SduiJson, depth = 0): SduiJson {\n if (depth > MAX_PATCH_DEPTH) throw err('SDUI_PATCH_INVALID', 'value exceeds max depth')\n if (Array.isArray(value)) return value.map((v) => cloneJson(v, depth + 1))\n if (isPlainObject(value)) {\n const out: Record<string, SduiJson> = {}\n for (const k of Object.keys(value)) {\n if (FORBIDDEN_KEYS.has(k)) throw err('SDUI_FORBIDDEN_KEY', `forbidden key \"${k}\"`)\n out[k] = cloneJson(value[k] as SduiJson, depth + 1)\n }\n return out\n }\n return value\n}\n\n/** Recursive RFC 7396 merge (internal; depth-guarded, prototype-pollution-safe). */\nfunction mergePatch(target: SduiJson | undefined, patch: SduiJson, depth: number): SduiJson {\n if (depth > MAX_PATCH_DEPTH) throw err('SDUI_PATCH_INVALID', 'patch exceeds max depth')\n if (!isPlainObject(patch)) return cloneJson(patch, depth)\n const base = isPlainObject(target) ? target : {}\n const out: Record<string, SduiJson> = {}\n for (const k of Object.keys(base)) {\n // Reject prototype-pollution keys on the BASE side too (not just the patch).\n // An own `__proto__` key (e.g. from JSON.parse of the prior OTA doc) would\n // otherwise hit the Object.prototype setter via `out[k] = ...` and corrupt the\n // returned object's prototype — mirroring the guard in the patch loop below.\n if (FORBIDDEN_KEYS.has(k)) throw err('SDUI_FORBIDDEN_KEY', `forbidden key \"${k}\"`)\n if (!Object.hasOwn(patch, k)) out[k] = cloneJson(base[k] as SduiJson, depth + 1)\n }\n for (const k of Object.keys(patch)) {\n if (FORBIDDEN_KEYS.has(k)) throw err('SDUI_FORBIDDEN_KEY', `forbidden key \"${k}\"`)\n const pv = patch[k] as SduiJson\n if (pv === null) continue // null deletes the key (RFC 7396)\n out[k] = mergePatch(isPlainObject(base) ? (base[k] as SduiJson) : undefined, pv, depth + 1)\n }\n return out\n}\n\n/**\n * Apply an RFC 7396 JSON Merge Patch, returning a new value (the input is untouched).\n * `null` members delete keys; objects merge recursively; arrays/primitives replace.\n * Prototype-pollution keys are rejected.\n */\nexport function applyMergePatch(target: SduiJson, patch: SduiJson): SduiJson {\n return mergePatch(target, patch, 0)\n}\n\n/** One operation of the safe RFC 6902 subset (`add` / `remove` / `replace`). */\nexport interface JsonPatchOp {\n readonly op: 'add' | 'remove' | 'replace'\n readonly path: string\n readonly value?: SduiJson\n}\n\n/** Parse an RFC 6901 JSON Pointer into its decoded tokens; reject dangerous segments. */\nfunction parsePointer(path: string): string[] {\n if (path === '') return []\n if (!path.startsWith('/')) throw err('SDUI_PATCH_INVALID', `invalid JSON Pointer \"${path}\"`)\n return path\n .slice(1)\n .split('/')\n .map((raw) => {\n const token = raw.replace(/~1/g, '/').replace(/~0/g, '~')\n if (FORBIDDEN_KEYS.has(token))\n throw err('SDUI_FORBIDDEN_KEY', `forbidden pointer segment \"${token}\"`)\n return token\n })\n}\n\n/** Parse + bounds-check an array-index pointer token (`allowEnd` permits `== length` for add). */\nfunction toArrayIndex(token: string, length: number, allowEnd: boolean): number {\n if (!/^\\d+$/.test(token)) throw err('SDUI_PATCH_INVALID', `invalid array index \"${token}\"`)\n const idx = Number(token)\n if (idx > length || (!allowEnd && idx >= length)) {\n throw err('SDUI_PATCH_INVALID', `array index ${idx} out of range`)\n }\n return idx\n}\n\n/**\n * Apply a safe RFC 6902 patch (`add`/`remove`/`replace` only) to `doc`, returning a new\n * value (the input is untouched). `move`/`copy`/`test` are intentionally unsupported and\n * throw. Prototype-pollution keys/segments are rejected. The result must still be\n * re-validated via {@link compileSdui} before render.\n */\nexport function applyJsonPatch(doc: SduiJson, ops: readonly JsonPatchOp[]): SduiJson {\n if (!Array.isArray(ops)) throw err('SDUI_PATCH_INVALID', 'ops must be an array')\n let root = cloneJson(doc)\n for (const op of ops) {\n // Fail closed (stable SduiError) on a malformed op envelope, not a raw TypeError.\n if (!isPlainObject(op) || typeof op.path !== 'string') {\n throw err('SDUI_PATCH_INVALID', 'each op must be an object with a string path')\n }\n if (op.op !== 'add' && op.op !== 'remove' && op.op !== 'replace') {\n throw err('SDUI_PATCH_INVALID', `unsupported op \"${String((op as { op: unknown }).op)}\"`)\n }\n if (op.op !== 'remove' && op.value === undefined) {\n throw err('SDUI_PATCH_INVALID', `op \"${op.op}\" requires a value`)\n }\n const tokens = parsePointer(op.path)\n\n if (tokens.length === 0) {\n if (op.op === 'remove') throw err('SDUI_PATCH_INVALID', 'cannot remove the whole document')\n root = cloneJson(op.value as SduiJson)\n continue\n }\n\n // Navigate to the parent of the target (the doc was already deep-cloned).\n let parent: SduiJson = root\n for (let i = 0; i < tokens.length - 1; i++) {\n const token = tokens[i] as string\n if (Array.isArray(parent)) {\n parent = parent[toArrayIndex(token, parent.length, false)] as SduiJson\n } else if (isPlainObject(parent) && Object.hasOwn(parent, token)) {\n parent = parent[token] as SduiJson\n } else {\n throw err('SDUI_PATCH_INVALID', `path not found: ${op.path}`)\n }\n }\n\n const last = tokens[tokens.length - 1] as string\n if (Array.isArray(parent)) {\n if (op.op === 'add') {\n const idx = last === '-' ? parent.length : toArrayIndex(last, parent.length, true)\n parent.splice(idx, 0, cloneJson(op.value as SduiJson))\n } else {\n const idx = toArrayIndex(last, parent.length, false)\n if (op.op === 'replace') parent[idx] = cloneJson(op.value as SduiJson)\n else parent.splice(idx, 1)\n }\n } else if (isPlainObject(parent)) {\n if (FORBIDDEN_KEYS.has(last)) throw err('SDUI_FORBIDDEN_KEY', `forbidden key \"${last}\"`)\n if (op.op === 'remove' || op.op === 'replace') {\n if (!Object.hasOwn(parent, last)) {\n throw err('SDUI_PATCH_INVALID', `target not found: ${op.path}`)\n }\n }\n if (op.op === 'remove') delete parent[last]\n else parent[last] = cloneJson(op.value as SduiJson)\n } else {\n throw err('SDUI_PATCH_INVALID', `cannot apply at ${op.path}`)\n }\n }\n return root\n}\n"],"mappings":"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"}
|