@markii/runtime 0.1.0 → 0.2.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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The closed, runtime-owned failure taxonomy for a script execution outcome
3
+ * (DESIGN.md §8). This is the ONE vocabulary every concrete `ScriptExecutor`
4
+ * (e.g. `@markii/lua`'s `createLuaExecutor`) is expected to map its own
5
+ * language-specific failure shape down to, and the one vocabulary
6
+ * `@markii/react` (and any other renderer) is expected to branch its
7
+ * presentation on. No UI text lives in this package — `normalizeFailureKind`
8
+ * only classifies; a renderer decides what each kind is called on screen.
9
+ *
10
+ * - `'script-error'` — the script itself threw, had a syntax error, or
11
+ * returned a value that couldn't be marshaled back out. The default/
12
+ * fallback bucket: anything not clearly one of the other three lands here,
13
+ * INCLUDING a value this package cannot trust (see `normalizeFailureKind`).
14
+ * - `'capability-denied'` — the grant was absent, or the host actively
15
+ * refused (an ungranted net host, a bundle path-jail rejection, a
16
+ * fetch-size cap). The script asked for something it was never allowed.
17
+ * - `'tier-blocked'` — the capability genuinely exists in the granted
18
+ * set, but the CURRENT execution tier forbids exercising it (an effectful
19
+ * op — `net.post`, `bundle.write` — attempted under the read-only
20
+ * `'auto'`/`'scheduled'` tier). Distinct from `'capability-denied'`: a
21
+ * manual run of the exact same script, with the exact same grants, would
22
+ * succeed.
23
+ * - `'limit'` — a resource limit was breached: instruction
24
+ * count, wall-clock, or memory. The run was killed, not refused.
25
+ */
26
+ export type FailureKind = 'script-error' | 'capability-denied' | 'tier-blocked' | 'limit';
27
+ /** Every valid `FailureKind`, for exhaustive iteration/validation. Keep in sync with the `FailureKind` union by construction — see `normalizeFailureKind`'s runtime check against this exact tuple. */
28
+ export declare const FAILURE_KINDS: readonly ["script-error", "capability-denied", "tier-blocked", "limit"];
29
+ /**
30
+ * Normalizes an arbitrary, UNTRUSTED value into a `FailureKind`. This is the
31
+ * boundary guard for the whole taxonomy: `value` may be anything an
32
+ * executor — third-party code at runtime even when it's typed at compile
33
+ * time — chooses to hand back, including a forged string an untrusted
34
+ * executor supplied, a prototype-pollution attempt (`'__proto__'`,
35
+ * `'constructor'`), a stale/renamed kind from an older version of some
36
+ * executor, `undefined`, a number, or an object. ANYTHING not exactly one of
37
+ * `FAILURE_KINDS` normalizes to `'script-error'` — the safest default, since
38
+ * an unrecognized failure is treated as "the script did something wrong",
39
+ * never silently upgraded to a more privileged-sounding category like
40
+ * `'tier-blocked'` or `'capability-denied'`.
41
+ *
42
+ * Never throws.
43
+ */
44
+ export declare function normalizeFailureKind(value: unknown): FailureKind;
@@ -0,0 +1,28 @@
1
+ /** Every valid `FailureKind`, for exhaustive iteration/validation. Keep in sync with the `FailureKind` union by construction — see `normalizeFailureKind`'s runtime check against this exact tuple. */
2
+ export const FAILURE_KINDS = [
3
+ 'script-error',
4
+ 'capability-denied',
5
+ 'tier-blocked',
6
+ 'limit',
7
+ ];
8
+ /**
9
+ * Normalizes an arbitrary, UNTRUSTED value into a `FailureKind`. This is the
10
+ * boundary guard for the whole taxonomy: `value` may be anything an
11
+ * executor — third-party code at runtime even when it's typed at compile
12
+ * time — chooses to hand back, including a forged string an untrusted
13
+ * executor supplied, a prototype-pollution attempt (`'__proto__'`,
14
+ * `'constructor'`), a stale/renamed kind from an older version of some
15
+ * executor, `undefined`, a number, or an object. ANYTHING not exactly one of
16
+ * `FAILURE_KINDS` normalizes to `'script-error'` — the safest default, since
17
+ * an unrecognized failure is treated as "the script did something wrong",
18
+ * never silently upgraded to a more privileged-sounding category like
19
+ * `'tier-blocked'` or `'capability-denied'`.
20
+ *
21
+ * Never throws.
22
+ */
23
+ export function normalizeFailureKind(value) {
24
+ return typeof value === 'string' &&
25
+ FAILURE_KINDS.includes(value)
26
+ ? value
27
+ : 'script-error';
28
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * DESIGN.md §10 ("Security model"): "Grants are remembered per note, keyed
3
+ * by a hash of the note's full *executable closure* — its inline scripts,
4
+ * `src=` script files, required bundle-local modules, vault-library
5
+ * modules, and the versions of any pack modules it requires. If any of that
6
+ * code changes, the grant is stale and the host re-prompts; otherwise
7
+ * edited shared code would silently inherit grants that were made to
8
+ * different code."
9
+ *
10
+ * This module is that hash. It is deliberately inert: nothing here parses
11
+ * markdown, reads a file, or touches the network — the host (the piece that
12
+ * already knows how to walk a note's scripts, resolve its `src=` files,
13
+ * follow its `require`s into the bundle and the vault library, and read the
14
+ * installed pack manifest) assembles a `GrantClosure` and hands it in.
15
+ * `@markii/core`'s `ScriptBlock` is NOT imported here on purpose — this
16
+ * package stays independent of the parser layer (see CLAUDE.md's import
17
+ * rule); `GrantClosureScript` below is a local structural type that mirrors
18
+ * the fields that matter to the closure.
19
+ *
20
+ * ## Canonical serialization ("markii-grant-key/1")
21
+ *
22
+ * `computeGrantKey` serializes a `GrantClosure` to a single byte string and
23
+ * returns the lowercase hex SHA-256 digest of those bytes. The byte string
24
+ * is built as follows, and a second implementation (in any language) that
25
+ * follows this precisely reproduces byte-identical output for the same
26
+ * closure:
27
+ *
28
+ * Primitives:
29
+ * - `u32(n)`: 4 bytes, big-endian unsigned.
30
+ * - `str(s)`: `u32(byteLength)` followed by `s`'s UTF-8 bytes, where
31
+ * `byteLength` is the UTF-8 *byte* length (as `TextEncoder` would
32
+ * produce), never the string's UTF-16 code-unit `.length`. Every string
33
+ * in the closure is framed this way — a length prefix, not a delimiter —
34
+ * specifically so no delimiter character embedded in a name, path, or
35
+ * source text can ever be mistaken for a field or section boundary.
36
+ * - `opt(s)`: one tag byte — `0x00` if `s` is `undefined`, `0x01` followed
37
+ * by `str(s)` if it is present. This is what makes an absent optional
38
+ * field distinguishable from one holding `""` (`opt(undefined)` is one
39
+ * byte; `opt("")` is `0x01` + `u32(0)`, five bytes).
40
+ *
41
+ * A *record* is the concatenation of its fields' encodings, in the fixed
42
+ * field order given below — records never carry their own outer length
43
+ * prefix, because their field-level length prefixes already make the byte
44
+ * stream self-delimiting given the record's known schema.
45
+ *
46
+ * A *set* of same-shaped records (used for anything that is conceptually
47
+ * unordered — an array of scripts, the entries of a `Record<string, …>`
48
+ * map) is encoded as `u32(count)` followed by each record's bytes, IN
49
+ * ASCENDING ORDER OF THE RECORD'S OWN ENCODED BYTES (lexicographic,
50
+ * shorter-is-less on equal prefix). Sorting by encoded bytes rather than by
51
+ * some "obvious" key (a name, a path) is what makes the digest depend only
52
+ * on the closure's *content*, never on the order the host happened to
53
+ * collect scripts in, or a `Record`'s key iteration order.
54
+ *
55
+ * A *section* is `tag(1 byte)` + a set, where `tag` is a fixed per-section
56
+ * constant (`0x01` scripts, `0x02` bundle modules, `0x03` vault modules,
57
+ * `0x04` packs). The tag exists so that two structurally different record
58
+ * shapes (e.g. a 2-field module record vs. a 4-field script record) can
59
+ * never be confused for each other even if some pathological input made
60
+ * their encoded byte counts coincide.
61
+ *
62
+ * The whole closure is:
63
+ *
64
+ * ```
65
+ * str("markii-grant-key/1") // scheme/version marker
66
+ * section(0x01, scripts) // GrantClosureScript records:
67
+ * // str(name) str(lang) opt(src) str(code)
68
+ * section(0x02, bundleModules) // module records: str(path) str(source)
69
+ * section(0x03, vaultModules) // namespace records:
70
+ * // str(namespace) + moduleSet
71
+ * // (moduleSet = u32(count) + sorted
72
+ * // module records: str(path) str(source);
73
+ * // note: no section tag inside — it's
74
+ * // nested in an already-tagged section)
75
+ * section(0x04, packs) // pack records:
76
+ * // str(namespace) str(version) opt-flag(1 byte)
77
+ * // + moduleSet only when the flag is 0x01
78
+ * ```
79
+ *
80
+ * `SHA-256(bytes)`, rendered as 64 lowercase hex characters, is the grant
81
+ * key. Bumping `markii-grant-key/1` to `/2` (or later) is how a future
82
+ * change to this scheme is made explicit and visible — any consumer keying
83
+ * off the literal string sees a different marker rather than a silent
84
+ * reinterpretation of old digests.
85
+ */
86
+ /**
87
+ * One of the note's own script blocks — the inline `` ```lang {name=...}
88
+ * `` `` fences and the `src=`-referenced ones, in any order. Mirrors the
89
+ * fields of `@markii/core`'s `ScriptBlock` that are part of what actually
90
+ * *executes* (not `publish` or `position`, which don't change what code
91
+ * runs). Deliberately a local type — see this module's top doc comment for
92
+ * why `@markii/core` is never imported here.
93
+ */
94
+ export interface GrantClosureScript {
95
+ /** The script fence's `name=` attribute — the value-store key it writes to. */
96
+ name: string;
97
+ /** The fence's language tag (e.g. `"lua"`), or `""` if the fence had none. */
98
+ lang: string;
99
+ /**
100
+ * Bundle-relative path when this block is a `src=` reference to a
101
+ * long-script file; `undefined` for an inline block. The referenced
102
+ * file's own source text is NOT here — it belongs in `bundleModules`,
103
+ * keyed by this same path, so its content participates in the closure
104
+ * too.
105
+ */
106
+ src?: string;
107
+ /**
108
+ * The fence's own body text. Empty (`""`) for a `src=` reference, whose
109
+ * code lives in the referenced file instead.
110
+ */
111
+ code: string;
112
+ }
113
+ /**
114
+ * One installed pack's identity, plus its module sources when the host has
115
+ * them. A pack's `version` alone must move the key even if the host never
116
+ * fetched its source — DESIGN.md §10 keys the grant to "the versions of any
117
+ * pack modules it requires", not only to code the host happens to have
118
+ * bytes for.
119
+ */
120
+ export interface GrantClosurePack {
121
+ /** The pack's namespace (its directive-resolution prefix). */
122
+ namespace: string;
123
+ /** The installed version string (semver or otherwise). */
124
+ version: string;
125
+ /**
126
+ * Module path -> source text, when the host has resolved the pack's
127
+ * module sources. Omit the field entirely (not `{}`) when the host only
128
+ * knows the pack's identity, not its code — an empty map and "no map"
129
+ * are different closures and must hash differently.
130
+ */
131
+ modules?: Record<string, string>;
132
+ }
133
+ /**
134
+ * The full executable closure a grant is keyed to (DESIGN.md §10). Every
135
+ * field here must be populated by the host from whatever it has already
136
+ * resolved; `computeGrantKey` does no resolution of its own.
137
+ */
138
+ export interface GrantClosure {
139
+ /** The note's own script blocks — inline and `src=`-referenced, in any order. */
140
+ scripts: GrantClosureScript[];
141
+ /**
142
+ * Bundle-relative script-file path -> source text, for every bundle-local
143
+ * module the closure requires: `src=` targets, and any modules those
144
+ * files (transitively) `require`.
145
+ */
146
+ bundleModules: Record<string, string>;
147
+ /**
148
+ * Vault-library module sources, keyed by vault namespace, then by module
149
+ * path -> source text.
150
+ */
151
+ vaultModules: Record<string, Record<string, string>>;
152
+ /** Installed pack modules the closure requires. */
153
+ packs: GrantClosurePack[];
154
+ }
155
+ /**
156
+ * Computes the grant key for a note's executable closure (DESIGN.md §10):
157
+ * the lowercase hex SHA-256 digest of the closure's canonical serialization
158
+ * — see this module's top doc comment for the exact byte form. Pure and
159
+ * side-effect free: this function does not parse, fetch, or read anything;
160
+ * the host must have already assembled `closure` from the note's scripts,
161
+ * resolved `src=`/`require` targets, and installed pack manifest.
162
+ *
163
+ * Uses `globalThis.crypto.subtle` (Web Crypto — available in Node >=20 and
164
+ * every browser) rather than `node:crypto`, so this package stays
165
+ * browser-safe with no added dependency.
166
+ */
167
+ export declare function computeGrantKey(closure: GrantClosure): Promise<string>;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * DESIGN.md §10 ("Security model"): "Grants are remembered per note, keyed
3
+ * by a hash of the note's full *executable closure* — its inline scripts,
4
+ * `src=` script files, required bundle-local modules, vault-library
5
+ * modules, and the versions of any pack modules it requires. If any of that
6
+ * code changes, the grant is stale and the host re-prompts; otherwise
7
+ * edited shared code would silently inherit grants that were made to
8
+ * different code."
9
+ *
10
+ * This module is that hash. It is deliberately inert: nothing here parses
11
+ * markdown, reads a file, or touches the network — the host (the piece that
12
+ * already knows how to walk a note's scripts, resolve its `src=` files,
13
+ * follow its `require`s into the bundle and the vault library, and read the
14
+ * installed pack manifest) assembles a `GrantClosure` and hands it in.
15
+ * `@markii/core`'s `ScriptBlock` is NOT imported here on purpose — this
16
+ * package stays independent of the parser layer (see CLAUDE.md's import
17
+ * rule); `GrantClosureScript` below is a local structural type that mirrors
18
+ * the fields that matter to the closure.
19
+ *
20
+ * ## Canonical serialization ("markii-grant-key/1")
21
+ *
22
+ * `computeGrantKey` serializes a `GrantClosure` to a single byte string and
23
+ * returns the lowercase hex SHA-256 digest of those bytes. The byte string
24
+ * is built as follows, and a second implementation (in any language) that
25
+ * follows this precisely reproduces byte-identical output for the same
26
+ * closure:
27
+ *
28
+ * Primitives:
29
+ * - `u32(n)`: 4 bytes, big-endian unsigned.
30
+ * - `str(s)`: `u32(byteLength)` followed by `s`'s UTF-8 bytes, where
31
+ * `byteLength` is the UTF-8 *byte* length (as `TextEncoder` would
32
+ * produce), never the string's UTF-16 code-unit `.length`. Every string
33
+ * in the closure is framed this way — a length prefix, not a delimiter —
34
+ * specifically so no delimiter character embedded in a name, path, or
35
+ * source text can ever be mistaken for a field or section boundary.
36
+ * - `opt(s)`: one tag byte — `0x00` if `s` is `undefined`, `0x01` followed
37
+ * by `str(s)` if it is present. This is what makes an absent optional
38
+ * field distinguishable from one holding `""` (`opt(undefined)` is one
39
+ * byte; `opt("")` is `0x01` + `u32(0)`, five bytes).
40
+ *
41
+ * A *record* is the concatenation of its fields' encodings, in the fixed
42
+ * field order given below — records never carry their own outer length
43
+ * prefix, because their field-level length prefixes already make the byte
44
+ * stream self-delimiting given the record's known schema.
45
+ *
46
+ * A *set* of same-shaped records (used for anything that is conceptually
47
+ * unordered — an array of scripts, the entries of a `Record<string, …>`
48
+ * map) is encoded as `u32(count)` followed by each record's bytes, IN
49
+ * ASCENDING ORDER OF THE RECORD'S OWN ENCODED BYTES (lexicographic,
50
+ * shorter-is-less on equal prefix). Sorting by encoded bytes rather than by
51
+ * some "obvious" key (a name, a path) is what makes the digest depend only
52
+ * on the closure's *content*, never on the order the host happened to
53
+ * collect scripts in, or a `Record`'s key iteration order.
54
+ *
55
+ * A *section* is `tag(1 byte)` + a set, where `tag` is a fixed per-section
56
+ * constant (`0x01` scripts, `0x02` bundle modules, `0x03` vault modules,
57
+ * `0x04` packs). The tag exists so that two structurally different record
58
+ * shapes (e.g. a 2-field module record vs. a 4-field script record) can
59
+ * never be confused for each other even if some pathological input made
60
+ * their encoded byte counts coincide.
61
+ *
62
+ * The whole closure is:
63
+ *
64
+ * ```
65
+ * str("markii-grant-key/1") // scheme/version marker
66
+ * section(0x01, scripts) // GrantClosureScript records:
67
+ * // str(name) str(lang) opt(src) str(code)
68
+ * section(0x02, bundleModules) // module records: str(path) str(source)
69
+ * section(0x03, vaultModules) // namespace records:
70
+ * // str(namespace) + moduleSet
71
+ * // (moduleSet = u32(count) + sorted
72
+ * // module records: str(path) str(source);
73
+ * // note: no section tag inside — it's
74
+ * // nested in an already-tagged section)
75
+ * section(0x04, packs) // pack records:
76
+ * // str(namespace) str(version) opt-flag(1 byte)
77
+ * // + moduleSet only when the flag is 0x01
78
+ * ```
79
+ *
80
+ * `SHA-256(bytes)`, rendered as 64 lowercase hex characters, is the grant
81
+ * key. Bumping `markii-grant-key/1` to `/2` (or later) is how a future
82
+ * change to this scheme is made explicit and visible — any consumer keying
83
+ * off the literal string sees a different marker rather than a silent
84
+ * reinterpretation of old digests.
85
+ */
86
+ const SCHEME_VERSION = 'markii-grant-key/1';
87
+ const SECTION_TAG = {
88
+ scripts: 0x01,
89
+ bundleModules: 0x02,
90
+ vaultModules: 0x03,
91
+ packs: 0x04,
92
+ };
93
+ const textEncoder = new TextEncoder();
94
+ /**
95
+ * Concatenates byte chunks into ONE freshly allocated buffer. The return
96
+ * type is pinned to `Uint8Array<ArrayBuffer>` (never the default
97
+ * `ArrayBufferLike`, which also admits `SharedArrayBuffer`) by allocating
98
+ * through an explicit `new ArrayBuffer` — that is what lets the digest call
99
+ * in `computeGrantKey` pass this straight to `SubtleCrypto.digest`, whose
100
+ * `BufferSource` parameter excludes shared memory, with no cast anywhere.
101
+ */
102
+ function concatBytes(chunks) {
103
+ let total = 0;
104
+ for (const chunk of chunks)
105
+ total += chunk.length;
106
+ const out = new Uint8Array(new ArrayBuffer(total));
107
+ let offset = 0;
108
+ for (const chunk of chunks) {
109
+ out.set(chunk, offset);
110
+ offset += chunk.length;
111
+ }
112
+ return out;
113
+ }
114
+ /** `u32(n)`: 4 bytes, big-endian. See the module doc comment's canonical form. */
115
+ function encodeU32(n) {
116
+ const bytes = new Uint8Array(4);
117
+ new DataView(bytes.buffer).setUint32(0, n, false);
118
+ return bytes;
119
+ }
120
+ /**
121
+ * `str(s)`: length-prefixed UTF-8 bytes. The prefix is the UTF-8 BYTE
122
+ * length (via `TextEncoder`), never `s.length` (UTF-16 code units) — this
123
+ * is what keeps multi-byte text framed correctly.
124
+ */
125
+ function encodeString(value) {
126
+ const bytes = textEncoder.encode(value);
127
+ return concatBytes([encodeU32(bytes.length), bytes]);
128
+ }
129
+ /** `opt(s)`: one tag byte, then `str(s)` only when present. See top doc comment. */
130
+ function encodeOptionalString(value) {
131
+ if (value === undefined)
132
+ return new Uint8Array([0x00]);
133
+ return concatBytes([new Uint8Array([0x01]), encodeString(value)]);
134
+ }
135
+ /** Lexicographic byte-array comparison: shorter-is-less on equal prefix. */
136
+ function compareBytes(a, b) {
137
+ const length = Math.min(a.length, b.length);
138
+ for (let i = 0; i < length; i++) {
139
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
140
+ if (diff !== 0)
141
+ return diff;
142
+ }
143
+ return a.length - b.length;
144
+ }
145
+ /** Sorts records by their own encoded bytes — never by an original array/map order. */
146
+ function sortRecords(records) {
147
+ return [...records].sort(compareBytes);
148
+ }
149
+ /** `section(tag, records)`: 1 tag byte + `u32(count)` + sorted record bytes. */
150
+ function encodeSection(tag, records) {
151
+ const sorted = sortRecords(records);
152
+ return concatBytes([
153
+ new Uint8Array([tag]),
154
+ encodeU32(sorted.length),
155
+ ...sorted,
156
+ ]);
157
+ }
158
+ function encodeScriptRecord(script) {
159
+ return concatBytes([
160
+ encodeString(script.name),
161
+ encodeString(script.lang),
162
+ encodeOptionalString(script.src),
163
+ encodeString(script.code),
164
+ ]);
165
+ }
166
+ function encodeModuleRecord(path, source) {
167
+ return concatBytes([encodeString(path), encodeString(source)]);
168
+ }
169
+ /** `moduleSet`: `u32(count)` + sorted `str(path) str(source)` records. No section tag — always nested inside an already-tagged section/record. */
170
+ function encodeModuleSet(modules) {
171
+ const records = Object.entries(modules).map(([path, source]) => encodeModuleRecord(path, source));
172
+ const sorted = sortRecords(records);
173
+ return concatBytes([encodeU32(sorted.length), ...sorted]);
174
+ }
175
+ function encodeVaultNamespaceRecord(namespace, modules) {
176
+ return concatBytes([encodeString(namespace), encodeModuleSet(modules)]);
177
+ }
178
+ function encodePackRecord(pack) {
179
+ const modules = pack.modules;
180
+ return concatBytes([
181
+ encodeString(pack.namespace),
182
+ encodeString(pack.version),
183
+ new Uint8Array([modules === undefined ? 0x00 : 0x01]),
184
+ modules === undefined ? new Uint8Array(0) : encodeModuleSet(modules),
185
+ ]);
186
+ }
187
+ function encodeClosure(closure) {
188
+ const scriptRecords = closure.scripts.map(encodeScriptRecord);
189
+ const bundleRecords = Object.entries(closure.bundleModules).map(([path, source]) => encodeModuleRecord(path, source));
190
+ const vaultRecords = Object.entries(closure.vaultModules).map(([namespace, modules]) => encodeVaultNamespaceRecord(namespace, modules));
191
+ const packRecords = closure.packs.map(encodePackRecord);
192
+ return concatBytes([
193
+ encodeString(SCHEME_VERSION),
194
+ encodeSection(SECTION_TAG.scripts, scriptRecords),
195
+ encodeSection(SECTION_TAG.bundleModules, bundleRecords),
196
+ encodeSection(SECTION_TAG.vaultModules, vaultRecords),
197
+ encodeSection(SECTION_TAG.packs, packRecords),
198
+ ]);
199
+ }
200
+ function toHex(buffer) {
201
+ const bytes = new Uint8Array(buffer);
202
+ let hex = '';
203
+ for (const byte of bytes) {
204
+ hex += byte.toString(16).padStart(2, '0');
205
+ }
206
+ return hex;
207
+ }
208
+ /**
209
+ * Computes the grant key for a note's executable closure (DESIGN.md §10):
210
+ * the lowercase hex SHA-256 digest of the closure's canonical serialization
211
+ * — see this module's top doc comment for the exact byte form. Pure and
212
+ * side-effect free: this function does not parse, fetch, or read anything;
213
+ * the host must have already assembled `closure` from the note's scripts,
214
+ * resolved `src=`/`require` targets, and installed pack manifest.
215
+ *
216
+ * Uses `globalThis.crypto.subtle` (Web Crypto — available in Node >=20 and
217
+ * every browser) rather than `node:crypto`, so this package stays
218
+ * browser-safe with no added dependency.
219
+ */
220
+ export async function computeGrantKey(closure) {
221
+ const bytes = encodeClosure(closure);
222
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
223
+ return toHex(digest);
224
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,5 @@
1
1
  export { createValueStore, type StoredValue, type ValueStatus, type ValueStore, } from './store.js';
2
+ export { FAILURE_KINDS, normalizeFailureKind, type FailureKind, } from './failure.js';
3
+ export { computeGrantKey, type GrantClosure, type GrantClosurePack, type GrantClosureScript, } from './grant-key.js';
4
+ export { createVaultStore, type CreateVaultStoreOptions, type VaultPublishFailure, type VaultPublishResult, type VaultPublishSuccess, type VaultStore, type VaultStoreHandle, type VaultWriter, } from './vault.js';
2
5
  export { runDocumentScripts, tierForTrigger, type ExecuteFailure, type ExecuteResult, type ExecuteSuccess, type ExecutionTier, type RunDocumentScriptsOptions, type RunSummary, type RunSummaryEntry, type RunTrigger, type ScriptExecutor, } from './run.js';
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
1
1
  export { createValueStore, } from './store.js';
2
+ export { FAILURE_KINDS, normalizeFailureKind, } from './failure.js';
3
+ export { computeGrantKey, } from './grant-key.js';
4
+ export { createVaultStore, } from './vault.js';
2
5
  export { runDocumentScripts, tierForTrigger, } from './run.js';
package/dist/run.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { ScriptBlock } from '@markii/core';
2
+ import { type FailureKind } from './failure.js';
2
3
  import type { ValueStore } from './store.js';
4
+ import type { VaultWriter } from './vault.js';
3
5
  /**
4
6
  * Slice 2 of the scripting-usability layer (DESIGN.md §8): the run
5
7
  * orchestrator that executes a document's script blocks and writes their
@@ -32,18 +34,23 @@ export interface ExecuteSuccess {
32
34
  value: unknown;
33
35
  }
34
36
  /**
35
- * A failed script execution. `kind` is intentionally a plain `string`
36
- * (not a fixed union) here `@markii/runtime` stays language-agnostic and
37
- * does not know the specific failure taxonomy of any one language runtime
38
- * (e.g. `@markii/lua`'s `'limit' | 'capability' | 'marshal' | 'runtime'`).
39
- * `runDocumentScripts` only ever special-cases the literal string
40
- * `'capability'`, by convention every executor is expected to use for a
41
- * denied-capability failure.
37
+ * A failed script execution. `kind` is the CLOSED `FailureKind` union
38
+ * (`./failure.ts`) every concrete `ScriptExecutor` (e.g. `@markii/lua`'s
39
+ * `createLuaExecutor`) is expected to map its own language-specific failure
40
+ * shape (e.g. Lua's `'limit' | 'capability' | 'marshal' | 'runtime'`) down
41
+ * to this shared vocabulary before returning. Even though this is typed as
42
+ * `FailureKind` at compile time, an executor is untrusted third-party code
43
+ * at RUNTIME (a forged string, a stale value from an older executor
44
+ * version, ...) — `runDocumentScripts` therefore runs every incoming
45
+ * `kind` through `normalizeFailureKind` at the boundary regardless of what
46
+ * the type system already promises, so a hostile or buggy executor can
47
+ * never produce a `StoredValue`/`RunSummaryEntry` carrying an
48
+ * out-of-taxonomy `failureKind`.
42
49
  */
43
50
  export interface ExecuteFailure {
44
51
  ok: false;
45
52
  error: {
46
- kind: string;
53
+ kind: FailureKind;
47
54
  message: string;
48
55
  };
49
56
  }
@@ -66,6 +73,30 @@ export interface RunSummaryEntry {
66
73
  name: string;
67
74
  status: 'fresh' | 'error';
68
75
  error?: string;
76
+ /**
77
+ * Set alongside `error` on every `status: 'error'` entry — the closed
78
+ * `FailureKind` (`./failure.ts`) this failure was classified as. Already
79
+ * normalized (see `ExecuteFailure`'s doc comment), so a caller can branch
80
+ * on it directly without re-deriving trust. Absent for a `'fresh'` entry.
81
+ */
82
+ failureKind?: FailureKind;
83
+ /**
84
+ * Publish outcome (DESIGN.md §8's vault). Set ONLY for a script block
85
+ * whose fence carried the bare `publish` attribute (`ScriptBlock.publish
86
+ * === true`) AND whose run succeeded (`status === 'fresh'`) — a
87
+ * non-publish block never gets this field, and a publish-flagged block
88
+ * whose run FAILED never gets it either (its `status` already says
89
+ * `'error'`; there is nothing to publish).
90
+ * - `'published'` — `vault.publish` accepted the value.
91
+ * - `'rejected'` — a writer was present but declined (see
92
+ * `publishError`); the note's own `status` STAYS `'fresh'` regardless —
93
+ * a vault rejection is not a script failure.
94
+ * - `'not-granted'` — no `vault` was supplied to `runDocumentScripts` at
95
+ * all; publishing was flagged but no grant existed to act on it.
96
+ */
97
+ publish?: 'published' | 'rejected' | 'not-granted';
98
+ /** Set only when `publish === 'rejected'`; the writer's rejection message. */
99
+ publishError?: string;
69
100
  }
70
101
  /**
71
102
  * The result of one `runDocumentScripts` call. `results` has one entry per
@@ -83,6 +114,8 @@ export interface RunSummary {
83
114
  freshCount: number;
84
115
  errorCount: number;
85
116
  duplicateNames: string[];
117
+ /** Count of `results` entries with `publish === 'published'`. */
118
+ publishedCount: number;
86
119
  }
