Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
3633111
test(markdown): define parser preflight size RED
seonghobae Aug 11, 2026
c5f4d33
fix(markdown): define bounded HTML conversion policy
seonghobae Aug 11, 2026
3fd1ac5
fix(markdown): preflight HTML input before parsing
seonghobae Aug 11, 2026
b3b23f5
test(markdown): cover bounded conversion policy
seonghobae Aug 11, 2026
3c13126
test(package): verify bounded HTML conversion in packed consumers
seonghobae Aug 11, 2026
79d44e5
docs(markdown): record active resource-bound contract
seonghobae Aug 11, 2026
a2b635b
test(markdown): bind package barrel to resource ceiling
seonghobae Aug 11, 2026
4da498e
feat(markdown): expose conversion resource policy
seonghobae Aug 11, 2026
2e0b6a7
feat(markdown): expose resource bounds from root package
seonghobae Aug 11, 2026
c773145
test(api): verify HTML conversion resource exports
seonghobae Aug 11, 2026
333240c
test(markdown): verify public resource policy exports
seonghobae Aug 11, 2026
c6c4c7f
test(package): verify resource policy exports in packed consumers
seonghobae Aug 11, 2026
2e41614
test(markdown): define Markdown parser preflight RED
seonghobae Aug 11, 2026
431a4f3
test(markdown): prove preflight precedes parser allocation
seonghobae Aug 11, 2026
6bcce5f
test(markdown): reach lexer allocation boundary
seonghobae Aug 11, 2026
abaea8c
fix(markdown): add parser input resource policy
seonghobae Aug 11, 2026
fbac2d6
fix(markdown): enforce resource-bound public conversion
seonghobae Aug 11, 2026
b20fa85
fix(editor): preflight Markdown ingress resources
seonghobae Aug 11, 2026
7e1f3ee
test(markdown): cover bounded conversion policy
seonghobae Aug 11, 2026
c39c251
feat(markdown): expose bounded parser contract
seonghobae Aug 11, 2026
2b04d82
feat(api): publish Markdown resource bounds
seonghobae Aug 11, 2026
e8d8c97
test(api): verify Markdown resource exports
seonghobae Aug 11, 2026
d7260b6
test(package): require Markdown resource contract
seonghobae Aug 11, 2026
b7722ce
test(package): verify bounded Markdown consumers
seonghobae Aug 11, 2026
7915172
test(markdown): prove default bounded conversion
seonghobae Aug 11, 2026
c154e09
test(reliability): prove plain-text parser preflight
seonghobae Aug 11, 2026
2f9ebc1
test(reliability): trigger hosted plain-text RED
seonghobae Aug 11, 2026
3f74b05
test(reliability): cover plain-text parser bounds
seonghobae Aug 11, 2026
895905c
fix(reliability): bound plain-text parser input
seonghobae Aug 11, 2026
d753043
docs(markdown): document active plain-text parser bounds
seonghobae Aug 11, 2026
45e7a5d
docs(api): clarify plain-text resource ceilings
seonghobae Aug 11, 2026
0cea5a4
test(reliability): compile valid plain-text limits
seonghobae Aug 11, 2026
5f54bc4
test(reliability): define public Markdown resource option RED
seonghobae Aug 11, 2026
acd11c7
test(reliability): require exact adapter UTF-8 ceilings
seonghobae Aug 11, 2026
9172cc7
fix(reliability): enforce caller Markdown ceilings on adapters
seonghobae Aug 11, 2026
d43a4d6
feat(package): export Markdown adapter resource options
seonghobae Aug 11, 2026
4e495be
feat(package): expose normalize resource option from root
seonghobae Aug 11, 2026
7fa0430
test(package): verify adapter resource ceilings in packed consumers
seonghobae Aug 11, 2026
59ddce8
test(reliability): prove email language preflight gap
seonghobae Aug 11, 2026
9c9968b
fix(reliability): preflight email language metadata
seonghobae Aug 11, 2026
3f56e9c
test(reliability): exercise public email language boundary
seonghobae Aug 11, 2026
4975ea1
test(reliability): cover omitted email language metadata
seonghobae Aug 11, 2026
39af46f
test(reliability): expose unbounded email title metadata
seonghobae Aug 11, 2026
e39f53c
fix(reliability): bound email title metadata
seonghobae Aug 11, 2026
905fcc0
test(markdown): avoid full DOM NodeList copy
seonghobae Aug 11, 2026
23464ee
test(editor): reject invalid runtime modes
seonghobae Aug 11, 2026
d5a7b80
fix(markdown): avoid full DOM NodeList copy
seonghobae Aug 11, 2026
f47e836
fix(editor): validate runtime serialization mode
seonghobae Aug 11, 2026
efda102
test(envelope): consolidate canonical output boundary
seonghobae Aug 11, 2026
763bb69
fix(envelope): bound canonical output encoding
seonghobae Aug 11, 2026
bd3b38c
test(envelope): validate runtime encoding options
seonghobae Aug 11, 2026
3add74c
test(envelope): cover canonical output limits
seonghobae Aug 11, 2026
97345ba
feat(envelope): export canonical encoding options
seonghobae Aug 11, 2026
1b6a6ec
test(reliability): consolidate safe-link resource boundary
seonghobae Aug 11, 2026
f2c05f8
fix(reliability): bound safe-link validation input
seonghobae Aug 11, 2026
e6d90af
feat(reliability): expose bounded safe-link contract
seonghobae Aug 11, 2026
a529776
test(reliability): cover safe-link resource contracts
seonghobae Aug 11, 2026
7346b82
feat(reliability): export safe-link resource options
seonghobae Aug 11, 2026
b6f9ac6
test(reliability): cover explicit default safe-link bound
seonghobae Aug 11, 2026
fb2a341
test(data-integrity): reject malformed plain-text options
seonghobae Aug 12, 2026
684d8ba
fix(data-integrity): validate plain-text runtime options
seonghobae Aug 12, 2026
69457e6
test(data-integrity): cover plain-text option shape guards
seonghobae Aug 12, 2026
4a735f7
test(data-integrity): reject invalid email document mode
seonghobae Aug 13, 2026
0504d66
fix(data-integrity): validate email document mode
seonghobae Aug 13, 2026
5283215
test(markdown): reject non-string sources before coercion
seonghobae Aug 13, 2026
6379dff
fix(markdown): reject invalid source values before parsing
seonghobae Aug 13, 2026
5595280
test(markdown): specify fail-closed option bags
seonghobae Aug 13, 2026
83d4b4c
test(markdown): keep option-bag RED type-correct
seonghobae Aug 13, 2026
b095876
test(markdown): correct document marker
seonghobae Aug 13, 2026
09fca53
fix(markdown): enforce option contracts
seonghobae Aug 13, 2026
1513b34
test(markdown): preflight email direction before parser
seonghobae Aug 14, 2026
1359aaa
fix(markdown): preflight email metadata before parsing
seonghobae Aug 14, 2026
f5c7133
test(markdown): fail closed on hostile HTML conversion options
seonghobae Aug 20, 2026
02bacef
test(markdown): cover hostile HTML option bag shapes
seonghobae Aug 20, 2026
ae81bb3
fix(markdown): validate HTML conversion option bags before parsing
seonghobae Aug 20, 2026
4fedaeb
test(markdown): cover option bag reflection branches
seonghobae Aug 20, 2026
c422a83
test(markdown): reject hostile non-string HTML before access
seonghobae Aug 20, 2026
41bbc9d
fix(markdown): reject non-string HTML before source access
seonghobae Aug 20, 2026
8eadd7c
docs(markdown): align HTML boundary contract with runtime validation
seonghobae Aug 20, 2026
2f31bee
docs(markdown): keep next-release contract wording canonical
seonghobae Aug 20, 2026
afb9f05
test(markdown): pin proxy reflection boundary
seonghobae Aug 20, 2026
68aa1ed
docs(markdown): correct proxy reflection contract
seonghobae Aug 20, 2026
19e5052
test(markdown): reject ambient browser authority in packed consumers
seonghobae Aug 25, 2026
ef5b707
fix(markdown): avoid ambient window accessor execution
seonghobae Aug 25, 2026
3123981
fix(markdown): neutralize Turndown ambient window probe
seonghobae Aug 25, 2026
e1345c4
fix(markdown): match pinned Turndown window probe
seonghobae Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions docs/doctoring/html-to-markdown-resource-bounds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# HTML-to-Markdown resource bounds

