@markii/bundle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sadigaxund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @markii/bundle
2
+
3
+ Bundle (`.mkbundle`) storage and policy handling for [Mark](https://github.com/sadigaxund/markii): manifest parsing/validation, a bundle-relative path-jail, the zip storage form (via `fflate`), a Node-only directory storage form (`./fs` subpath), and a capability-restricted script view. No React, no markdown parsing.
4
+
5
+ See the [repository](https://github.com/sadigaxund/markii) for the format spec and the reference library as a whole.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Thrown by a `BundleStorage` implementation when a path fails the
3
+ * path-jail (`normalizeBundlePath`), or — for the directory form (`./fs`)
4
+ * — when the real filesystem doesn't behave like the jailed logical path
5
+ * promised: a symlink anywhere along the path (`resolveInsideRoot`'s
6
+ * lstat-per-component walk), a resolved path that diverges from the
7
+ * requested one, or an existing file with more than one hard link
8
+ * (`writeExistingFileNoHardlink`, refusing to write through a path that
9
+ * aliases some other file — possibly outside the bundle entirely). All of
10
+ * these are the same class of violation from the caller's perspective:
11
+ * "this path does not stay inside the bundle."
12
+ */
13
+ export declare class BundlePathError extends Error {
14
+ readonly path: string;
15
+ readonly reason: string;
16
+ constructor(path: string, reason: string);
17
+ }
18
+ /**
19
+ * Thrown by `openZipBundle` when the archive contains one or more entries
20
+ * whose names fail the path-jail (zip-slip candidates: `../`, absolute
21
+ * paths, backslash paths, drive letters). Deliberately loud — a tampered
22
+ * bundle is rejected outright, never silently pruned to "the safe entries."
23
+ */
24
+ export declare class BundleZipError extends Error {
25
+ readonly entries: readonly string[];
26
+ constructor(message: string, entries: readonly string[]);
27
+ }
28
+ /**
29
+ * Thrown by a `ScriptView` (see `./script-view`) when the manifest does not
30
+ * grant the capability a call requires. Distinct from `BundlePathError`:
31
+ * this is a permissions failure, not a malformed-path failure, and a future
32
+ * script runtime should be able to tell the two apart (e.g. to show "needs
33
+ * permission" vs. "invalid path" to the note author).
34
+ */
35
+ export declare class ScriptCapabilityError extends Error {
36
+ constructor(message: string);
37
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Thrown by a `BundleStorage` implementation when a path fails the
3
+ * path-jail (`normalizeBundlePath`), or — for the directory form (`./fs`)
4
+ * — when the real filesystem doesn't behave like the jailed logical path
5
+ * promised: a symlink anywhere along the path (`resolveInsideRoot`'s
6
+ * lstat-per-component walk), a resolved path that diverges from the
7
+ * requested one, or an existing file with more than one hard link
8
+ * (`writeExistingFileNoHardlink`, refusing to write through a path that
9
+ * aliases some other file — possibly outside the bundle entirely). All of
10
+ * these are the same class of violation from the caller's perspective:
11
+ * "this path does not stay inside the bundle."
12
+ */
13
+ export class BundlePathError extends Error {
14
+ path;
15
+ reason;
16
+ constructor(path, reason) {
17
+ super(`invalid bundle path ${JSON.stringify(path)}: ${reason}`);
18
+ this.name = 'BundlePathError';
19
+ this.path = path;
20
+ this.reason = reason;
21
+ }
22
+ }
23
+ /**
24
+ * Thrown by `openZipBundle` when the archive contains one or more entries
25
+ * whose names fail the path-jail (zip-slip candidates: `../`, absolute
26
+ * paths, backslash paths, drive letters). Deliberately loud — a tampered
27
+ * bundle is rejected outright, never silently pruned to "the safe entries."
28
+ */
29
+ export class BundleZipError extends Error {
30
+ entries;
31
+ constructor(message, entries) {
32
+ super(message);
33
+ this.name = 'BundleZipError';
34
+ this.entries = entries;
35
+ }
36
+ }
37
+ /**
38
+ * Thrown by a `ScriptView` (see `./script-view`) when the manifest does not
39
+ * grant the capability a call requires. Distinct from `BundlePathError`:
40
+ * this is a permissions failure, not a malformed-path failure, and a future
41
+ * script runtime should be able to tell the two apart (e.g. to show "needs
42
+ * permission" vs. "invalid path" to the note author).
43
+ */
44
+ export class ScriptCapabilityError extends Error {
45
+ constructor(message) {
46
+ super(message);
47
+ this.name = 'ScriptCapabilityError';
48
+ }
49
+ }
package/dist/fs.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { BundleStorage } from './storage.js';
2
+ import { type OpenZipBundleOptions } from './zip.js';
3
+ /**
4
+ * Opens the directory form of a bundle: reads/writes go straight to disk
5
+ * under `rootDir`, with every operation re-verified against symlink escape
6
+ * (see `resolveInsideRoot`) and, on write, against hard-link aliasing (see
7
+ * `writeExistingFileNoHardlink`). `rootDir` must already exist.
8
+ */
9
+ export declare function openDirBundle(rootDir: string): BundleStorage;
10
+ /**
11
+ * Scaffolds a brand-new bundle directory: `dir/note.mk.md` with `smdText` as
12
+ * its content, plus a default `dir/manifest.json` (no permissions granted,
13
+ * no packs declared). Creates `dir` if it doesn't already exist.
14
+ */
15
+ export declare function promoteToBundle(smdText: string, dir: string, specVersion?: string): Promise<void>;
16
+ /** Zips up an existing bundle directory. Reuses `openDirBundle` + `exportZipBundle` from `./zip`. */
17
+ export declare function dirToZip(rootDir: string): Promise<Uint8Array>;
18
+ /**
19
+ * Extracts a zip bundle into `destDir` (created if missing). Inherits
20
+ * `openZipBundle`'s zip-slip/collision/decompression-bomb/CRC rejection —
21
+ * a tampered or malformed zip throws `BundleZipError` before anything is
22
+ * written to disk. `options` is forwarded to `openZipBundle` (see there for
23
+ * the decompression-size-budget knobs).
24
+ */
25
+ export declare function zipToDir(bytes: Uint8Array, destDir: string, options?: OpenZipBundleOptions): Promise<void>;
package/dist/fs.js ADDED
@@ -0,0 +1,249 @@
1
+ // Node-only: exported solely via the "./fs" subpath (see package.json),
2
+ // mirroring @markii/core's "./corpus" split — a browser bundler resolving
3
+ // this package's main entry never has to reason about `node:fs`.
4
+ import { lstat, mkdir, open, readFile, readdir, realpath, stat, writeFile, } from 'node:fs/promises';
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { BundlePathError } from './errors.js';
7
+ import { createDefaultManifest, CURRENT_SPEC_VERSION } from './manifest.js';
8
+ import { normalizeOrThrow } from './storage.js';
9
+ import { exportZipBundle, openZipBundle, } from './zip.js';
10
+ function isErrnoException(err) {
11
+ return err instanceof Error && 'code' in err;
12
+ }
13
+ function isEnoent(err) {
14
+ return isErrnoException(err) && err.code === 'ENOENT';
15
+ }
16
+ /**
17
+ * `ENOTDIR` shows up when an *earlier* path component turns out to be a
18
+ * plain file instead of a directory (e.g. writing `cache/data.json/sub`
19
+ * when `cache/data.json` already exists as a file) — treated the same as
20
+ * "doesn't exist yet" by the lstat walk below, deferring the actual clear
21
+ * error to the subsequent `mkdir`/`writeFile`/`open` call.
22
+ */
23
+ function isEnoentLike(err) {
24
+ return (isErrnoException(err) && (err.code === 'ENOENT' || err.code === 'ENOTDIR'));
25
+ }
26
+ /**
27
+ * Resolves `rootAbs/relPath` (both already-safe: `relPath` has passed
28
+ * `normalizeOrThrow`) against the real filesystem, refusing to follow any
29
+ * symlink anywhere along the way — this is the storage-layer half of
30
+ * closing ESCAPE 1/2/3 from the independent security review: the path-jail
31
+ * in `./paths` only reasons about the *logical* path string, but the actual
32
+ * filesystem write/read *follows symlinks*, so a symlink planted anywhere
33
+ * inside the bundle (even one that resolves to somewhere still nominally
34
+ * "inside the bundle root", like `cache/pwn -> ../manifest.json`) can
35
+ * silently retarget an otherwise-innocuous-looking write.
36
+ *
37
+ * Two layers, in order:
38
+ *
39
+ * 1. **Primary defense — lstat every path component.** Walk `relPath`
40
+ * segment by segment from `rootAbs`, `lstat`-ing each one (never
41
+ * `stat`, which would itself follow a symlink). The moment any
42
+ * *existing* component is a symlink — file or directory, leaf or
43
+ * ancestor, whether or not it resolves inside or outside the root — the
44
+ * whole operation is rejected. This alone defeats ESCAPE 1 (`cache/pwn`
45
+ * symlinked to `../manifest.json`) and its directory variant
46
+ * (`cache/up` symlinked to `..`, then `cache/up/manifest.json` or even
47
+ * `cache/up/not-yet-created.txt`): the symlinked component is caught
48
+ * before we ever ask whether its target exists.
49
+ * 2. **Defense in depth — resolved-path re-check.** Once the walk finds
50
+ * the nearest existing ancestor (the leaf itself may not exist yet,
51
+ * e.g. a fresh write), that ancestor is symlink-free by construction
52
+ * (step 1 proved it), so its `realpath` is expected to equal its literal
53
+ * path exactly. We compute that anyway and assert it — effectively
54
+ * "re-run the write policy on the resolved path": since resolved path
55
+ * and logical path are now provably identical, whatever policy check a
56
+ * caller (e.g. `ScriptView.write`, gated on `isWriteAllowed`) already
57
+ * ran against the logical path remains valid for what actually gets
58
+ * touched on disk. A mismatch here (mount points, case-insensitive
59
+ * filesystems, or a bug in step 1) is rejected rather than trusted.
60
+ *
61
+ * TOCTOU note: there is an unavoidable gap between this check and the
62
+ * `open`/`readFile`/`writeFile` call that follows it (and a second, smaller
63
+ * gap inside `write` between opening the file and hard-link-checking it —
64
+ * see there). Closing that gap completely would require doing everything
65
+ * through `O_NOFOLLOW`-opened file descriptors end-to-end, which Node's
66
+ * high-level `fs/promises` API does not expose uniformly across platforms.
67
+ * The threat model here is a *hostile script running inside this process*
68
+ * (§10) manipulating the bundle's own files between our checks — not a
69
+ * concurrent, privileged local-filesystem attacker racing the filesystem
70
+ * from *outside* the process, which is out of scope (same assumption the
71
+ * rest of this package makes: a bundle's own directory isn't being
72
+ * concurrently rewritten by an unrelated actor while we operate on it).
73
+ *
74
+ * Returns the plain (non-realpath) `rootAbs`-joined path for the caller to
75
+ * actually operate on, plus whether a filesystem entry currently exists at
76
+ * that path (so `write` can decide between "fresh file" and "must check
77
+ * hard-link count before touching an existing one").
78
+ */
79
+ async function resolveInsideRoot(rootAbs, relPath) {
80
+ const rootReal = await realpath(rootAbs);
81
+ const segments = relPath.split('/');
82
+ let current = rootAbs;
83
+ let consumed = 0;
84
+ for (; consumed < segments.length; consumed++) {
85
+ const segment = segments[consumed];
86
+ const next = join(current, segment);
87
+ let st;
88
+ try {
89
+ st = await lstat(next);
90
+ }
91
+ catch (err) {
92
+ if (isEnoentLike(err))
93
+ break;
94
+ throw err;
95
+ }
96
+ if (st.isSymbolicLink()) {
97
+ throw new BundlePathError(relPath, `path component ${JSON.stringify(segments.slice(0, consumed + 1).join('/'))} is a symlink — symlinks inside a bundle are never followed`);
98
+ }
99
+ current = next;
100
+ }
101
+ const exists = consumed === segments.length;
102
+ // Defense-in-depth resolved-path re-check (see doc comment above).
103
+ const existingReal = current === rootAbs ? rootReal : await realpath(current);
104
+ const rel = relative(rootReal, existingReal);
105
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
106
+ throw new BundlePathError(relPath, 'resolved path escapes the bundle root');
107
+ }
108
+ const relNormalized = rel.length === 0 ? '' : rel.split(sep).join('/');
109
+ const expectedPrefix = segments.slice(0, consumed).join('/');
110
+ if (relNormalized !== expectedPrefix) {
111
+ throw new BundlePathError(relPath, 'resolved path does not match the requested bundle-relative path (possible symlink or mount-point redirection)');
112
+ }
113
+ return { target: join(rootAbs, relPath), exists };
114
+ }
115
+ /**
116
+ * Writes `data` to an already symlink-checked `target` that is known to
117
+ * exist, refusing to write through a hard link (ESCAPE 3: `st_nlink > 1`
118
+ * means some other path — possibly outside the bundle entirely, e.g.
119
+ * `ln b.mkbundle/../victim.txt b.mkbundle/cache/hard` — refers to the exact same
120
+ * inode, so writing here would also silently modify that other path).
121
+ *
122
+ * Opens the file first *without* truncating (`'r+'`), `fstat`s the open
123
+ * file descriptor (not a separate `stat(path)` call — checking the fd
124
+ * avoids a second TOCTOU window between "check nlink" and "open the file
125
+ * we checked"), and only truncates + writes once the link count is
126
+ * confirmed to be exactly 1.
127
+ */
128
+ async function writeExistingFileNoHardlink(target, relPath, data) {
129
+ const handle = await open(target, 'r+');
130
+ try {
131
+ const st = await handle.stat();
132
+ if (st.nlink > 1) {
133
+ throw new BundlePathError(relPath, `refusing to write through a hard-linked file (${st.nlink} links) — writing would also modify the other link target(s)`);
134
+ }
135
+ await handle.truncate(0);
136
+ if (data.length > 0) {
137
+ await handle.write(data, 0, data.length, 0);
138
+ }
139
+ }
140
+ finally {
141
+ await handle.close();
142
+ }
143
+ }
144
+ /**
145
+ * Opens the directory form of a bundle: reads/writes go straight to disk
146
+ * under `rootDir`, with every operation re-verified against symlink escape
147
+ * (see `resolveInsideRoot`) and, on write, against hard-link aliasing (see
148
+ * `writeExistingFileNoHardlink`). `rootDir` must already exist.
149
+ */
150
+ export function openDirBundle(rootDir) {
151
+ const rootAbs = resolve(rootDir);
152
+ return {
153
+ async read(path) {
154
+ const relPath = normalizeOrThrow(path);
155
+ const { target } = await resolveInsideRoot(rootAbs, relPath);
156
+ try {
157
+ const buf = await readFile(target);
158
+ // `readFile` resolves a Node `Buffer` (a `Uint8Array` subclass).
159
+ // Re-view it as a plain `Uint8Array` so callers get the type this
160
+ // interface promises, not an implementation detail that happens to
161
+ // compare unequal to a literal `Uint8Array` under deep-equality.
162
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
163
+ }
164
+ catch (err) {
165
+ if (isEnoent(err))
166
+ return undefined;
167
+ throw err;
168
+ }
169
+ },
170
+ async write(path, data) {
171
+ const relPath = normalizeOrThrow(path);
172
+ const { target, exists } = await resolveInsideRoot(rootAbs, relPath);
173
+ if (exists) {
174
+ await writeExistingFileNoHardlink(target, relPath, data);
175
+ return;
176
+ }
177
+ await mkdir(dirname(target), { recursive: true });
178
+ await writeFile(target, data);
179
+ },
180
+ async list() {
181
+ const results = [];
182
+ const walk = async (dirAbs, prefix) => {
183
+ const entries = await readdir(dirAbs, { withFileTypes: true });
184
+ for (const entry of entries) {
185
+ // Never follow symlinked entries during enumeration: a symlink
186
+ // planted inside the bundle dir could otherwise leak the
187
+ // directory structure (or contents) of wherever it points.
188
+ if (entry.isSymbolicLink())
189
+ continue;
190
+ const entryRelPath = prefix ? `${prefix}/${entry.name}` : entry.name;
191
+ if (entry.isDirectory()) {
192
+ await walk(join(dirAbs, entry.name), entryRelPath);
193
+ }
194
+ else if (entry.isFile()) {
195
+ results.push(entryRelPath);
196
+ }
197
+ }
198
+ };
199
+ await walk(rootAbs, '');
200
+ return results.sort();
201
+ },
202
+ async exists(path) {
203
+ const relPath = normalizeOrThrow(path);
204
+ const { target } = await resolveInsideRoot(rootAbs, relPath);
205
+ try {
206
+ const info = await stat(target);
207
+ return info.isFile();
208
+ }
209
+ catch (err) {
210
+ if (isEnoent(err))
211
+ return false;
212
+ throw err;
213
+ }
214
+ },
215
+ };
216
+ }
217
+ /**
218
+ * Scaffolds a brand-new bundle directory: `dir/note.mk.md` with `smdText` as
219
+ * its content, plus a default `dir/manifest.json` (no permissions granted,
220
+ * no packs declared). Creates `dir` if it doesn't already exist.
221
+ */
222
+ export async function promoteToBundle(smdText, dir, specVersion = CURRENT_SPEC_VERSION) {
223
+ const dirAbs = resolve(dir);
224
+ await mkdir(dirAbs, { recursive: true });
225
+ await writeFile(join(dirAbs, 'note.mk.md'), smdText, 'utf8');
226
+ const manifest = createDefaultManifest(specVersion);
227
+ await writeFile(join(dirAbs, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
228
+ }
229
+ /** Zips up an existing bundle directory. Reuses `openDirBundle` + `exportZipBundle` from `./zip`. */
230
+ export async function dirToZip(rootDir) {
231
+ return exportZipBundle(openDirBundle(rootDir));
232
+ }
233
+ /**
234
+ * Extracts a zip bundle into `destDir` (created if missing). Inherits
235
+ * `openZipBundle`'s zip-slip/collision/decompression-bomb/CRC rejection —
236
+ * a tampered or malformed zip throws `BundleZipError` before anything is
237
+ * written to disk. `options` is forwarded to `openZipBundle` (see there for
238
+ * the decompression-size-budget knobs).
239
+ */
240
+ export async function zipToDir(bytes, destDir, options) {
241
+ const src = openZipBundle(bytes, options); // throws BundleZipError on unsafe/oversized/corrupt entries
242
+ await mkdir(resolve(destDir), { recursive: true });
243
+ const dest = openDirBundle(destDir);
244
+ for (const path of await src.list()) {
245
+ const data = await src.read(path);
246
+ if (data !== undefined)
247
+ await dest.write(path, data);
248
+ }
249
+ }
@@ -0,0 +1,11 @@
1
+ export type { BundleManifest, BundlePermissions, ManifestParseResult, } from './manifest.js';
2
+ export { CURRENT_SPEC_VERSION, createDefaultManifest, parseManifest, } from './manifest.js';
3
+ export type { BundleFsGrant, BundleWritePolicy, NormalizePathResult, } from './paths.js';
4
+ export { isWriteAllowed, normalizeBundlePath } from './paths.js';
5
+ export type { BundleStorage } from './storage.js';
6
+ export { normalizeOrThrow } from './storage.js';
7
+ export { BundlePathError, BundleZipError, ScriptCapabilityError, } from './errors.js';
8
+ export type { OpenZipBundleOptions } from './zip.js';
9
+ export { DEFAULT_MAX_ZIP_ENTRY_BYTES, DEFAULT_MAX_ZIP_TOTAL_BYTES, exportZipBundle, openZipBundle, } from './zip.js';
10
+ export type { ScriptView } from './script-view.js';
11
+ export { createScriptView, grantAllDeclaredPermissions, } from './script-view.js';
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // This is @markii/bundle's browser-safe entry point: manifest handling, the
2
+ // path-jail, the zip storage form (fflate has no Node dependency), and the
3
+ // script capability view. The directory storage form touches `node:fs` and
4
+ // lives at the `@markii/bundle/fs` subpath instead (mirroring @markii/core's
5
+ // `./corpus` split), so a browser bundler consuming this package's main
6
+ // entry never has to reason about Node builtins reachable from it.
7
+ export { CURRENT_SPEC_VERSION, createDefaultManifest, parseManifest, } from './manifest.js';
8
+ export { isWriteAllowed, normalizeBundlePath } from './paths.js';
9
+ export { normalizeOrThrow } from './storage.js';
10
+ export { BundlePathError, BundleZipError, ScriptCapabilityError, } from './errors.js';
11
+ export { DEFAULT_MAX_ZIP_ENTRY_BYTES, DEFAULT_MAX_ZIP_TOTAL_BYTES, exportZipBundle, openZipBundle, } from './zip.js';
12
+ export { createScriptView, grantAllDeclaredPermissions, } from './script-view.js';
@@ -0,0 +1,56 @@
1
+ import type { BundleFsGrant } from './paths.js';
2
+ /**
3
+ * `manifest.json`'s contract (spec §9–§11). `mark` is the only required
4
+ * field. The index signature keeps unrecognized top-level keys typed as
5
+ * `unknown` rather than dropped — `parseManifest` preserves them verbatim
6
+ * (see the "forward compatibility" note there) so a manifest written by a
7
+ * newer spec version round-trips through an older implementation intact.
8
+ */
9
+ export interface BundleManifest {
10
+ /** Spec semver this bundle was authored against, e.g. `"0.1.0"`. */
11
+ mark: string;
12
+ permissions?: BundlePermissions;
13
+ uses?: string[];
14
+ [key: string]: unknown;
15
+ }
16
+ export interface BundlePermissions {
17
+ net?: {
18
+ /** Bare hostnames (no scheme/port/path/wildcard) allowed for GET. */
19
+ get?: string[];
20
+ /** Bare hostnames allowed for POST. */
21
+ post?: string[];
22
+ };
23
+ /** Bundle-filesystem grants; see `isWriteAllowed` in `./paths`. */
24
+ bundle?: BundleFsGrant[];
25
+ }
26
+ export type ManifestParseResult = {
27
+ ok: true;
28
+ manifest: BundleManifest;
29
+ warnings: string[];
30
+ } | {
31
+ ok: false;
32
+ errors: string[];
33
+ };
34
+ /** The current spec version this package's default manifests declare. */
35
+ export declare const CURRENT_SPEC_VERSION = "0.1.0";
36
+ /**
37
+ * Hand-rolled `manifest.json` validation (no schema library — see CLAUDE.md
38
+ * dependency policy). Never throws: malformed JSON, a non-object root, or
39
+ * any other malformed input all come back as `{ ok: false, errors }`.
40
+ *
41
+ * Unknown top-level keys are forward-compatible: they produce warnings, not
42
+ * errors, and are preserved on the returned `manifest` object untouched, so
43
+ * a manifest field introduced by a future spec version doesn't break an
44
+ * older implementation and isn't silently discarded if that manifest is
45
+ * later re-serialized.
46
+ *
47
+ * Judgment call: this forward-compatibility guarantee is *top-level only*,
48
+ * matching the task contract literally. An unrecognized key nested inside
49
+ * `permissions` (e.g. a future `permissions.fs`) is currently dropped
50
+ * rather than preserved, since `permissions` itself is a known, normalized
51
+ * key. Extending preservation to arbitrary nesting depth would be a
52
+ * reasonable follow-up but wasn't asked for here.
53
+ */
54
+ export declare function parseManifest(json: string): ManifestParseResult;
55
+ /** A minimal, valid manifest for a freshly promoted bundle: no permissions granted, no packs declared. */
56
+ export declare function createDefaultManifest(specVersion?: string): BundleManifest;
@@ -0,0 +1,146 @@
1
+ /** The current spec version this package's default manifests declare. */
2
+ export const CURRENT_SPEC_VERSION = '0.1.0';
3
+ const KNOWN_TOP_LEVEL_KEYS = new Set(['mark', 'permissions', 'uses']);
4
+ const KNOWN_FS_GRANTS = new Set(['read', 'write:cache/']);
5
+ // Simplified but structurally correct semver: MAJOR.MINOR.PATCH with
6
+ // optional prerelease/build metadata. Good enough for a "shape" check —
7
+ // this package does not need to compare or range-match versions.
8
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
9
+ // Bare hostname only: letters/digits/hyphens in dot-separated labels, no
10
+ // scheme (`https://`), no port (`:8080`), no path (`/x`), no wildcard (`*`).
11
+ const HOSTNAME_RE = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(?:\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
12
+ function isStringArray(value) {
13
+ return (Array.isArray(value) && value.every((item) => typeof item === 'string'));
14
+ }
15
+ function isPlainObject(value) {
16
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
17
+ }
18
+ /**
19
+ * Hand-rolled `manifest.json` validation (no schema library — see CLAUDE.md
20
+ * dependency policy). Never throws: malformed JSON, a non-object root, or
21
+ * any other malformed input all come back as `{ ok: false, errors }`.
22
+ *
23
+ * Unknown top-level keys are forward-compatible: they produce warnings, not
24
+ * errors, and are preserved on the returned `manifest` object untouched, so
25
+ * a manifest field introduced by a future spec version doesn't break an
26
+ * older implementation and isn't silently discarded if that manifest is
27
+ * later re-serialized.
28
+ *
29
+ * Judgment call: this forward-compatibility guarantee is *top-level only*,
30
+ * matching the task contract literally. An unrecognized key nested inside
31
+ * `permissions` (e.g. a future `permissions.fs`) is currently dropped
32
+ * rather than preserved, since `permissions` itself is a known, normalized
33
+ * key. Extending preservation to arbitrary nesting depth would be a
34
+ * reasonable follow-up but wasn't asked for here.
35
+ */
36
+ export function parseManifest(json) {
37
+ let raw;
38
+ try {
39
+ raw = JSON.parse(json);
40
+ }
41
+ catch (err) {
42
+ return {
43
+ ok: false,
44
+ errors: [
45
+ `malformed JSON: ${err instanceof Error ? err.message : String(err)}`,
46
+ ],
47
+ };
48
+ }
49
+ if (!isPlainObject(raw)) {
50
+ return { ok: false, errors: ['manifest must be a JSON object'] };
51
+ }
52
+ const obj = raw;
53
+ const errors = [];
54
+ const warnings = [];
55
+ // --- mark (required) ---
56
+ const markRaw = obj.mark;
57
+ if (typeof markRaw !== 'string') {
58
+ errors.push('"mark" is required and must be a string');
59
+ }
60
+ else if (!SEMVER_RE.test(markRaw)) {
61
+ errors.push(`"mark" must be a semver string (got ${JSON.stringify(markRaw)})`);
62
+ }
63
+ // --- permissions (optional) ---
64
+ let permissions;
65
+ if (obj.permissions !== undefined) {
66
+ if (!isPlainObject(obj.permissions)) {
67
+ errors.push('"permissions" must be an object');
68
+ }
69
+ else {
70
+ const permsObj = obj.permissions;
71
+ const perms = {};
72
+ if (permsObj.net !== undefined) {
73
+ if (!isPlainObject(permsObj.net)) {
74
+ errors.push('"permissions.net" must be an object');
75
+ }
76
+ else {
77
+ const netObj = permsObj.net;
78
+ const net = {};
79
+ for (const method of ['get', 'post']) {
80
+ const hosts = netObj[method];
81
+ if (hosts === undefined)
82
+ continue;
83
+ if (!isStringArray(hosts)) {
84
+ errors.push(`"permissions.net.${method}" must be an array of strings`);
85
+ continue;
86
+ }
87
+ const badHosts = hosts.filter((host) => !HOSTNAME_RE.test(host));
88
+ if (badHosts.length > 0) {
89
+ errors.push(`"permissions.net.${method}" must list bare hostnames only ` +
90
+ `(no scheme, port, path, or wildcard) — invalid: ${badHosts.join(', ')}`);
91
+ }
92
+ else {
93
+ net[method] = hosts;
94
+ }
95
+ }
96
+ perms.net = net;
97
+ }
98
+ }
99
+ if (permsObj.bundle !== undefined) {
100
+ if (!isStringArray(permsObj.bundle)) {
101
+ errors.push('"permissions.bundle" must be an array of strings');
102
+ }
103
+ else {
104
+ const badGrants = permsObj.bundle.filter((grant) => !KNOWN_FS_GRANTS.has(grant));
105
+ if (badGrants.length > 0) {
106
+ errors.push(`"permissions.bundle" contains invalid grant(s): ${badGrants.join(', ')} ` +
107
+ `(expected "read" or "write:cache/")`);
108
+ }
109
+ else {
110
+ perms.bundle = permsObj.bundle;
111
+ }
112
+ }
113
+ }
114
+ permissions = perms;
115
+ }
116
+ }
117
+ // --- uses (optional) ---
118
+ let uses;
119
+ if (obj.uses !== undefined) {
120
+ if (!isStringArray(obj.uses)) {
121
+ errors.push('"uses" must be an array of strings');
122
+ }
123
+ else {
124
+ uses = obj.uses;
125
+ }
126
+ }
127
+ // --- unknown top-level keys: forward-compat warning, not an error ---
128
+ for (const key of Object.keys(obj)) {
129
+ if (!KNOWN_TOP_LEVEL_KEYS.has(key)) {
130
+ warnings.push(`unknown manifest key "${key}" (ignored by this implementation)`);
131
+ }
132
+ }
133
+ if (errors.length > 0) {
134
+ return { ok: false, errors };
135
+ }
136
+ const manifest = { ...obj, mark: markRaw };
137
+ if (permissions !== undefined)
138
+ manifest.permissions = permissions;
139
+ if (uses !== undefined)
140
+ manifest.uses = uses;
141
+ return { ok: true, manifest, warnings };
142
+ }
143
+ /** A minimal, valid manifest for a freshly promoted bundle: no permissions granted, no packs declared. */
144
+ export function createDefaultManifest(specVersion = CURRENT_SPEC_VERSION) {
145
+ return { mark: specVersion };
146
+ }