87
120
  export interface RunDocumentScriptsOptions {
88
121
  scripts: ScriptBlock[];
@@ -96,6 +129,29 @@ export interface RunDocumentScriptsOptions {
96
129
  * block is recorded as an error (never a thrown exception).
97
130
  */
98
131
  loadSource?: (src: string) => Promise<string> | string;
132
+ /**
133
+ * The publish grant (DESIGN.md §8: "Publishing requires a grant ...
134
+ * because it writes beyond the note"). ITS PRESENCE IS THE GRANT — there
135
+ * is no separate flag to enable publishing, and no per-script grant
136
+ * check; a host that hands in a `vault` is thereby authorizing every
137
+ * `publish`-flagged block in this batch to write to it. Absent =>
138
+ * publish-flagged blocks simply do not publish (`publish: 'not-granted'`
139
+ * on their `RunSummaryEntry`); this is NOT an error and the run still
140
+ * succeeds.
141
+ *
142
+ * Publishing is allowed under BOTH execution tiers (`'manual'` and
143
+ * `'auto'`) whenever a writer is present — the grant is the gate here,
144
+ * NOT the trigger/tier. This is deliberately orthogonal to
145
+ * `tierForTrigger`'s security gate: that gate governs what CAPABILITIES a
146
+ * script may exercise while running (network access, cache writes, ...)
147
+ * via the tier passed to the executor; it says nothing about whether the
148
+ * host is willing to copy an already-computed, already-successful return
149
+ * value into its own vault afterward. A read-only `'auto'`-tier run can
150
+ * still publish, because publishing isn't a capability the SCRIPT
151
+ * exercises — it's something the HOST does with a value the script already
152
+ * (successfully) produced.
153
+ */
154
+ vault?: VaultWriter;
99
155
  }
100
156
  /**
101
157
  * Runs every script block in `scripts`, in document order, against
@@ -112,6 +168,15 @@ export interface RunDocumentScriptsOptions {
112
168
  * per-script, matching DESIGN.md §8 (a run is manual, auto, or scheduled as
113
169
  * a whole; individual scripts don't choose their own tier).
114
170
  *
171
+ * Publishing (§8's vault): after a `publish`-flagged block's run SUCCEEDS,
172
+ * if `options.vault` was supplied its `publish` is called with the same
173
+ * `StoredValue` just written to `store` — see `RunDocumentScriptsOptions.
174
+ * vault`'s doc comment for why the grant, not the trigger/tier, is what
175
+ * gates this. A writer that rejects, throws, or has its promise reject
176
+ * never aborts the batch and never changes the note's own `status` (which
177
+ * stays `'fresh'`) — only `RunSummaryEntry.publish`/`.publishError` record
178
+ * the outcome. This function still never throws.
179
+ *
115
180
  * Duplicate `name`s: every attempt gets its own `RunSummary.results` entry,
116
181
  * but `store.set` is called once per script in document order, so the LAST
117
182
  * run for a given name is what's left in the store afterward — see
package/dist/run.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeFailureKind } from './failure.js';
1
2
  /**
2
3
  * DESIGN.md §8's trigger x capability table, expressed as a pure lookup —
3
4
  * THIS IS THE SECURITY GATE for the whole run path. `'manual'` is the only
@@ -22,27 +23,38 @@ export function tierForTrigger(trigger) {
22
23
  function describeThrown(err) {
23
24
  return err instanceof Error ? err.message : String(err);
24
25
  }
25
- /**
26
- * §8: "An effectful call under an auto trigger fails cleanly; the
27
- * consuming component shows a 'requires manual run' marker." When the tier
28
- * a script ran at was the read-only `'auto'` tier and the executor reports
29
- * a `'capability'`-kind failure, the stored error message is rewritten to
30
- * clearly say so, so a component (or a human reading the value store) does
31
- * not have to re-derive "this needs a manual run" from a generic capability
32
- * message.
33
- */
34
- function messageForFailure(tier, error) {
35
- if (tier === 'auto' && error.kind === 'capability') {
36
- return `${error.message} (requires manual run: this capability is only available on a manual run)`;
37
- }
38
- return error.message;
39
- }
40
26
  /**
41
27
  * Runs exactly one script block and never throws: every way it can fail
42
28
  * (missing `loadSource` for a `src=` reference, `loadSource` itself
43
29
  * throwing, the executor rejecting/throwing, or the executor reporting
44
30
  * `ok: false`) is caught here and turned into an `error`-status outcome, so
45
31
  * one bad script can never abort the rest of a `runDocumentScripts` batch.
32
+ *
33
+ * Failure classification (`failureKind`): the three failure paths this
34
+ * function owns OUTRIGHT — a `src=` block with no `loadSource` configured,
35
+ * `loadSource` itself throwing/rejecting, and the executor throwing or its
36
+ * returned promise rejecting (as opposed to resolving with `ok: false`) —
37
+ * are all classified as `'script-error'`. None of these are the executor
38
+ * reporting a considered, typed failure; they are this package's OWN
39
+ * plumbing failing (a missing host wire-up, or the executor misbehaving by
40
+ * throwing instead of returning `ExecuteResult`), so there is no more
41
+ * specific taxonomy member that legitimately applies — `'script-error'` is
42
+ * both correct (something about running the script went wrong) and the safe
43
+ * default (never guesses a more privileged-sounding kind like
44
+ * `'capability-denied'` or `'tier-blocked'` for a failure this package can't
45
+ * actually attribute to a capability decision). Only the fourth path —
46
+ * `result.ok === false` from a normally-returning executor — carries a
47
+ * `kind` the executor itself chose, and even that is passed through
48
+ * `normalizeFailureKind` before being trusted (see `ExecuteFailure`'s doc
49
+ * comment): the executor is untrusted third-party code at runtime.
50
+ *
51
+ * Stored/reported messages are the executor's `error.message` VERBATIM —
52
+ * this function never rewrites or appends to it (see `ExecuteFailure`'s doc
53
+ * comment; the previous `messageForFailure` auto-tier rewrite is gone: a
54
+ * `'tier-blocked'` failure's own message already says what happened, and a
55
+ * `'capability-denied'` failure is not a "needs manual run" situation at
56
+ * all, so rewriting every capability-kind auto-tier failure that way was
57
+ * simply wrong).
46
58
  */
47
59
  async function runOne(script, executor, tier, loadSource) {
48
60
  let code;
@@ -61,8 +73,19 @@ async function runOne(script, executor, tier, loadSource) {
61
73
  const message = describeThrown(err);
62
74
  const ranAt = Date.now();
63
75
  return {
64
- entry: { name: script.name, status: 'error', error: message },
65
- storedValue: { value: undefined, status: 'error', error: message, ranAt },
76
+ entry: {
77
+ name: script.name,
78
+ status: 'error',
79
+ error: message,
80
+ failureKind: 'script-error',
81
+ },
82
+ storedValue: {
83
+ value: undefined,
84
+ status: 'error',
85
+ error: message,
86
+ failureKind: 'script-error',
87
+ ranAt,
88
+ },
66
89
  };
67
90
  }
68
91
  let result;
@@ -73,8 +96,19 @@ async function runOne(script, executor, tier, loadSource) {
73
96
  const message = describeThrown(err);
74
97
  const ranAt = Date.now();
75
98
  return {
76
- entry: { name: script.name, status: 'error', error: message },
77
- storedValue: { value: undefined, status: 'error', error: message, ranAt },
99
+ entry: {
100
+ name: script.name,
101
+ status: 'error',
102
+ error: message,
103
+ failureKind: 'script-error',
104
+ },
105
+ storedValue: {
106
+ value: undefined,
107
+ status: 'error',
108
+ error: message,
109
+ failureKind: 'script-error',
110
+ ranAt,
111
+ },
78
112
  };
79
113
  }
80
114
  const ranAt = Date.now();
@@ -84,10 +118,17 @@ async function runOne(script, executor, tier, loadSource) {
84
118
  storedValue: { value: result.value, status: 'fresh', ranAt },
85
119
  };
86
120
  }
87
- const message = messageForFailure(tier, result.error);
121
+ const failureKind = normalizeFailureKind(result.error.kind);
122
+ const message = result.error.message;
88
123
  return {
89
- entry: { name: script.name, status: 'error', error: message },
90
- storedValue: { value: undefined, status: 'error', error: message, ranAt },
124
+ entry: { name: script.name, status: 'error', error: message, failureKind },
125
+ storedValue: {
126
+ value: undefined,
127
+ status: 'error',
128
+ error: message,
129
+ failureKind,
130
+ ranAt,
131
+ },
91
132
  };
92
133
  }