Status: Implemented on active PR

## Purpose

Inkspan's standalone `htmlToMarkdown()` boundary accepts caller-provided HTML and
parses it through either a detached browser template or Turndown's browserless
parser. Protected shipped truth is currently
`main@3b38ead2d00f44eb578d0689087b9293b3dabe1e`; this active PR carries the
next-release bounded reliability contract and remains unintegrated. The contract
does not change transport, persistence, credentials, models, tenancy,
authorization, collaboration-provider, or network authority.

## Active-PR contract

`HtmlToMarkdownOptions.maxHtmlBytes` is an optional positive safe integer. The
active implementation defaults to 16 MiB and rejects configured values above a
64 MiB hard maximum. The public runtime option bag is snapshotted before source
sizing or parser work: only ordinary/null-prototype enumerable data properties
for `includeImageAlt` and `maxHtmlBytes` are accepted. Accessor-backed options
are rejected without invoking their getters. Exotic prototypes, symbol or
unknown keys, non-enumerable properties, malformed `includeImageAlt`, and
hostile reflection failures also fail closed through the stable resource error.

JavaScript Proxy meta-object traps are a distinct boundary: inspecting an
untrusted option object's prototype/descriptors necessarily performs language
reflection and can execute a Proxy `getPrototypeOf`, `ownKeys`, or
`getOwnPropertyDescriptor` trap. Inkspan does not claim otherwise. A thrown or
malformed reflection result is normalized to the payload-redacted
`HtmlToMarkdownResourceError` contract and is never reflected to diagnostics;
callers that require a no-caller-code boundary must pass ordinary or
null-prototype data objects rather than Proxies.

