@shayc/open-board-format 1.3.2 → 1.3.3

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +223 -204
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @shayc/open-board-format
2
2
 
3
+ ## 1.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - a58a344: Rewrite the README as a decision-first package guide with accurate API, validation, error, and security documentation.
8
+
3
9
  ## 1.3.2
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -2,28 +2,22 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@shayc/open-board-format)](https://www.npmjs.com/package/@shayc/open-board-format)
4
4
  [![CI](https://github.com/shayc/open-board-format/actions/workflows/ci.yml/badge.svg)](https://github.com/shayc/open-board-format/actions/workflows/ci.yml)
5
- [![License: MIT](https://img.shields.io/npm/l/@shayc/open-board-format.svg)](LICENSE)
6
5
 
7
- A TypeScript toolkit that parses, validates, and creates [Open Board Format](https://www.openboardformat.org/) files — the open standard for Augmentative and Alternative Communication (AAC) boards. OBF (`.obf`) is a JSON file describing a single communication board: buttons, images, sounds, grid layout, metadata. OBZ (`.obz`) is a ZIP archive bundling one or more boards with their media and a `manifest.json`. This package handles both, and powers [AAC Board AI](https://aacboard.app), an offline-first AAC web app.
6
+ Parse, validate, and create [Open Board Format](https://www.openboardformat.org/) (OBF) communication boards (`.obf`) and archives (`.obz`) for augmentative and alternative communication (AAC) applications in TypeScript or JavaScript.
8
7
 
9
- ```
10
- my-board.obz
11
- ├── manifest.json ← table of contents: root board + id-to-path maps
12
- ├── boards/
13
- │ └── home.obf ← one JSON board per file
14
- ├── images/
15
- │ └── dog.png
16
- └── sounds/
17
- └── hello.mp3
18
- ```
8
+ Add AAC board import and export without implementing schemas, manifests, or archive handling yourself.
19
9
 
20
- - **Typed end to end** every type is inferred from a [Zod](https://zod.dev/) schema, and every schema is exported for `safeParse` or composing into your own contracts.
21
- - **Browser and Node.js 22+** — pure ESM, works against `File`, `Blob`, `ArrayBuffer`, or any typed-array view (e.g. Node's `Buffer`).
22
- - **One entry point for either format** — `loadBoard` sniffs the bytes and tells you whether it found an `.obf` board or an `.obz` package.
23
- - **Spec-faithful round trips** — unknown fields are preserved rather than stripped, so vendor extensions allowed by the OBF spec survive `parseOBF` → `stringifyOBF`.
24
- - **Small footprint** — ~11 kB min+gzip including the single runtime dependency ([fflate](https://github.com/101arrowz/fflate)); Zod is a peer, and the package is tree-shakeable with no side effects.
10
+ The package handles format detection, validation, archive creation, and schema access so applications can focus on board experiences instead of file handling.
25
11
 
26
- **Contents:** [Install](#install) · [Quick start](#quick-start) · [Usage](#usage) · [API](#api) · [Errors](#errors) · [Security](#security) · [Scope](#scope) · [Versioning](#versioning) · [Contributing](#contributing) · [Related](#related) · [License](#license)
12
+ ## Features
13
+
14
+ - Load OBF or OBZ through one byte-based format detection API.
15
+ - Create OBZ archives with generated manifests and validated media resources.
16
+ - Use exported [Zod](https://zod.dev/) schemas and inferred TypeScript types.
17
+ - Preserve unknown fields, including vendor extensions.
18
+ - Run as pure ESM in Node.js 22+ and modern browsers.
19
+
20
+ It focuses on board data and archives only. It does not render boards, play media, fetch remote resources, or resolve navigation and media references.
27
21
 
28
22
  ## Install
29
23
 
@@ -31,75 +25,77 @@ my-board.obz
31
25
  npm install @shayc/open-board-format zod
32
26
  ```
33
27
 
34
- `zod` 4 is a peer dependency — npm 7+ installs it automatically, but pnpm and Yarn users should add it explicitly (as shown above).
35
-
36
- ESM only — CommonJS (`require`) is not supported.
28
+ `zod ^4.4.3` is a required peer dependency.
37
29
 
38
30
  ## Quick start
39
31
 
40
32
  ```ts
41
33
  import { loadBoard } from "@shayc/open-board-format";
34
+ import type { BinaryInput, OBFBoard } from "@shayc/open-board-format";
42
35
 
43
- // `file` came from drag-and-drop or <input type="file"> — could be .obf or .obz
44
- const loaded = await loadBoard(file);
36
+ export async function loadRootBoard(input: BinaryInput): Promise<OBFBoard> {
37
+ const loaded = await loadBoard(input);
45
38
 
46
- if (loaded.format === "obf") {
47
- console.log(loaded.board.buttons.length);
48
- } else {
49
- console.log(loaded.archive.rootBoard.buttons.length);
39
+ return loaded.format === "obf" ? loaded.board : loaded.archive.rootBoard;
50
40
  }
51
41
  ```
52
42
 
53
- `loadBoard` accepts a `File`, `Blob`, `ArrayBuffer`, or any typed-array view (e.g. a Node `Buffer`), and throws on invalid input (see [Errors](#errors)). Already holding a JSON string? `parseOBF(json)` returns a validated `OBFBoard` directly.
43
+ `BinaryInput` accepts `File`, `Blob`, `ArrayBuffer`, and `ArrayBufferView` values, including browser files, fetched blobs, typed arrays, and Node.js `Buffer` values. `loadBoard` detects the format from the bytes, not the filename.
54
44
 
55
- In Node.js, read the file first — there's no `File` API outside the browser, but `readFile`'s `Buffer` works directly:
45
+ ## Formats
56
46
 
57
- ```ts
58
- import { readFile } from "node:fs/promises";
59
- import { loadBoard } from "@shayc/open-board-format";
47
+ - **OBF (`.obf`)** is one JSON communication board.
48
+ - **OBZ (`.obz`)** is a ZIP archive containing one or more boards and optional media.
60
49
 
61
- const loaded = await loadBoard(await readFile("my-board.obz"));
50
+ ```text
51
+ my-board.obz
52
+ ├── manifest.json
53
+ ├── boards/
54
+ │ └── home.obf
55
+ ├── images/
56
+ │ └── dog.png
57
+ └── sounds/
58
+ └── hello.mp3
62
59
  ```
63
60
 
64
- ## Usage
61
+ Every OBZ archive requires `manifest.json` at its root, even when it contains only one board.
65
62
 
66
- ### Which function do I need?
63
+ ### Which function should I call?
67
64
 
68
- - **You have a single board (OBF).** Use `parseOBF` for a JSON string, `validateOBF` for an already-parsed object, `loadOBF` for a browser `File`. `stringifyOBF` serializes back out.
69
- - **You have a package of boards plus media (OBZ).** Use `extractOBZ` for a `File`, `Blob`, `ArrayBuffer`, or typed-array view (`loadOBZ` is the same thing, `File`-only); `createOBZ` to build a new one.
70
- - **You don't know which you have.** Use `loadBoard` it sniffs the bytes and returns a `{ format, ... }` union so you don't have to inspect the file extension yourself.
65
+ - Unknown file: `loadBoard(input)`
66
+ - Known `.obf` file: `loadOBF(file)`
67
+ - Known `.obz` input: `extractOBZ(input)`
68
+ - Creating an archive: `createOBZ(...)`
71
69
 
72
- ### Read an OBZ package
70
+ See the [API reference](#api-reference) for the complete function list. Here, `File` means the Web Platform object, not a filesystem path.
73
71
 
74
- ```ts
75
- import { loadOBZ, extractOBZ } from "@shayc/open-board-format";
72
+ ## Examples
76
73
 
77
- // From a File (e.g. drag-and-drop)
78
- const { rootBoard, boards, resources } = await loadOBZ(file);
74
+ ### Read an OBZ archive
79
75
 
80
- // Or from anything else — ArrayBuffer, Blob, Node Buffer, etc.
81
- const parsed = await extractOBZ(buffer);
76
+ ```ts
77
+ import { extractOBZ } from "@shayc/open-board-format";
82
78
 
83
- // Untrusted input? Cap declared uncompressed sizes (see Security)
84
- const guarded = await extractOBZ(buffer, {
85
- limits: { maxTotalOriginalSize: 500e6 },
86
- });
79
+ const archive = await extractOBZ(obzBytes);
87
80
  ```
88
81
 
89
- `rootBoard` is the package's home board — the one `manifest.root` points at, already resolved. `boards` is keyed by board ID and `resources` by archive path; the `manifest` is also returned if you need the raw table of contents.
82
+ The returned `ParsedOBZ` contains:
90
83
 
91
- Resources are raw bytes. To display an image in the browser:
84
+ - `manifest`: the validated OBZ manifest.
85
+ - `rootBoard`: the board referenced by `manifest.root`.
86
+ - `boards`: a `Map` keyed by board ID.
87
+ - `resources`: a `Map` containing the raw bytes of every file entry.
92
88
 
93
- ```ts
94
- const bytes = resources.get("images/hello.png")!;
95
- const url = URL.createObjectURL(new Blob([bytes]));
96
- ```
89
+ `resources` includes the manifest, board files, media, and unrelated extra files. Directory-marker entries are omitted.
90
+
91
+ For untrusted archives, configure [extraction limits](#extraction-limits).
97
92
 
98
- ### Create an OBZ package
93
+ ### Create an OBZ archive
99
94
 
100
- Buttons reference media by ID (`image_id`, `sound_id`); the board's `images`/`sounds` entries carry the archive `path`; the resources map supplies the bytes for each path. Every `path` a board declares must have a matching resource entry, or `createOBZ` throws.
95
+ Buttons reference media by ID. Image and sound records declare archive paths, while the `resources` map supplies the bytes stored at those paths.
101
96
 
102
97
  ```ts
98
+ import { readFile, writeFile } from "node:fs/promises";
103
99
  import { createOBZ } from "@shayc/open-board-format";
104
100
  import type { OBFBoard } from "@shayc/open-board-format";
105
101
 
@@ -111,198 +107,221 @@ const board: OBFBoard = {
111
107
  images: [{ id: "img-1", path: "images/hello.png" }],
112
108
  };
113
109
 
114
- const pngBytes = new Uint8Array(/* ... */);
110
+ const pngBytes = await readFile("hello.png");
115
111
  const resources = new Map([["images/hello.png", pngBytes]]);
116
112
 
117
113
  const blob = await createOBZ([board], "board-1", resources);
114
+ await writeFile("my-board.obz", new Uint8Array(await blob.arrayBuffer()));
118
115
  ```
119
116
 
120
- The `manifest.json` is generated for you boards are written to `boards/<id>.obf` (the id is percent-encoded, so it's always a safe filename), and `rootBoardId` (the second argument) selects the home board.
117
+ `createOBZ` generates the manifest automatically, writes boards to `boards/<encoded-id>.obf`, and uses `rootBoardId` as the archive's entry board.
121
118
 
122
- ### Validate with Zod directly
119
+ Before writing the archive, it checks board IDs, the root board, generated paths, media-path conflicts, and declared media resources. It does not resolve `load_board`, `image_id`, or `sound_id` references.
120
+
121
+ ### Validate a board
123
122
 
124
123
  ```ts
125
124
  import { OBFBoardSchema } from "@shayc/open-board-format";
126
125
 
127
- const result = OBFBoardSchema.safeParse(data);
126
+ export const validateBoard = (value: unknown) =>
127
+ OBFBoardSchema.safeParse(value);
128
+ ```
129
+
130
+ Every public OBF data model has a matching Zod schema export with a `Schema` suffix. The schemas can also be composed with Zod APIs such as `.extend()` and `.pick()`.
128
131
 
129
- if (result.success) {
130
- console.log(result.data.buttons);
131
- } else {
132
- console.error(result.error.issues);
133
- }
132
+ ## Validation behavior
133
+
134
+ Validation returns a parsed copy of the input. Known fields may be normalized during parsing:
135
+
136
+ - Numeric IDs become strings.
137
+ - Empty optional IDs, URLs, and email addresses become `undefined`.
138
+ - Unknown properties are preserved at every loose-object level, with or without an `ext_` prefix.
139
+
140
+ Structural validation checks:
141
+
142
+ - URL and email fields are syntax-checked.
143
+ - Grid dimensions must be integers from 1 through 100.
144
+ - `grid.order` must exactly match the declared row and column counts.
145
+ - Positioned buttons must provide `top`, `left`, `width`, and `height`, each between 0 and 1.
146
+ - Format versions must match `open-board-*`; they are not restricted to `open-board-0.1`.
147
+ - An OBZ manifest root must appear in `paths.boards`.
148
+
149
+ Validation is not a complete OBF conformance or graph-integrity check. It does not enforce:
150
+
151
+ - Unique button, image, or sound IDs.
152
+ - Resolution of `grid.order`, `image_id`, `sound_id`, or `load_board` references.
153
+ - A consistent positioning mode across every button on a board.
154
+ - BCP 47 locale syntax, color syntax, MIME correctness, or safe HTML.
155
+ - During extraction, the existence of manifest-declared media files or their agreement with board media records.
156
+
157
+ Add application-specific checks after parsing when those guarantees matter.
158
+
159
+ ## API reference
160
+
161
+ ### Functions
162
+
163
+ #### Board data
164
+
165
+ | Function | Returns | Behavior |
166
+ | --------------------- | ------------------- | ----------------------------------------------------------- |
167
+ | `parseOBF(json)` | `OBFBoard` | Parse JSON and validate a board; strips a leading UTF-8 BOM |
168
+ | `validateOBF(value)` | `OBFBoard` | Validate and normalize an unknown value |
169
+ | `stringifyOBF(board)` | `string` | Serialize as two-space JSON without revalidating |
170
+ | `loadOBF(file)` | `Promise<OBFBoard>` | Read a `File`, then parse and validate it |
171
+
172
+ #### Archives and format detection
173
+
174
+ | Function | Returns | Behavior |
175
+ | -------------------------------------------- | ---------------------- | ------------------------------------------------------------------- |
176
+ | `loadBoard(input, options?)` | `Promise<LoadedBoard>` | Detect OBF or OBZ from the bytes, then load it |
177
+ | `loadOBZ(file, options?)` | `Promise<ParsedOBZ>` | `File` convenience wrapper around `extractOBZ` |
178
+ | `extractOBZ(input, options?)` | `Promise<ParsedOBZ>` | Extract and validate the manifest and every manifest-declared board |
179
+ | `createOBZ(boards, rootBoardId, resources?)` | `Promise<Blob>` | Validate and package boards and resources with a generated manifest |
180
+ | `parseManifest(json)` | `OBFManifest` | Parse and validate manifest JSON |
181
+
182
+ #### ZIP utilities
183
+
184
+ | Function | Returns | Behavior |
185
+ | ------------------------- | ---------------------------------- | -------------------------------------------------------- |
186
+ | `isZip(buffer)` | `boolean` | Check whether an `ArrayBuffer` has a ZIP signature |
187
+ | `zip(entries)` | `Promise<Uint8Array>` | Compress a map of paths to `Uint8Array` or `ArrayBuffer` |
188
+ | `unzip(buffer, options?)` | `Promise<Map<string, Uint8Array>>` | Extract an `ArrayBuffer` and omit directory markers |
189
+
190
+ ### Types and schemas
191
+
192
+ `LoadedBoard` is a discriminated union:
193
+
194
+ ```ts
195
+ { format: "obf", board: OBFBoard }
196
+ | { format: "obz", archive: ParsedOBZ }
134
197
  ```
135
198
 
136
- ## API
137
-
138
- One naming convention covers the whole surface: `parse*` takes a JSON string, `validate*` takes an already-parsed object, `load*` takes a browser `File`, `stringify*` returns a JSON string — and `extractOBZ`/`loadBoard` also accept a `Blob`, `ArrayBuffer`, or typed-array view (e.g. a Node `Buffer`), for use outside the browser.
139
-
140
- ### OBF (single board)
141
-
142
- | Function | Returns | Description |
143
- | --------------------- | ------------------- | ------------------------------------------------------------ |
144
- | `parseOBF(json)` | `OBFBoard` | Parse a JSON string into a validated `OBFBoard` |
145
- | `validateOBF(data)` | `OBFBoard` | Validate an unknown object as `OBFBoard` (throws on failure) |
146
- | `stringifyOBF(board)` | `string` | Serialize an `OBFBoard` to a JSON string |
147
- | `loadOBF(file)` | `Promise<OBFBoard>` | Load an `OBFBoard` from a browser `File` |
148
-
149
- ### OBZ (board package)
150
-
151
- | Function | Returns | Description |
152
- | -------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------- |
153
- | `loadOBZ(file, options?)` | `Promise<ParsedOBZ>` | Load an OBZ package from a browser `File` |
154
- | `extractOBZ(archive, options?)` | `Promise<ParsedOBZ>` | Extract boards, manifest, root board, and resources from a `File`, `Blob`, `ArrayBuffer`, or typed-array view |
155
- | `createOBZ(boards, rootBoardId, resources?)` | `Promise<Blob>` | Create an OBZ package as a `Blob` |
156
- | `parseManifest(json)` | `OBFManifest` | Parse a `manifest.json` string into a validated `OBFManifest` |
157
-
158
- ### Format detection
159
-
160
- | Function | Returns | Description |
161
- | ---------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
162
- | `loadBoard(input, options?)` | `Promise<LoadedBoard>` | Detect OBF vs OBZ from a `File`, `Blob`, `ArrayBuffer`, or typed-array view and load it; returns a `LoadedBoard` union |
163
-
164
- ### Utilities
165
-
166
- | Function | Returns | Description |
167
- | -------------------------- | ---------------------------------- | -------------------------------------------------------- |
168
- | `isZip(archive)` | `boolean` | Check if an `ArrayBuffer` starts with a ZIP magic number |
169
- | `zip(entries)` | `Promise<Uint8Array>` | Create a ZIP from a map of paths to buffers |
170
- | `unzip(archive, options?)` | `Promise<Map<string, Uint8Array>>` | Extract a ZIP into a map of paths to `Uint8Array` |
171
-
172
- ### Types
173
-
174
- | Type | Description |
175
- | --------------------- | -------------------------------------------------------------------------------------------------------------- |
176
- | `OBFBoard` | A single communication board |
177
- | `OBFGrid` | Grid layout (rows, columns, order) |
178
- | `OBFButton` | A button on the board |
179
- | `OBFButtonAction` | Button action (spelling or specialty) |
180
- | `OBFSpellingAction` | Spelling action (e.g., `+s`) |
181
- | `OBFSpecialtyAction` | Specialty action (e.g., `:clear`) |
182
- | `OBFLoadBoard` | Reference to load another board |
183
- | `OBFMedia` | Common media properties (base for `OBFImage` and `OBFSound`) |
184
- | `OBFImage` | An image resource (extends `OBFMedia`) |
185
- | `OBFSound` | A sound resource (alias of `OBFMedia`) |
186
- | `OBFSymbolInfo` | Symbol set reference |
187
- | `OBFManifest` | OBZ package manifest |
188
- | `ParsedOBZ` | Return type of `extractOBZ` / `loadOBZ` — `{ manifest, boards, rootBoard, resources }` |
189
- | `LoadedBoard` | Return type of `loadBoard` — `{ format: "obz", archive } \| { format: "obf", board }` |
190
- | `BinaryInput` | Input type of `loadBoard` / `extractOBZ` — `File \| Blob \| ArrayBuffer \| ArrayBufferView` |
191
- | `UnzipLimits` | Optional extraction caps — `{ maxEntrySize?, maxTotalOriginalSize?, maxEntries? }` (see [Security](#security)) |
192
- | `UnzipOptions` | Options for `unzip` / `extractOBZ` / `loadOBZ` / `loadBoard` — `{ limits?: UnzipLimits }` |
193
- | `OBFID` | Unique identifier (string, coerced from number) |
194
- | `OBFFormatVersion` | Format version string (e.g., `open-board-0.1`) |
195
- | `OBFLicense` | Licensing information |
196
- | `OBFLocaleCode` | BCP 47 locale code |
197
- | `OBFLocalizedStrings` | Key-value string translations |
198
- | `OBFStrings` | Multi-locale string translations |
199
-
200
- ### Schemas
201
-
202
- Every type above except `ParsedOBZ`, `LoadedBoard`, `BinaryInput`, `UnzipLimits`, and `UnzipOptions` is exported alongside a matching Zod schema with a `Schema` suffix — `OBFBoard` → `OBFBoardSchema`, `OBFManifest` → `OBFManifestSchema`, and so on. Import any of them to validate with `safeParse`/`parse` or to compose into your own schemas:
199
+ `ParsedOBZ` provides the validated archive contents:
203
200
 
204
201
  ```ts
205
- import { OBFButtonSchema, OBFManifestSchema } from "@shayc/open-board-format";
202
+ interface ParsedOBZ {
203
+ manifest: OBFManifest;
204
+ boards: Map<string, OBFBoard>;
205
+ rootBoard: OBFBoard;
206
+ resources: Map<string, Uint8Array>;
207
+ }
206
208
  ```
207
209
 
208
- ## Errors
210
+ Main exports include:
211
+
212
+ - Board, action, media, metadata, and manifest types.
213
+ - Matching Zod schemas, including `OBFBoardSchema` and `OBFManifestSchema`.
214
+ - Input and archive types: `BinaryInput`, `ParsedOBZ`, and `LoadedBoard`.
215
+ - Structured errors through `OBFError` and its related types.
216
+
217
+ ### Errors
209
218
 
210
- Every failure throws an `OBFError`. Branch on `error.info.code` — a discriminated union where each `code` carries exactly the fields relevant to that failure. Don't match on `error.message`; the message is human-readable and may change between releases.
219
+ Expected parsing, validation, and archive-domain failures from the high-level APIs use `OBFError`.
220
+
221
+ Branch on `error.info.code`, not `error.message`.
211
222
 
212
223
  ```ts
213
224
  import { loadBoard, OBFError } from "@shayc/open-board-format";
225
+ import type { BinaryInput } from "@shayc/open-board-format";
226
+
227
+ export async function openBoard(input: BinaryInput) {
228
+ try {
229
+ return await loadBoard(input);
230
+ } catch (error) {
231
+ if (!(error instanceof OBFError)) throw error;
214
232
 
215
- try {
216
- await loadBoard(file);
217
- } catch (error) {
218
- if (!(error instanceof OBFError)) throw error;
219
-
220
- switch (error.info.code) {
221
- case "missing-resource":
222
- // `kind`, `mediaId`, and `path` are all typed and present here
223
- console.warn(`Missing ${error.info.kind} at ${error.info.path}`);
224
- break;
225
- case "invalid-board":
226
- // `issues` is the Zod issue list — which field failed and why
233
+ if (error.info.code === "invalid-board") {
227
234
  console.error(error.info.issues);
228
- break;
229
- default:
235
+ } else {
230
236
  console.error(error.message);
237
+ }
238
+
239
+ throw error;
231
240
  }
232
241
  }
233
242
  ```
234
243
 
235
- The `code` values, grouped by what they describe:
236
-
237
- | Group | `info.code` | Key fields (on `info`) |
238
- | ----------- | ------------------- | --------------------------------------------- |
239
- | Decoding | `not-json` | `source` |
240
- | | `not-zip` | |
241
- | | `unreadable-zip` | — |
242
- | | `archive-too-large` | `limit`, `path`, and the tripped cap's fields |
243
- | Validation | `invalid-board` | `issues`, `boardId?` |
244
- | | `invalid-manifest` | `issues` |
245
- | Read (OBZ) | `missing-manifest` | |
246
- | | `missing-board` | `boardId`, `path` |
247
- | | `board-id-mismatch` | `path`, `declaredId`, `actualId` |
248
- | Write (OBZ) | `unknown-root` | `rootBoardId` |
249
- | | `duplicate-board` | `boardId` |
250
- | | `missing-resource` | `kind`, `mediaId`, `path` |
251
- | | `conflicting-paths` | `kind`, `mediaId`, `paths` |
252
- | | `path-collision` | `path` |
253
- | | `zip-failed` | |
254
- | Internal | `internal` | `detail` |
255
-
256
- `OBFErrorInfo` and `OBFErrorCode` are exported for exhaustive handling.
257
-
258
- - The underlying error, when there is one, is always on the standard `error.cause` — never duplicated on `info`.
259
- - Validation failures (`invalid-board`, `invalid-manifest`) put the `ZodError` on `error.cause`, so `z.treeifyError(error.cause)` gives nested, UI-friendly output, while `info.issues` (Zod's issue type, re-exported as `OBFIssue`) gives you the flat list directly.
260
- - For `not-json` / `*-zip` failures, `error.cause` is the underlying parser or fflate error.
261
- - An `internal` code signals a bug in this library that callers can't recover from please report it.
262
-
263
- **Re-zipping an extracted `.obz` by hand?** Zip the folder's _contents_, not the folder itself. If `manifest.json` ends up nested inside a top-level folder in the archive, extraction fails with `missing-manifest` even though the file is right there.
244
+ <details>
245
+ <summary><strong>Error codes</strong></summary>
246
+
247
+ | Area | `info.code` | Additional fields |
248
+ | -------------- | ------------------- | -------------------------------------------------- |
249
+ | Decoding | `not-json` | `source` |
250
+ | Decoding | `not-zip` | — |
251
+ | Decoding | `unreadable-zip` | |
252
+ | Limits | `archive-too-large` | `limit`, `path`, and fields for the exceeded limit |
253
+ | Validation | `invalid-board` | `issues`, `boardId?` |
254
+ | Validation | `invalid-manifest` | `issues` |
255
+ | OBZ extraction | `missing-manifest` | |
256
+ | OBZ extraction | `missing-board` | `boardId`, `path` |
257
+ | OBZ extraction | `board-id-mismatch` | `path`, `declaredId`, `actualId` |
258
+ | OBZ creation | `unknown-root` | `rootBoardId` |
259
+ | OBZ creation | `duplicate-board` | `boardId` |
260
+ | OBZ creation | `missing-resource` | `kind`, `mediaId`, `path` |
261
+ | OBZ creation | `conflicting-paths` | `kind`, `mediaId`, `paths` |
262
+ | OBZ creation | `path-collision` | `path` |
263
+ | OBZ creation | `zip-failed` | |
264
+ | Internal | `internal` | `detail` |
265
+
266
+ </details>
267
+
268
+ Validation failures expose the underlying `ZodError` as `error.cause` and provide its flat issue list through `error.info.issues`.
269
+
270
+ `not-json`, `unreadable-zip`, and `zip-failed` expose the underlying parser or ZIP error as `error.cause`. An `internal` error indicates a library invariant failure and should be reported.
271
+
272
+ Direct schema `.parse()` calls throw `ZodError` rather than `OBFError`.
264
273
 
265
274
  ## Security
266
275
 
267
- OBZ archives are untrusted input. To guard against zip bombs, pass `limits` (via `UnzipOptions`) to `loadBoard` / `loadOBZ` / `extractOBZ` / `unzip`:
276
+ Treat OBZ archives and their contents as untrusted input.
277
+
278
+ ### Extraction limits
268
279
 
269
280
  ```ts
270
- const parsed = await extractOBZ(buffer, {
271
- limits: {
272
- maxEntrySize: 100e6, // any single entry, in bytes
273
- maxTotalOriginalSize: 500e6, // sum of all entries, in bytes
274
- maxEntries: 10_000, // entry count, including directory entries
275
- },
276
- });
281
+ import { extractOBZ } from "@shayc/open-board-format";
282
+ import type { BinaryInput } from "@shayc/open-board-format";
283
+
284
+ export function extractUntrusted(input: BinaryInput) {
285
+ return extractOBZ(input, {
286
+ limits: {
287
+ // Examples only—choose limits appropriate for your application.
288
+ maxEntrySize: 100 * 1024 ** 2, // 100 MiB
289
+ maxTotalOriginalSize: 500 * 1024 ** 2, // 500 MiB
290
+ maxEntries: 10_000,
291
+ },
292
+ });
293
+ }
277
294
  ```
278
295
 
279
- Size limits are checked per entry against the uncompressed size declared in the archive's ZIP metadata, before that entry is inflated; `maxEntries` caps how many entries are processed at all. Entries accepted before a later entry trips a limit have already been inflated, but total allocation stays bounded by the caps. Exceeding a limit aborts extraction and rejects with an `OBFError` whose code is `archive-too-large` (`info` carries `limit`, the entry `path` that tripped it, and `maxBytes`/`declaredBytes` for the size limits or `maxEntries`/`entryCount` for the count). No limits are applied by default. A lying header can't force larger allocations — fflate allocates output buffers at exactly the declared size — so the declared-size caps bound memory use.
280
-
281
- Entry paths are not sanitized — if you write extracted resources to disk, validate paths yourself first to avoid directory traversal.
296
+ Extraction limits are optional and disabled by default. Entry and total-size limits are checked against ZIP metadata before inflation, while `maxEntries` caps the number of entries processed.
282
297
 
283
- Found a security issue? Open a private advisory at [github.com/shayc/open-board-format/security/advisories/new](https://github.com/shayc/open-board-format/security/advisories/new).
298
+ These limits reduce risk, but they are not strict memory guarantees. ZIP metadata can be dishonest, and stored entries can produce more output than their declared uncompressed size.
284
299
 
285
- ## Scope
300
+ Also enforce a limit on the compressed archive size before passing it to this package. Use process isolation or a streaming design when your threat model requires a strict memory boundary.
286
301
 
287
- What this library deliberately does not do:
302
+ ### Other boundaries
288
303
 
289
- - **No network I/O** media referenced by `url` or `data_url` is not fetched; resolving external media is up to you.
290
- - **No rendering** it parses and validates data; drawing boards and playing sounds belong to your app.
291
- - **No default extraction limits, no path sanitization** size caps are opt-in via `UnzipOptions`; see [Security](#security) before writing archive contents to disk.
292
- - **No referential integrity checks** — a `grid.order` id with no matching button, or an `image_id`/`sound_id` with no matching image/sound, is not flagged. Resolving references is up to your rendering layer.
304
+ - Archive entry paths are not sanitized. Validate them before writing files to disk to prevent directory traversal.
305
+ - `description_html` is not sanitized. Sanitize it before inserting it into the DOM.
306
+ - URLs and `data_url` values are validated syntactically but are never fetched.
293
307
 
294
- ## Versioning
308
+ Found a vulnerability? Email [shayc@outlook.com](mailto:shayc@outlook.com) rather than opening a public issue.
295
309
 
296
- Semver. The public API — every exported function, type, and Zod schema — is stable; breaking changes ship as major releases. See [CHANGELOG.md](CHANGELOG.md).
310
+ ## Runtime
297
311
 
298
- ## Contributing
312
+ - Pure ESM for Node.js `>=22` and modern browsers; CommonJS is unsupported.
313
+ - Browser environments must provide `Blob`, `File`, `TextEncoder`, and `TextDecoder`.
314
+ - `fflate` is the only runtime dependency; `zod ^4.4.3` is a peer dependency.
315
+ - CI covers Node.js 22, 24, and 26. Browser engines are not currently tested in CI.
299
316
 
300
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup (Node 22+, Vitest, the changeset workflow).
317
+ ## Project
301
318
 
302
- ## Related
319
+ The public API follows semantic versioning. Breaking changes to exported APIs, schemas, or documented behavior ship as major releases.
303
320
 
304
- - [Open Board Format specification](https://www.openboardformat.org/docs) — the official standard and format documentation. A 1:1 mirror is kept at [docs/external/open-board-format.md](docs/external/open-board-format.md) for offline reference.
305
- - [AAC Board AI](https://github.com/shayc/aac-board-ai) an offline-first AAC web app built on this package, using on-device browser AI for grammar, tone, and translation ([live app](https://aacboard.app)).
321
+ - **Changelog:** See [CHANGELOG.md](CHANGELOG.md).
322
+ - **Support:** [Open an issue](https://github.com/shayc/open-board-format/issues) with a minimal reproduction, package version, runtime, and bundler where applicable.
323
+ - **Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md) for development commands, tests, and the changeset workflow.
324
+ - **Specification:** See the [official OBF documentation](https://www.openboardformat.org/docs) or the included [offline mirror](docs/external/open-board-format.md).
306
325
 
307
326
  ## License
308
327
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shayc/open-board-format",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Parse, validate, and create Open Board Format (.obf/.obz) files — the open standard for Augmentative and Alternative Communication (AAC) boards. TypeScript, browser and Node.js.",
5
5
  "license": "MIT",
6
6
  "author": "Shay Cojocaru <shayc@outlook.com>",