93
134
  /**
@@ -105,13 +146,22 @@ async function runOne(script, executor, tier, loadSource) {
105
146
  * per-script, matching DESIGN.md §8 (a run is manual, auto, or scheduled as
106
147
  * a whole; individual scripts don't choose their own tier).
107
148
  *
149
+ * Publishing (§8's vault): after a `publish`-flagged block's run SUCCEEDS,
150
+ * if `options.vault` was supplied its `publish` is called with the same
151
+ * `StoredValue` just written to `store` — see `RunDocumentScriptsOptions.
152
+ * vault`'s doc comment for why the grant, not the trigger/tier, is what
153
+ * gates this. A writer that rejects, throws, or has its promise reject
154
+ * never aborts the batch and never changes the note's own `status` (which
155
+ * stays `'fresh'`) — only `RunSummaryEntry.publish`/`.publishError` record
156
+ * the outcome. This function still never throws.
157
+ *
108
158
  * Duplicate `name`s: every attempt gets its own `RunSummary.results` entry,
109
159
  * but `store.set` is called once per script in document order, so the LAST
110
160
  * run for a given name is what's left in the store afterward — see
111
161
  * `RunSummary.duplicateNames`.
112
162
  */
113
163
  export async function runDocumentScripts(options) {
114
- const { scripts, executor, trigger, store, loadSource } = options;
164
+ const { scripts, executor, trigger, store, loadSource, vault } = options;
115
165
  const tier = tierForTrigger(trigger);
116
166
  const results = [];
117
167
  const seenNames = new Set();
@@ -122,16 +172,49 @@ export async function runDocumentScripts(options) {
122
172
  }
123
173
  seenNames.add(script.name);
124
174
  const outcome = await runOne(script, executor, tier, loadSource);
125
- results.push(outcome.entry);
126
175
  store.set(script.name, outcome.storedValue);
176
+ // Publishing (DESIGN.md §8): only for a block that both asked to
177
+ // publish (bare `publish` on its fence — see `ScriptBlock.publish`) and
178
+ // actually succeeded. A failed run has nothing to publish; `runOne`
179
+ // already recorded its failure in `outcome.entry.status`/`.error`, and
180
+ // `publish` is left `undefined` on that entry (per `RunSummaryEntry`'s
181
+ // doc comment) rather than getting a misleading 'not-granted'/'rejected'.
182
+ if (script.publish === true && outcome.entry.status === 'fresh') {
183
+ if (!vault) {
184
+ outcome.entry.publish = 'not-granted';
185
+ }
186
+ else {
187
+ let result;
188
+ try {
189
+ result = await vault.publish(script.name, outcome.storedValue);
190
+ }
191
+ catch (err) {
192
+ result = {
193
+ ok: false,
194
+ error: { kind: 'error', message: describeThrown(err) },
195
+ };
196
+ }
197
+ if (result.ok) {
198
+ outcome.entry.publish = 'published';
199
+ }
200
+ else {
201
+ outcome.entry.publish = 'rejected';
202
+ outcome.entry.publishError = result.error.message;
203
+ }
204
+ }
205
+ }
206
+ results.push(outcome.entry);
127
207
  }