The HTML source itself must be a primitive string. Non-string runtime input is
rejected before reading caller properties, `TextEncoder` coercion, browser DOM
materialization, or browserless parsing. For accepted strings, Inkspan first
compares JavaScript UTF-16 code-unit length to the selected byte ceiling. Because
each code unit contributes at least one UTF-8 byte, that check can reject inputs
that are certainly oversized without allocating a complete encoded buffer.
Inputs not rejected by that lower bound receive an exact UTF-8 byte-length check
before any browser DOM or browserless Turndown parser is reached.

Oversized input raises a stable redacted error with name
`HtmlToMarkdownResourceError`, code `input_too_large`, and no caller-controlled
HTML in its message. Non-string input uses code `invalid_input`; malformed
runtime options/resource-limit configuration use code `invalid_configuration`.
Accepted safe-link, strict inline-raster, image-alt, normalization,
browserless-package, and deterministic conversion semantics remain unchanged.

## Verification

The active PR carries machine tests that prove:

- obvious oversize does not invoke `TextEncoder.encode()` or browser template
creation;
- hostile non-string input is rejected before caller property access, encoding,
or parser work and without leaking caller-thrown values;
- non-ASCII input still uses exact UTF-8 byte accounting;
- exact-boundary input remains accepted;
- accessor-backed option properties are rejected without executing their
getters, while a hostile Proxy prototype-reflection trap is explicitly proven
to execute once and its private thrown value is normalized/redacted;
- malformed option bags and wrong-type, fractional, zero, and above-maximum
limits fail closed without reflecting input content; and
- the packed ESM runtime and strict TypeScript consumer exercise the public
resource-bound surface while retaining the no-network/no-credential package
authority check.

This document is active-PR truth only. It must not be represented as protected
behavior until the implementation is integrated into protected `main`. Issue
#118 continues to own the exact `0.6.0` protected release-candidate operational
boundary, so this next-release lane remains Draft and unmerged while that
identity is active.

## Rollback

Before protected integration, rollback removes this active-PR option, resource
policy module, regression tests, packed-consumer assertions, and this doctoring
record together. After integration, reducing the documented hard ceiling or
changing error codes/messages is a public compatibility decision and requires
versioned release treatment.
24 changes: 24 additions & 0 deletions docs/plain-text-projection.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ default policy. Informative image alternatives remain in reading order, which
allows indexing and AI workflows to retain author-supplied non-visual meaning
without receiving image bytes.

## Next-release parser resource bounds

Status: `implemented_on_active_pr` in #174. Protected `main` does not yet expose
these plain-text-specific options, so this section is not a shipped-release
claim.

The active contract accepts `maxMarkdownBytes` on `markdownToPlainText()` and
checks the exact UTF-8 byte size before the Marked lexer materializes tokens.
The inherited Markdown parser policy defaults to 16 MiB and rejects configured
limits above the 64 MiB hard maximum. Invalid configuration and oversized input
fail closed through the same stable, payload-redacted Markdown resource errors
used by the shared Markdown package boundary.

`htmlToPlainText()` additionally accepts `maxHtmlBytes`. That ceiling is checked
before HTML normalization, and `maxMarkdownBytes` is checked again on the
normalized Markdown before it enters the plain-text lexer. The two bounds are
intentionally independent because HTML normalization can change representation
size. Accepted-input reading-order, list/table/code, image-alt, link-label and
raw-HTML omission semantics remain unchanged.

These local parser bounds are defense in depth, not transport or persistence
authority. Hosts still own request/ingress limits, authorization, tenancy,
durable storage, retention and operational admission control.

## Runtime and interoperability boundary

