Guides · Embedding · SDK phase 1 of 3
Embed the editor
Phase 1 of the SDK ships three things a host can use today: an
installable package, a framework-free
<opendoc-editor> custom element, and a capability
contract that is resolved before the frame's first navigation —
so an embed cannot be talked out of the mode it was given. What it does
not ship yet is at the bottom of this page, in as
many words.
Try it first
The configuration playground puts every
role, capability, chrome region and brand token on one page beside a
running editor, and hands you the snippet that reproduces whatever you
end up with. Reading a capability table is slower than switching to
readonly and watching the ribbon go.
Everything below is extracted from code in this repository by
webapp/tools/build-embed-docs.mjs, and a guard fails the
build if the page and the code disagree. Each code panel's caption
names the file it came from. That is not decoration: the public pages
here have carried fabricated claims twice, in both directions, so no
example on this page is typed and trusted — see
how this page is kept honest.
Install it
@casualoffice/opendoc-embed
version 0.1.0:
12 files,
214 kB unpacked,
0 runtime dependencies.
Nothing publishes it to a registry yet, so today a host installs
the tarball npm pack produces from a checkout. The
packaging guard does exactly this — it packs the package, installs the
tarball into an empty project, and imports it by its bare specifier, so
"installable" is a measured fact rather than a manifest that looks
right.
# Nothing publishes @casualoffice/opendoc-embed to a registry yet, so a host
# installs the tarball `npm pack` produces from the checkout.
cd packages/opendoc-embed
npm pack # -> casualoffice-opendoc-embed-0.1.0.tgz
npm install /path/to/casualoffice-opendoc-embed-0.1.0.tgz
The package has 6 entry points, and a set of typings generated from the capability authority's real values rather than typed out beside it:
./src/embed_element.mjsimport "@casualoffice/ opendoc-embed"defineOpenDocEditor../src/embed_define.jsimport "@casualoffice/ opendoc-embed/ define"./src/capabilities.mjsimport "@casualoffice/ opendoc-embed/ capabilities"./src/host_contract.mjsimport "@casualoffice/ opendoc-embed/ contract"./src/host_client.mjsimport "@casualoffice/ opendoc-embed/ client"createHostClient — the postMessage transport, for a host that cannot reach into the frame../types/index.d.ts
The package is the mount and the capability contract; it does not carry
the engine. You also serve the editor's own static files
(editor.html and the WebAssembly bundle beside it) from
somewhere you control, and point editor-src at them.
Mount it
The element takes 8
attributes. mode names a role; can and
chrome narrow it, per capability and per region, so the role
never has to be the unit of what a host can express; prefs
sets the container's opening positions. config takes an
ONLYOFFICE-shaped block whole, for a host migrating one, and reports back
everything it could not honour. This is the package's own documented
usage, and a guard checks every attribute in it against the attributes the
element really reads, and the mode value against the names
the authority really accepts:
<script type="module">
import "@casualoffice/opendoc-embed/define";
</script>
<opendoc-editor
mode="commentor"
can="-download"
chrome="-brand"
editor-src="/opendoc/editor.html"
frame-title="Contract preview"
></opendoc-editor>
It is mode and not role because
role is a global ARIA attribute:
role="readonly" would publish an invalid ARIA role to every
assistive technology on the page. frame-title has no
default — an iframe with no accessible name is an axe
frame-title violation, and the package has no business
inventing English for your page, so you get one console warning and no
label.
Mounting from script is the same three attributes. This is the code the demo host in this repository runs, not a paraphrase of it — the embed demo page mounts the editor twice, once this way and once as a hand-wired iframe, and the browser suite drives both:
/** The custom element's own path: the host DECLARES the configuration and the
* element resolves, sandboxes and reports. `can` is an attribute here, which
* is the whole difference from the panel above — `mountIframe` has to build
* the same list into a query string by hand. The readout is taken from the
* element's event rather than recomputed here: if the page and the element
* disagreed, the page would be hiding it. */
function mountElement({ mode, can }
Setting mode or editor-src on a live element
remounts it rather than retuning it. A permission handed over
after the document is already on screen was never a permission.
You do not have to use the package. A host can wire the iframe itself — and this is what that costs, which is also the argument for the element: every line here is a decision the element already made.
/** What a host writes with no package at all — and why they should not have
* to. Every line below is a decision the element already made: put the mode
* and the withhold list in the URL before the frame is attached, derive the
* sandbox from the capability set, and give the frame an accessible name. */
function mountIframe(request) {
const frame = document.createElement("iframe");
frame.setAttribute("sandbox", resolveFor(request).sandbox.join(" "));
frame.setAttribute("allow", "clipboard-read; clipboard-write");
frame.title = panel.dataset.frameTitle ?? "";
frame.src = editorUrl(request).href;
return frame;
}
Resolution is pure and has no DOM, so a host can ask what a role grants before mounting anything — which matters, because each embed holds its own WebAssembly instance and a page should not have to boot one to find out what it would be allowed to do:
/** The capability set, review mode and sandbox for a host's request, as an embed
* would see them: `framed: true`, because every embed on this page is framed.
*
* `can` goes to the same `resolveCapabilities` the element and the editor page
* both call, so the readout cannot describe a narrowing the frame did not get. */
function resolveFor({ mode, can }
Serve it yourself
The element's editor-src points at an origin, and that
origin is a directory of static files. There is no server behind the
editor: the format work is in the WebAssembly module the browser
downloads, so a host serves the editor the way it serves its own assets
— a bucket, a CDN, its existing web server, or the container image in
this repository. Nothing here phones home, and nothing here needs a
licence check.
That also means the collaboration relay is a second, separate deployment rather than part of installing this. A host embedding a document for one reader needs no server at all; a host running a room several people edit at once adds one, and it holds no document even then. The compose file keeps the two apart, which is what the table below is reading:
editorrelayrelayThe commands below are generated from that same compose file, so a renamed service or profile changes them here rather than leaving a page that tells you to run something that does not exist. The full deployment story — the journal's backup and restore, TLS termination, what can be monitored, honest sizing, and upgrading without losing a room's history — is the deployment guide.
# docker-compose.yml — generated from it, so a renamed profile changes this.
# The editor on its own. No server, nothing to create, nothing to back up.
docker compose up --build editor
# Adding the optional relay: create the room's journal once, then serve it.
docker compose run --rm relay create /var/lib/opendoc-relay/room.journal
docker compose --profile relay up --build
# Reading the journal back, on a stopped room or on a copy. Not a service:
# it runs and exits.
docker compose run --rm relay inspect /var/lib/opendoc-relay/room.journal
Capabilities, and the five roles
There are 9 capabilities. A host grants or withholds them; the editor asks the resolved set and never asks for a role name.
opennewsaveallow-downloads), chromedownloadallow-downloads), chromeprintallow-modals), chromeeditcommentbrandingautosave5 roles are presets over that set, in strictly increasing order of what they grant. The ordering is asserted, not described: a "higher" role quietly losing something a lower one had is the classic permissions defect, so the chain is proven monotone for every role there is and every role added later.
previewviewingSandbox: no tokens beyond the unconditional two
readonlyviewingprintSandbox adds
allow-modalscommentorsuggestingprint download commentSandbox adds
allow-downloads allow-modalsediteditingsave download print edit comment autosaveSandbox adds
allow-downloads allow-modalsownereditingopen new save download print edit comment branding autosaveSandbox adds
allow-downloads allow-modals
Every embed's frame is sandboxed with
allow-scripts allow-same-origin
unconditionally — without them the editor cannot run its own
WebAssembly or read its own origin, and an embed that cannot boot is not
a permission decision. Together they do mean the frame can reach out of
the sandbox, which is why the sandbox is one layer
and not the enforcement.
8 names are accepted in total: the roles, plus the older preset names that are a published URL contract. An unrecognised name falls back to the framed default rather than throwing, and never to more than it — a typo in a host's URL must not leave the editor with no chrome, and must not grant anything extra either.
standaloneowner.open new save download print edit comment branding autosaveviewerreadonly.printembeddedsave download print edit comment
A host that wires its own iframe passes the same vocabulary in the URL:
?mode=readonly. Framing alone is enough — a page that
is not the top window resolves to embedded without the host
having to discover a parameter, which is the whole point: the defect
this closed was a framed editor being a standalone one, with File ▸ Open
live inside someone else's page. ?autosave=1 and
?autosave=0 move the one per-capability override there is.
Per capability, not per tier
The roles are names for subsets, never the unit of enforcement. Nothing downstream branches on a role name: the review mode, the sandbox tokens and every gated command are computed from the capability set. That is what makes "print, download, save, edit and comment are withheld independently" a property of the design rather than a promise.
A host picks one of the
8 accepted names and then narrows it,
per capability, with can. An embed whose
editor-src carries
?can=-print,-download on top of
mode="commentor" may comment and nothing else.
The list resolves through the same function the presets do, which is what keeps "roles are presets over the capability set" a fact rather than a claim — a guard proves every role is reachable by narrowing the role above it. It only ever takes away, whether or not an entry carries its minus sign, and an unknown entry is dropped: dropping narrows nothing, so a typo in a URL can never grant more than the role did.
preview is not readonly
They are two different presentations, not two rungs of a ladder.
preview is the runtime as a layout and rendering engine — a
picture of the document, for a thumbnail, an attachment preview, a
search result. readonly is a reading experience of
a published document: navigation, outline, find, zoom, page controls and
a paper copy. The test of the difference is whether a static image could
replace it: for preview, nearly; for readonly,
no, because the reader navigates and searches. So
preview is not "readonly minus print".
The chrome reflects that. A readonly container gets
10 regions — the menu bar, the
navigation rail, the status bar, zoom, find and the document title — and
no editing ribbon at all, not a ribbon full of greyed
buttons. A preview container gets
0: the runtime paints the
document and nothing else.
Why the menu bar, and not just "no ribbon"
readonly is granted exactly one capability —
print — and File ▸ Print lives in the menu bar. Hiding the
ribbon without revealing the menu bar would have made the one thing a
reader is allowed to do unreachable, which is why a guard asserts that
every capability a preset grants has an affordance in chrome that preset
shows. It also keeps the editor's one-navigation-axis rule: a container
has the tab strip or the menu bar, never both.
Three layers of enforcement
In order of how hard they are to defeat.
1 · The browser. The element derives the iframe's
sandbox tokens from the capability set, and the browser does
not negotiate: a frame without allow-downloads cannot start
a download however its chrome or its engine is persuaded, and one
without allow-modals cannot open the print dialog. For
comparison, ONLYOFFICE's createIframe
(apps/api/documents/api.js:1279) sets no
sandbox attribute at all — that file contains the string
nowhere — so this is a layer they do not have.
2 · The engine. A capability set maps onto the editor's three
existing review modes: edit → Editing,
comment → Suggesting (every body change recorded as a
tracked revision), neither → Viewing. Viewing is read-only at a single
fail-closed choke point that refuses every mutation path — typing,
deletion, paste, toolbar formatting, table operations, comment and
revision decisions — rather than relying on any individual control being
disabled. A readonly embed is therefore in Viewing
on arrival, not an Editing page with dimmed buttons, and the
browser suite proves it by stripping every disabled and
aria-disabled attribute in the frame and then attacking the
document with typing, paste and
17 ribbon commands, one
by one rather than a sample. The evidence
is the engine's own word and character counts, not the DOM.
3 · The chrome. A withheld capability disables its control
with a reason and never hides it: a control that vanishes
teaches nothing, and a product that silently lacks a feature is
indistinguishable from one that is broken. Open the command palette
inside a readonly embed and look at Open… — it is
present, it is disabled, and its title says why. This is the layer that
is least complete today: the editor page consults
8 of the
9 capabilities.
autosavecommentdownloadeditnewfile.newopenfile.openprintfile.printsavefile.save
The other 1 are not
consulted by the chrome at all. print,
download and save are withheld by the sandbox
— which works, but the refusal arrives from the browser rather than from
a control that explains itself, and the editor's own status line can
still report a save that the browser declined.
branding is consulted nowhere yet: withholding it does not
remove OpenDoc's mark from the frame. Both are listed below rather than
left to be discovered.
Nothing here is gated
The package is Apache-2.0 and consults no licence, in any code path.
That is the whole competitive position rather than a footnote:
ONLYOFFICE's host-customization path early-returns when the editor is
not licensed —
apps/common/,
if (!_licensed || !config) return; — so the customization a
host is offered is a licence check away from doing nothing. Gating ours
would surrender the only structural advantage that cannot be copied.
Commanding it, and being told what happened
A host gets a typed way to command the editor and to hear what it did,
and it is the same contract whether they hold a reference to the editor
or are talking to it across an iframe boundary. Contract version
1:
197 commands by name and
11 families of value commands,
7 events,
7 refusal codes and
4 verbs
(describe execute query ping).
Both transports are built from one schema,
webapp/. Neither carries
a command list, an event list or a refusal code of its own — which is
what makes adding a command to one without the other a build failure
rather than a half-reachable API. The in-process handle is
window.opendoc inside the frame; the
postMessage client is
createHostClient. The demo host drives both, and the
only difference between them is how the call travels:
/**
* Runs one command over one transport, and returns exactly what came back.
*
* The two branches are the whole demonstration, and they are four lines apart so a
* reader can see that the only difference between them is how the call travels.
*/
async function runCommand(panel, transport, id) {
if (transport === "post-message") {
const client = clients.get(panel);
return client ? client.execute(id) : null;
}
const session = liveFrame(panel)?.contentWindow?.opendoc ?? null;
return session ? session.execute(id) : null;
}
A command id is the unit of addressing because it already was: the
editor's registry is reached by id, every keyboard chord resolves to one,
and 111 ribbon controls carry data-command. Most ids are
literals; some name a value of a control
(format.family.Georgia, style.Heading 1,
view.zoom.150) and those sets are generated from the
document, the font inventory or markup, so the schema declares the family
prefix instead. Measured in a browser, the registry offers 214 ids, of
which 93 are family members — and the font inventory differed between two
reads of the same document, so a table of literals would have been wrong
on arrival.
Events
change carries a revision handle and a dirty flag, never a
snapshot. An event that serialised the document on every keystroke would
make per-interaction work grow with the document, which is a defect
however fast it feels on a small file.
readycontract capabilities editingMode commandschangerevision dirtyselectionanchor focus hasRangesaveformat name bytesexportformat name byteserrorcode message commandrefusalcode command requires messageA refusal is a value, not a toast
execute resolves to
{ ok, command, revision } or to
{ ok: false, refusal: { code, command, requires, message } },
and a host branches on the code — never on the message, which is
localised and belongs to the chrome. This matters because the same defect
has already been fixed one layer down: Save on a readonly
embed used to run, fire no download, and report “Saved”. An API that
only emitted a toast would reintroduce it.
unknown-commandcapability-withheldunavailableengine-refusedthrewbad-requesttimeout
The capability gate is the fourth enforcement layer, beside the browser
sandbox, the engine's Viewing choke point and the chrome, and it runs
before dispatch. A host that was not granted edit
cannot run an editing command through the API either — which is the whole
point, because three locked doors and an unlocked one is one unlocked
door. It is deliberately stricter than the chrome in one place:
deciding a tracked change writes it into the document outright, so
review.accept* requires edit and a
commentor cannot accept their own suggestions. Google Docs'
Commenter cannot either.
The origin contract
A wildcard target origin in an editor that can be embedded anywhere is a
security decision, not a default, so there is no default: the editor
accepts its own origin and any origin the deployment names with
?hostOrigin=, and nothing else. A message from an unknown
origin is dropped in silence rather than answered with an error, because
an error reply tells whoever is probing what this frame is. Every outbound
post names an explicit target;
postMessage(msg, "*") appears nowhere, and that is
driven rather than grepped — a source scan would pass the moment the
wildcard arrived through a variable.
/**
* Parses a host-declared origin allowlist.
*
* THE ORIGIN CONTRACT, stated rather than defaulted. A wildcard target origin in
* an editor that can be embedded anywhere is a security decision, not a default:
* `postMessage(documentContent, "*")` hands the bytes to whatever page happens
* to be the parent. ONLYOFFICE does exactly that, with a TODO where the decision
* should be (`reference/web-apps/apps/api/documents/api.js:1303-1306`).
*
* WHAT WE ACCEPT, AND WHY:
*
* * The editor's OWN origin, always and implicitly. A same-origin embed — the
* shape `<opendoc-editor>` produces, because `sandboxTokensFor` grants
* `allow-same-origin` so the frame can reach its own WebAssembly — is the
* common case, and it needs no configuration to work.
* * Any origin the host names explicitly, as `?hostOrigin=https://app.example`
* (comma-separated for several). Explicit, so a cross-origin host is a
* deliberate act by whoever deployed the editor rather than whoever framed
* it.
* * Nothing else. `*` and `null` are REJECTED rather than honoured: they are
* the two spellings of "anyone", and an editor that accepts them has no
* origin contract at all. A rejected entry is dropped, so a typo narrows the
* allowlist and never widens it.
*
* Complexity: O(entries).
*
* @param {string|null|undefined} raw the `hostOrigin` parameter, if any.
* @param {string} selfOrigin the editor's own origin.
* @returns {readonly string[]} absolute origins, deduplicated, `selfOrigin` first.
*/
export function parseOriginAllowlist(raw, selfOrigin) {
const allowed = [];
const add = (value) => {
if (typeof value !== "string") return;
const origin = value.trim();
// "null" is the origin a sandboxed or `data:` document reports, and "*" is
// the wildcard. Neither identifies anybody.
if (!origin || origin === "*" || origin === "null") return;
let parsed = null;
try {
parsed = new URL(origin);
} catch {
return;
}
// An entry must BE an origin, not a URL with a path: `https://x/y` and
// `https://x` are the same origin, and accepting the first spelling invites
// a host to believe the path is part of the check.
if (parsed.origin !== origin) return;
if (!allowed.includes(parsed.origin)) allowed.push(parsed.origin);
};
add(selfOrigin);
for (const entry of String(raw ?? "").split(",")) add(entry);
return Object.freeze(allowed);
}
Make it your product
Name, mark, palette, font and wording are one file —
webapp/brand.json — and one command. Nothing below needs a code
change: the generator writes the stylesheet, the module and the icon links
the editor reads, and build.sh fails if a committed artifact is
not what a fresh run produces.
{
"version": 1,
"name": "Northwind Docs",
"mark": "./opendoc-mark.svg",
"markHref": "https://example.invalid/northwind",
"customer": {
"name": "Northwind Trading Co.",
"info": "Document workspace for the Northwind field teams.",
"address": "14 Harbour Road, Bristol BS1 4RN",
"www": "https://example.invalid/northwind",
"mail": "mailto:[email protected]",
"phone": "tel:+441170000000"
},
"feedback": {
"text": "Tell us what broke",
"url": "https://example.invalid/northwind/support"
},
"help": {
"text": "Northwind help centre",
"url": "https://example.invalid/northwind/help"
},
"tabTitle": "document",
"theme": {
"tokens": {
"--accent": "#0f6d6a",
"--accent-ink": "#ffffff"
},
"light": {
"--bg": "#f2f0ec",
"--bg-2": "#f7f6f3",
"--surface": "#ffffff",
"--surface-alt": "#f4f2ee",
"--ink": "#1b1a18",
"--muted": "#5c5953",
"--faint": "#67635c",
"--line": "#e4e1da",
"--line-strong": "#cec9bf",
"--primary-bg": "#1b1a18",
"--primary-ink": "#faf9f6",
"--accent-text": "#0c5a57"
},
"dark": {
"--bg": "#16181a",
"--bg-2": "#1c1f21",
"--surface": "#202426",
"--surface-alt": "#272b2e",
"--ink": "#e9eaea",
"--muted": "#a9aeb0",
"--faint": "#9ba1a3",
"--line": "#303538",
"--line-strong": "#414749",
"--primary-bg": "#e9eaea",
"--primary-ink": "#16181a",
"--accent-text": "#5fc3bf"
}
},
"font": null,
"strings": {
"de": {
"branding.themeSetByHost": "Die Farbe wird von Northwind Docs vorgegeben."
}
}
}
18 token names are
overridable and frozen as a public contract: the accent family, the
surfaces, the foregrounds, the lines and the primary action. Geometry,
z-order and type scale are refused, because those are structure rather than
brand. So is the --paper* layer: the sheet is white in both
themes on purpose so that everything drawn onto it has a fixed contrast
partner, and a host who darkened it would put the dark theme's insertion
and deletion marks on a dark page.
An unreadable palette is refused, not shipped
A host palette is measured as it will render — our tokens with
yours written over them, per theme, with var() and
color-mix() resolved — because a brand colour measured on its
own passes when it is unreadable only against the surface it will be painted
on. Text pairs clear 4.5:1 and edges clear 3:1, which is WCAG's own split,
so a hairline is not held to the prose bar. A failure stops the build and
says what would work:
$ node webapp/tools/build-brand.mjs
brand.json was refused:
- light: --muted (#9aa0a6) on --surface (#ffffff) measures 2.64:1 and must clear 4.5:1. You set --muted: #72777b there would pass, on the same hue.
- light: --accent-ink (#ffffff) on --accent (#f5a524) measures 2.04:1 and must clear 4.5:1. You set --accent-ink: #484848 there would pass, on the same hue.
- light: --accent-text (var(--accent)) on --surface (#ffffff) measures 2.04:1 and must clear 4.5:1. You set --accent: #a06c17 there would pass, on the same hue.
- system dark: --accent-ink (#ffffff) on --accent (#f5a524) measures 2.04:1 and must clear 4.5:1. You set --accent-ink: #484848 there would pass, on the same hue.
- explicit dark: --accent-ink (#ffffff) on --accent (#f5a524) measures 2.04:1 and must clear 4.5:1. You set --accent-ink: #484848 there would pass, on the same hue.
Refused rather than corrected, because shipping your product in a colour you did not choose is a worse outcome than a failed build — and rather than warned about, because a warning in a build log is read once and then the integrator ships it. The failure lands on the person who can still fix it, at the moment it is free to fix.
Choose the chrome
25 regions, of which
8 are ribbon bands, each withheld
independently with chrome. A withheld region is a presentation
decision about your page, not a permission — so it is absent and
silent, where a command you were offered but cannot run right now is
disabled and says why. "Never, for you" versus "not right now".
brandtitlestatereviewmenuribbonband.fileband.homeband.insertband.layoutband.referencesband.reviewband.viewband.tablerailrulercaretcontextobjectshistorystatuszoomfindselectionsettingsChange the words
Setting name renames the product in all nineteen languages. The
override set is derived by substitution over the committed catalogues rather
than written per locale, because nineteen hand-written overrides are nineteen
chances to forget one and the one forgotten is the one a reader sees. Host
overrides are a layer on the same t() lookup the catalogues
resolve through — * for every language, an exact tag to narrow —
and there is deliberately no second string mechanism.
A face is named as a file you place beside the editor, never a URL. The chrome self-hosts every font it uses and a test asserts no remote font reference, because local-first means the chrome paints with the network off; a white-label that fetched a webfont from a CDN would break that for every deployment that used it.
What you installed, and what it speaks
@casualoffice/opendoc-embed/release reports three numbers, and
they are deliberately not one. The package version is what npm resolved; the
engine version (0.0.1) is the Rust
workspace the WebAssembly came from; the contract version
(1) is what the editor speaks, and
it is stable across package releases because an added command, event or
refusal code is additive — tying it to the package version would make you
re-pin for changes that break nothing. The licence is
Apache-2.0, with no licence check,
no tab counting and no modal anywhere in it.
There is no build commit in that module. The commit is stamped into the deployed assets at deploy time; a committed file carrying a hash would be wrong for every build after the one that wrote it.
How this page is kept honest
The pages of this site have carried fabricated claims twice, in both directions — a parity figure that contradicted the fidelity page, a corpus file that did not exist, a repaint timing with no benchmark, and three shipped features listed as "Not yet". Understating is also false. So this page is built the way the package is:
webapp/tools/build-embed-docs.mjs generates every code
panel, every table and every number above from the code they document,
and --check fails when the committed page is not what a
fresh run produces. That makes both directions of drift a build failure:
editing the page by hand, and changing the code without regenerating the
page. webapp/tests/embedding_page.test.mjs then re-derives
the numbers independently of the generator, proves each extracted
snippet still appears verbatim in the file its caption names, and proves
every item under "does not do yet" is still true. Both run in the
browser-smoke job of .github/workflows/ci.yml,
which is itself asserted, because a gate nobody armed has been claimed
here before.
The behavioural claims are not this page's word either. These are the tests they rest on:
webapp/ tests/ roles.test.mjsnpm run test:unitwebapp/ tests/ embed_package.test.mjsnpm run test:unitwebapp/ tests/ e2e/ embedded-capability-gate.spec.mjsnpm run test:e2ewebapp/ tests/ e2e/ embedded-capability-gate.spec.mjsnpm run test:e2ewebapp/ tests/ host_contract.test.mjsnpm run test:unitwebapp/ tests/ host_contract.test.mjsnpm run test:unitwebapp/ tests/ host_contract.test.mjsnpm run test:unitwebapp/ tests/ e2e/ host-contract.spec.mjsnpm run test:e2ewebapp/ tests/ e2e/ host-contract.spec.mjsnpm run test:e2ewebapp/ tests/ e2e/ host-contract.spec.mjsnpm run test:e2ecrates/ casual-doc-sdk/ src/ host_parity.rscargo testcrates/ casual-doc-sdk/ src/ host_parity.rscargo testcrates/ casual-doc-sdk/ src/ host_parity.rscargo testwebapp/ tests/ e2e/ embedded-capability-gate.spec.mjsnpm run test:e2eWhat this does not do yet
Phase 1 is one of three. Each item here is checked by the guard, so it cannot outlive the gap it describes.
-
The element itself has no command methods. The command and event
API is reached through
window.opendocinside the frame or throughcreateHostClientoverpostMessage;<opendoc-editor>still fires exactly one event of its own,opendoc-capabilities, which is the capability contract announcing itself rather than the editor reporting on a document. A host holds the contract, not the element. -
No arguments for most commands. The schema declares an argument
list, and exactly one command has one
(
view.zoom, a percentage). Everything else is a verb: you cannot yet ask for a font size by value throughexecute's arguments, only by naming the command that already means that value (format.size.14). -
No document I/O through the contract. There is no
open(bytes)and noexport()that returns the bytes to the host:file.export.*hands the file to the visitor, and theexportevent reports the format, the name and the byte count. A host that wants the bytes cannot have them yet. - The context menu's word-level commands are outside the contract. Spelling suggestions and grammar rules are built per word when the menu opens, so they are reachable by a person and not by a host.
-
No way to hand it your document. The element's
8 attributes configure what
the editor is — the role, the two withhold lists, the opening
positions, a competitor's configuration block, the editor's language,
where the editor lives and the frame's accessible name — and none of
them is a document.
editor-srcmay carry the editor's own query parameters; there is no host-supplied document URL or byte array. -
No per-GROUP selection inside a ribbon band. All eight bands are
selectable and every one of the
25 chrome regions can be
withheld, but the groups inside a band are not addressable:
53 of them carry no
data-groupattribute at all, so a per-group roster would be half expressible. The honest grain for a host is the band. -
A browser-enforced refusal does not explain itself. Withholding
print,downloadorsaveis enforced by the sandbox, and the control that triggers it is still offered — so the refusal arrives as the browser declining, and the editor can still report a save that never reached the visitor's disk. -
The favicon, the canonical URL and the meta description are not
configuration. The brand mark in the top bar is a token and the
icon links are generated, so
brand.jsonreaches both. The canonical URL and the page description are still authored ineditor.html's head: a white-labelled deployment that cares about them edits two lines of HTML. Everything a reader sees inside the editor is configuration; two things a crawler sees are not. - No framework bindings, by choice. A custom element needs no binding layer; the package ships none and declares no framework entry point. The repository exercises it in plain HTML and in Node, not inside a framework.
- Not published. No workflow publishes the package to a registry. Install the tarball, or vendor the files.
-
The Rust facade declares this contract; it does not run the
editor.
crates/casual-doc-sdkholds the same events, refusal codes, request verbs and version, and a Rust test reads the editor's schema and fails when the two disagree — so a native host and a browser host read one vocabulary. The editor's own runtime is still the WebAssembly engine's own operations, which the crate does not carry, so there is no Rust in-process transport yet: converging the two is its own piece of work and it gates nothing on this page. - No passwords. Deliberately out of scope until there is format work to make an encrypted document mean anything.