128
208
  let freshCount = 0;
129
209
  let errorCount = 0;
210
+ let publishedCount = 0;
130
211
  for (const entry of results) {
131
212
  if (entry.status === 'fresh')
132
213
  freshCount++;
133
214
  else
134
215
  errorCount++;
216
+ if (entry.publish === 'published')
217
+ publishedCount++;
135
218
  }
136
219
  return {
137
220
  trigger,
@@ -140,5 +223,6 @@ export async function runDocumentScripts(options) {
140
223
  freshCount,
141
224
  errorCount,
142
225
  duplicateNames: [...duplicateNames],
226
+ publishedCount,
143
227
  };
144
228
  }
package/dist/store.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * the script's declared `name`. "Rendering is pure; running is an event" —
7
7
  * this store is the pure side of that split.
8
8
  */
9
+ import type { FailureKind } from './failure.js';
9
10
  /**
10
11
  * Freshness of one stored value:
11
12
  * - `fresh` — produced by the most recent successful run.
@@ -19,6 +20,17 @@ export interface StoredValue {
19
20
  value: unknown;
20
21
  status: ValueStatus;
21
22
  error?: string;
23
+ /**
24
+ * Set alongside `error` on every `status: 'error'` outcome (see
25
+ * `./run.ts`'s `runDocumentScripts`) — the closed `FailureKind` (`./
26
+ * failure.ts`) the failure was classified as, already run through
27
+ * `normalizeFailureKind` at the run-path boundary so it is safe for a
28
+ * renderer to branch on directly. Absent for a non-error status, and
29
+ * absent for an error `StoredValue` written by something other than the
30
+ * run path (e.g. a hand-constructed fixture) — a renderer must still
31
+ * degrade gracefully when this is missing.
32
+ */
33
+ failureKind?: FailureKind;
22
34
  ranAt?: number;