`markdownToPlainText` uses Marked's lexer and does not execute raw HTML, open a
Expand Down
104 changes: 101 additions & 3 deletions scripts/verify-markdown-subpath-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,26 +108,75 @@ Object.defineProperty(globalThis, 'document', {
configurable: true,
get() { throw new Error('ambient document access is forbidden'); },
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
get() { throw new Error('ambient window access is forbidden'); },
});
const markdown = await import('${packageJson.name}/markdown');
const {
DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES,
DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
HtmlToMarkdownResourceError,
MarkdownToHtmlResourceError,
htmlToMarkdown,
markdownToEmailHtml,
markdownToHtml,
markdownToPlainText,
normalizeMarkdown,
} = markdown;
assert.equal(DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, 16_777_216);
assert.equal(MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, 67_108_864);
assert.equal(DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, 16_777_216);
assert.equal(MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, 67_108_864);
const safeHtml = markdownToHtml('[safe](https://example.com)');
assert.equal(safeHtml.includes('href="https://example.com"'), true);
assert.doesNotMatch(markdownToHtml('[unsafe](javascript:alert(1))'), /href=/u);
let markdownBoundedFailure;
try {
markdownToHtml('oversized', { maxMarkdownBytes: 4 });
} catch (error) {
markdownBoundedFailure = error;
}
assert.equal(markdownBoundedFailure instanceof MarkdownToHtmlResourceError, true);
assert.equal(markdownBoundedFailure?.name, 'MarkdownToHtmlResourceError');
assert.equal(markdownBoundedFailure?.code, 'input_too_large');
assert.equal(
markdownBoundedFailure?.message,
'Markdown-to-HTML input exceeds the configured byte limit.',
);
assert.equal(htmlToMarkdown('<p>Alpha <strong>Beta</strong></p>'), 'Alpha **Beta**');
let htmlBoundedFailure;
try {
htmlToMarkdown('<p>oversized</p>', { maxHtmlBytes: 4 });
} catch (error) {
htmlBoundedFailure = error;
}
assert.equal(htmlBoundedFailure instanceof HtmlToMarkdownResourceError, true);
assert.equal(htmlBoundedFailure?.name, 'HtmlToMarkdownResourceError');
assert.equal(htmlBoundedFailure?.code, 'input_too_large');
assert.equal(
htmlBoundedFailure?.message,
'HTML-to-Markdown input exceeds the configured byte limit.',
);
assert.equal(markdownToPlainText('**Alpha** [Beta](https://example.com)'), 'Alpha Beta');
assert.equal(normalizeMarkdown('**Alpha**').includes('**Alpha**'), true);
assert.throws(
() => normalizeMarkdown('oversized', { maxMarkdownBytes: 4 }),
(error) => error instanceof MarkdownToHtmlResourceError && error.code === 'input_too_large',
);
const email = markdownToEmailHtml('Hello', {
fullDocument: true,
languageTag: 'ko-kr',
textDirection: 'ltr',
});
assert.equal(email.includes('<html lang="ko-KR" dir="ltr">'), true);
assert.throws(
() => markdownToEmailHtml('oversized', { maxMarkdownBytes: 4 }),
(error) => error instanceof MarkdownToHtmlResourceError && error.code === 'input_too_large',
);
delete globalThis.window;
delete globalThis.document;
`,
'utf8',
Expand All @@ -141,11 +190,34 @@ Object.defineProperty(globalThis, 'document', {
configurable: true,
get() { throw new Error('ambient document access is forbidden'); },
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
get() { throw new Error('ambient window access is forbidden'); },
});
const markdown = require('${packageJson.name}/markdown');
assert.equal(typeof markdown.markdownToHtml, 'function');
assert.equal(markdown.htmlToMarkdown('<p>Gamma</p>'), 'Gamma');
assert.equal(markdown.DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, 16_777_216);
assert.equal(markdown.MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, 67_108_864);
assert.equal(typeof markdown.HtmlToMarkdownResourceError, 'function');
assert.equal(markdown.DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, 16_777_216);
assert.equal(markdown.MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, 67_108_864);
assert.equal(typeof markdown.MarkdownToHtmlResourceError, 'function');
assert.throws(
() => markdown.markdownToHtml('oversized', { maxMarkdownBytes: 4 }),
(error) => error instanceof markdown.MarkdownToHtmlResourceError && error.code === 'input_too_large',
);
assert.throws(
() => markdown.normalizeMarkdown('oversized', { maxMarkdownBytes: 4 }),
(error) => error instanceof markdown.MarkdownToHtmlResourceError && error.code === 'input_too_large',
);
assert.equal(typeof markdown.markdownToEmailHtml, 'function');
assert.throws(
() => markdown.markdownToEmailHtml('oversized', { maxMarkdownBytes: 4 }),
(error) => error instanceof markdown.MarkdownToHtmlResourceError && error.code === 'input_too_large',
);
assert.equal(markdown.markdownToPlainText('# Title'), 'Title');
delete globalThis.window;
delete globalThis.document;
`,
'utf8',
Expand All @@ -162,27 +234,53 @@ function verifyDeclarationConsumer() {
writeFileSync(
sourcePath,
`import {
DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES,
DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
HtmlToMarkdownResourceError,
MarkdownToHtmlResourceError,
htmlToMarkdown,
htmlToPlainText,
markdownToEmailHtml,
markdownToHtml,
markdownToPlainText,
normalizeMarkdown,
type HtmlToMarkdownOptions,
type HtmlToMarkdownResourceErrorCode,
type MarkdownToEmailHtmlOptions,
type MarkdownToHtmlOptions,
type MarkdownToHtmlResourceErrorCode,
type NormalizeMarkdownOptions,
type PlainTextOptions,
} from '${packageJson.name}/markdown';
const htmlOptions: HtmlToMarkdownOptions = { includeImageAlt: false };
const htmlOptions: HtmlToMarkdownOptions = {
includeImageAlt: false,
maxHtmlBytes: 1024,
};
const markdownOptions: MarkdownToHtmlOptions = { maxMarkdownBytes: 1024 };
const normalizeOptions: NormalizeMarkdownOptions = { maxMarkdownBytes: 1024 };
const htmlErrorCode: HtmlToMarkdownResourceErrorCode = 'input_too_large';
const htmlResourceError = new HtmlToMarkdownResourceError(htmlErrorCode);
const markdownErrorCode: MarkdownToHtmlResourceErrorCode = 'input_too_large';
const markdownResourceError = new MarkdownToHtmlResourceError(markdownErrorCode);
const emailOptions: MarkdownToEmailHtmlOptions = {
fullDocument: true,
languageTag: 'en-US',
textDirection: 'ltr',
maxMarkdownBytes: 1024,
};
const plainOptions: PlainTextOptions = { includeImageAlt: true };
void [
markdownToHtml('x'),
DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES,
MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
htmlResourceError.code,
markdownResourceError.code,
markdownToHtml('x', markdownOptions),
htmlToMarkdown('<p>x</p>', htmlOptions),
normalizeMarkdown('x'),
normalizeMarkdown('x', normalizeOptions),
markdownToEmailHtml('x', emailOptions),
markdownToPlainText('x', plainOptions),
htmlToPlainText('<p>x</p>', plainOptions),
Expand Down
16 changes: 12 additions & 4 deletions src/components/editorSerialization.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import type { EditorMode } from '../types.js';
import {
htmlToMarkdown,
markdownToEditorHtml,
} from '../markdown/serializer.js';
import { htmlToMarkdown } from '../markdown/serializer.js';
import { markdownToEditorHtml } from '../markdown/resourceBoundMarkdown.js';

const INVALID_MODE_ERROR = 'Editor mode must be markdown or html.';

function assertEditorMode(mode: EditorMode): void {
if (mode !== 'markdown' && mode !== 'html') {
throw new RangeError(INVALID_MODE_ERROR);
}
}

/** Convert a host value in the selected editor mode into TipTap HTML. */
export function editorValueToHtml(value: string, mode: EditorMode): string {
assertEditorMode(mode);
return mode === 'markdown' ? markdownToEditorHtml(value) : value;
}

/** Convert TipTap HTML into the serialization selected by the host. */
export function editorHtmlToValue(html: string, mode: EditorMode): string {
assertEditorMode(mode);
return mode === 'markdown' ? htmlToMarkdown(html) : html;
}
21 changes: 21 additions & 0 deletions src/components/editorSerializationRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import {
editorHtmlToValue,
editorValueToHtml,
} from './editorSerialization.js';

const INVALID_MODE_ERROR = 'Editor mode must be markdown or html.';

describe('editor serialization runtime mode contract', () => {
it('rejects an invalid runtime mode before converting a host value', () => {
expect(() => editorValueToHtml('# Heading', 'md' as never)).toThrowError(
new RangeError(INVALID_MODE_ERROR),
);
});

it('rejects an invalid runtime mode before converting editor HTML', () => {
expect(() => editorHtmlToValue('<p>Body</p>', 'rich' as never)).toThrowError(
new RangeError(INVALID_MODE_ERROR),
);
});
});
Loading
Loading