@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.
- package/dist/failure.d.ts +44 -0
- package/dist/failure.js +28 -0
- package/dist/grant-key.d.ts +167 -0
- package/dist/grant-key.js +224 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/run.d.ts +73 -8
- package/dist/run.js +108 -24
- package/dist/store.d.ts +12 -0
- package/dist/vault.d.ts +97 -0
- package/dist/vault.js +63 -0
- package/package.json +2 -2
|
@@ -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;
|
package/dist/failure.js
ADDED
|
@@ -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
|
|
36
|
-
* (
|
|
37
|
-
*
|
|
38
|
-
* (e.g.
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
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:
|
|
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: {
|
|
65
|
-
|
|
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: {
|
|
77
|
-
|
|
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
|
|
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: {
|
|
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
|
/**
|
package/dist/vault.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
45
|
+
"@markii/core": "0.2.0"
|
|
46
46
|
}
|
|
47
47
|
}
|