23
35
  }
24
36
  /**
@@ -0,0 +1,97 @@
1
+ import type { StoredValue } from './store.js';
2
+ /**
3
+ * Slice 3 of the scripting-usability layer (DESIGN.md §8, "Vault-published
4
+ * values (the bulletin board)"): the APP-SCOPED store that a `publish`-
5
+ * flagged script block's result lands in, as distinct from `store.ts`'s
6
+ * NOTE-scoped `ValueStore`. "The store is app-side (§9): publishing adds no
7
+ * files to the vault" — this module holds no persistence of its own either;
8
+ * it is purely an in-memory reference implementation a host may use as-is or
9
+ * replace with its own (e.g. one backed by disk or a database).
10
+ *
11
+ * The read/write split mirrors `store.ts`'s null-proto, `Object.hasOwn`-
12
+ * guarded defensive posture (protection against `name`s like `__proto__` or
13
+ * `constructor` resolving to an inherited `Object.prototype` member instead
14
+ * of a real, or correctly-absent, entry), but goes one step further:
15
+ * `VaultStore` (read) and `VaultWriter` (write) are separate interfaces
16
+ * backed by the same data, so a host can hand a renderer the read seam alone
17
+ * and withhold the write capability entirely. §8: "Reading is render-time
18
+ * and pure ... Publishing requires a grant ... because it writes beyond the
19
+ * note" — possessing a `VaultWriter` reference IS that grant. There is no
20
+ * ambient "is this note allowed to publish" check anywhere in this module;
21
+ * the capability itself is the permission.
22
+ */
23
+ /**
24
+ * Read-only view of the vault-level, app-managed store (§8). Deliberately
25
+ * has NO `set` — rendering reads the vault and must never be able to write
26
+ * it; only a `VaultWriter` (obtained separately, and only by something the
27
+ * host trusts with the publish grant) can do that.
28
+ */
29
+ export interface VaultStore {
30
+ get(name: string): StoredValue | undefined;
31
+ has(name: string): boolean;
32
+ snapshot(): Record<string, StoredValue>;
33
+ }
34
+ /**
35
+ * A publish rejection. `kind` is a plain `string` (not a fixed union),
36
+ * mirroring `ExecuteFailure.error.kind` in `run.ts` — callers are expected
37
+ * to branch on `ok`, never on `message` text. This module's own writer only
38
+ * ever produces `'claimed'` (see `createVaultStore`'s `canPublish`) or
39
+ * `'policy'` (a throwing `canPublish` hook); a host's own `VaultWriter`
40
+ * implementation is free to define further `kind`s for its own policies.
41
+ */
42
+ export interface VaultPublishFailure {
43
+ ok: false;
44
+ error: {
45
+ kind: string;
46
+ message: string;
47
+ };
48
+ }
49
+ export interface VaultPublishSuccess {
50
+ ok: true;
51
+ }
52
+ export type VaultPublishResult = VaultPublishSuccess | VaultPublishFailure;
53
+ /**
54
+ * The capability-style write side: possessing a `VaultWriter` IS the host's
55
+ * publish grant (§8: "Publishing requires a grant ... because it writes
56
+ * beyond the note"). Hosts may implement this themselves — e.g. one that
57
+ * closes over the publishing note's identity and a persistent claim table —
58
+ * rather than using `createVaultStore`'s reference implementation.
59
+ * `publish` may be async so a host-backed implementation (network call,
60
+ * disk write, claim negotiation) fits the same shape as the in-memory one.
61
+ */
62
+ export interface VaultWriter {
63
+ publish(name: string, entry: StoredValue): VaultPublishResult | Promise<VaultPublishResult>;
64
+ }
65
+ export interface CreateVaultStoreOptions {
66
+ /** Seeds the vault, e.g. from a previous session's persisted snapshot. */
67
+ initial?: Record<string, StoredValue>;
68
+ /**
69
+ * Single-writer-per-name hook — APP POLICY, not runtime policy (§8: "The
70
+ * app rejects a second note publishing an already-claimed name"). Given
71
+ * the candidate `name` and `entry`, return `false` to reject the claim.
72
+ * Absent => every publish is accepted (no claim tracking at all). A hook
73
+ * that throws is treated as a rejection (`kind: 'policy'`), never
74
+ * propagated — a buggy policy hook must fail closed, not crash the run.
75
+ */
76
+ canPublish?: (name: string, entry: StoredValue) => boolean;
77
+ }
78
+ /**
79
+ * Reference in-memory `VaultStore`/`VaultWriter` pair. `store` and `writer`
80
+ * are deliberately SEPARATE objects backed by the same data, so a host can
81
+ * hand `store` to a renderer while withholding `writer` from anything that
82
+ * shouldn't be able to publish. No persistence of its own: storage is the
83
+ * host's concern (§9, "publishing adds no files to the vault") — a host
84
+ * that needs persistence wraps or replaces this with its own `VaultWriter`.
85
+ */
86
+ export interface VaultStoreHandle {
87
+ store: VaultStore;
88
+ writer: VaultWriter;
89
+ }
90
+ /**
91
+ * Creates an in-memory `VaultStoreHandle`. Backed by a null-prototype
92
+ * object, exactly like `createValueStore` — a vault `name` colliding with an
93
+ * inherited `Object.prototype` member (`__proto__`, `constructor`,
94
+ * `toString`, `hasOwnProperty`, ...) can never resolve to that inherited
95
+ * member instead of a real (or correctly-absent) entry.
96
+ */
97
+ export declare function createVaultStore(options?: CreateVaultStoreOptions): VaultStoreHandle;
package/dist/vault.js ADDED
@@ -0,0 +1,63 @@
1
+ function describeThrown(err) {
2
+ return err instanceof Error ? err.message : String(err);
3
+ }
4
+ /**
5
+ * Creates an in-memory `VaultStoreHandle`. Backed by a null-prototype
6
+ * object, exactly like `createValueStore` — a vault `name` colliding with an
7
+ * inherited `Object.prototype` member (`__proto__`, `constructor`,
8
+ * `toString`, `hasOwnProperty`, ...) can never resolve to that inherited
9
+ * member instead of a real (or correctly-absent) entry.
10
+ */
11
+ export function createVaultStore(options = {}) {
12
+ const { initial = {}, canPublish } = options;
13
+ const values = Object.create(null);
14
+ for (const [name, entry] of Object.entries(initial)) {
15
+ values[name] = entry;
16
+ }
17
+ const store = {
18
+ get(name) {
19
+ return Object.hasOwn(values, name) ? values[name] : undefined;
20
+ },
21
+ has(name) {
22
+ return Object.hasOwn(values, name);
23
+ },
24
+ // Shallow copy, exactly like `ValueStore.snapshot`: this is a fresh
25
+ // plain object, but each `StoredValue` it holds is the same object
26
+ // reference already in the vault — mutating a returned entry in place
27
+ // would be visible to the vault too. The returned object's own
28
+ // prototype is the ordinary `Object.prototype` (a plain `{ ...values }`
29
+ // spread, not `Object.create(null)`), since it's a caller-facing value
30
+ // with no further hostile-key lookups performed against it here.
31
+ snapshot() {
32
+ return { ...values };
33
+ },
34
+ };
35
+ const writer = {
36
+ publish(name, entry) {
37
+ if (canPublish) {
38
+ let allowed;
39
+ try {
40
+ allowed = canPublish(name, entry);
41
+ }
42
+ catch (err) {
43
+ return {
44
+ ok: false,
45
+ error: { kind: 'policy', message: describeThrown(err) },
46
+ };
47
+ }
48
+ if (!allowed) {
49
+ return {
50
+ ok: false,
51
+ error: {
52
+ kind: 'claimed',
53
+ message: `vault name "${name}" is already claimed by another writer`,
54
+ },
55
+ };
56
+ }
57
+ }
58
+ values[name] = entry;
59
+ return { ok: true };
60
+ },
61
+ };
62
+ return { store, writer };
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Host-side scripting glue for Mark (.mk.md): a null-proto value store and document-script execution with trigger-tier gating (auto/scheduled stay read-only). Framework-agnostic; the script executor is injected by the host (e.g. @markii/lua).",
5
5
  "keywords": [
6
6
  "markdown",
@@ -42,6 +42,6 @@
42
42
  "lint": "eslint ."
43
43
  },
44
44
  "dependencies": {
45
- "@markii/core": "0.1.0"
45
+ "@markii/core": "0.2.0"
46
46
  }
47
47
  }