@dokebi/seal
v0.4.2
Published
Payload sealing (obfuscation, replay defence, in-transit encryption) for seal hosts: @dokebi/websocket frames and @dokebi/web HTTP bodies, with browser socket, fetch and Inertia clients and their WASM loader
Maintainers
Readme
@dokebi/seal — payload sealing and the browser WebSocket client
- Decisions: D-0092 (sealed EJS forms) · D-0059 (replaced the old @dokebi/crypto; changed to public on 2026-10-15) · D-0016 (the old private rule, replaced by D-0059) · D-0038 (replay protection) · D-0062 (HTTP sealing)
- Origin: XFramework
packages/x-seal(sealing) andpackages/x-socketserver/clientandsrc/wire.ts(browser socket); the initial port preserved the wire protocol; Dokebi has since added HTTP integration, replay-capacity handling and WASM hardening. Provenance is in SEAL-NOTICE.md. - A public package (MIT, owner's decision of 2026-10-15).
npm install @dokebi/seal(in a Dokebi project,npx dokebi add @dokebi/seal: the @dokebi/cli installed in the project; the npm packagedokebiis not this CLI). The npm archive holds only the built JS and types, the WASM (dist/wasm), the wasm-bindgen glue and the browser bundles (dist-client); the Rust source (crate/) and the tests stay in the repository. The release check (scripts/release/prepare.ts) allows seal content (WASM,.rs, the decoy string) only in this archive and refuses it in any other public archive. - It is a member of the root workspace, so it is part of
npm run build,npm testandnpm run check. It builds without Rust (see "Build and checks" below). The playground'ssealed.localhosthost uses this package (25-playground). - When another method is needed, change only the encryption method and plug it into the same codec slot (
forConnection→{ decode, encode, hello }) (D-0059).
Security boundary: keys derive from public host/path/User-Agent/time values and a fixed string in the browser code. Someone who analyses that code can open and forge frames. Seal provides obfuscation and replay rejection when a nonce store is configured (default drift ±60 seconds). It provides neither confidentiality nor proof of client identity and does not by itself establish compliance with encryption requirements. Use TLS (HTTPS/WSS), authentication, authorization, input validation, CSRF protection and rate limits as required. Host binding prevents accidental cross-host reuse; it is not a secret available only to a trusted site.
seal: base64( obfuscate( AEAD(nonce || ciphertext || tag) ) )
frame: E:<ts>:<base64> (sealed) · P:<plain text> (sealing off; an explicit signal)
key: derived from the request's host, path, UA and time (different for every frame)🔴 The exact construction, constants and derivation are not written in this file (original audit 2026-08-12). The algorithm's source of truth is two places in code:
crate/src/crypto.rs(the Rust original) andsrc/crypto.ts(the JS mirror).
The Rust original (crate/) and the JS mirror (src/crypto.ts) are byte-level compatible, and the
known-vector parity gate (test/parity.test.ts) enforces it: changing one side only means client
and server cannot open each other's frames. The algorithm, the key-derivation inputs, the decoy string
(x-seal-default-decoy-v1; protocol data, so not renamed), the frame formats and the known test values
are as in the original. Only the sealed-body content type was renamed from application/vnd.x-seal to
application/vnd.dokebi-seal (D-0062; 0.1.0 was never published).
Entry points
| Path | Contents | Origin |
|---|---|---|
| @dokebi/seal | Node API + the Dokebi plugin createPlugin (core ^0.4.2): socket codec provider, HTTP codec provider and static serving of /dokebi/seal/* (D-0059 §4, D-0062) | (the plugin shell of @dokebi/crypto) |
| @dokebi/seal/node | Node API only (no core): deriveKey, getUaSlice, seal, open, openWithNonce, wsSeal, wsOpenWithNonce, SEAL_DECOY_SECRET, SealTerminator, SealError, SealUsageError, MemoryNonceStore, NonceStoreFullError, requiredTtlSeconds, checkTtlCoversDrift, SEALED_CONTENT_TYPE, PREFIX_SEALED, PREFIX_PLAIN, DEFAULT_DRIFT_MS, forConnection, forRequest, httpProvider, SEAL_HELLO | @x/seal |
| @dokebi/seal/testing | Node only: SealedClient, a browser-like client of a seal host for tests and concurrent load tests (cookie jar, CSRF, sealed forms, JSON and Inertia visits, its own connections; D-0101, "Testing a seal host" below) | (new) |
| @dokebi/seal/browser | the browser seal loader: initSeal({ prefix }), sealFrame, unsealFrame, unsealPage, sealBridge | @x/seal/client |
| @dokebi/seal/wasm · /wasm/glue | WASM binary · wasm-bindgen glue | wasm/x_seal* |
| @dokebi/seal/client | the browser WebSocket client connectChannel (knows nothing about sealing; the bridge is injected) | @x/socketserver/client |
| @dokebi/seal/client/seal | the bridge sealBridge filled by this package's seal loader + initSeal | (new) |
| @dokebi/seal/client/vue | Vue 3 useChannel, useChannelEvent (vue is an optional peer) | (new) |
| @dokebi/seal/client/react | React useChannel, useChannelEvent (react is an optional peer) | (new) |
| @dokebi/seal/http | browser HTTP sealing: sealInertia, sealedFetch, startHttpSeal, armHttpSeal, httpSealed, currentSealBridge, SealHttpError (D-0062) | x-inertia/client (lessons) |
| @dokebi/seal/forms | sealed HTML forms of server-drawn pages: starts on import; sealForms, submitSealedForm, and the pure parts formFields, formHasFiles, formRequest, formOutcome, sealedTarget, prefixOf (D-0092) | (new) |
| @dokebi/seal/bundle/seal.mjs | the seal loader as a single ESM file (glue included; the wasm is fetched separately) | dist-client/client.mjs |
| @dokebi/seal/bundle/socket.mjs | the socket client as a single ESM file (no dependencies) | dist-client/client.mjs |
| @dokebi/seal/bundle/http.mjs | the HTTP helpers as a single ESM file (the loader included) | (new) |
| @dokebi/seal/bundle/forms.mjs | the forms module as a single ESM file importing ./http.mjs (one instance with it) | (new) |
Browsers fetch the WASM from <prefix>/dokebi/seal/dokebi_seal_bg.wasm (originally /x/seal/). When
dokebi-web is registered, this plugin serves that path (see "Server connection" below).
Server connection: sealing per host (D-0059 §4)
# dokebi.yaml — register this plugin (dokebi-seal) as well; without it the start stops with SEAL_PROVIDER_MISSING.
dokebi-app:
domains:
secure.example.com: { app: shop, seal: true }
www.example.com: { app: shop }- Codec provider:
forConnection({ host, path, userAgent, nonces, driftMs })of the plugin API (=@dokebi/seal/node) gives one connection's{ decode, encode, hello }: aSealTerminatoras is (fail-closed, plain text refused, drift, single-use nonces). It is a structural contract that @dokebi/websocket 0.3 looks up by name (dokebi-seal); public packages never import this package.noncesishub.nonces(): in a cluster it is shared in Redis, so a frame used on one server is refused when replayed to another. Without a store no connection is created. A store that fails (a hub outage) closes the connection with 1013Try again later(the client reconnects), never 4400 (D-0096). GET /dokebi/seal/*(only with dokebi-web):seal.mjs(seal loader + glue),socket.mjs(socket client),http.mjs(HTTP helpers),forms.mjs(sealed forms, D-0092),dokebi_seal.js(glue only),dokebi_seal_bg.wasm.Cache-Control: public, max-age=300, must-revalidate+ETag(304 when equal)nosniff. Nothing but these six exists (404). Missing build output stops setup withSEAL_ASSETS_MISSING(npm run seal:build). The routes are public (config.auth: false): an app withdokebi-auth.default: requiredstill serves them to the login page (D-0096 §3).
- Pages: @dokebi/app provides
sealed(EJS data, Inertia shared prop,page.sealed). Load the loader only whensealed, so only seal hosts fetch the wasm (giving the bridge to a plain host works too; see the negotiation below):
<script type="module">
const sealed = <%- JSON.stringify(data.sealed) %>
const { connectChannel } = await import('/dokebi/seal/socket.mjs')
const seal = sealed ? await import('/dokebi/seal/seal.mjs') : undefined
if (seal) await seal.initSeal({ prefix: '' })
const channel = connectChannel({ path: '/ws', version: BUILD, ...(seal ? { seal: seal.sealBridge } : {}) })
</script>- Negotiation: with a bridge, the subprotocols
['dokebi.seal', 'dokebi.plain']are offered and the decision isws.protocol === 'dokebi.seal'only. Seal hosts pick onlydokebi.seal(never plain); plain hosts pickdokebi.plain, and the same code runs in plain text. A sealed connection isopenonly after the server's sealed{"$dokebi":"hello-v1"}arrives; itsE:<ts>:givesoffset = server ts − Date.now(), and every later frame (pings included) is sealed withDate.now() + offset, so a device clock that is minutes off still lands inside the ±60-second window. Connecting to a seal host without a bridge closes with 4415Seal required; a frame the server cannot open closes with 4400Bad frame(the reason is only in the server log). Neither reconnects. A hello that does not open is reported asSEAL_HELLO_INVALIDand closed with 4400. When hello does not arrive withinhelloTimeoutMs(default 10 seconds),SEAL_HELLO_TIMEOUTis reported and the connection closes with 4000 and reconnects with backoff (temporary).channel.sealed()tells whether the current connection is sealed.
Browser socket client
import { connectChannel } from '@dokebi/seal/client'
const channel = connectChannel({
path: '/ws', // the route the server defines; used as is when it starts with ws://
version: APP_VERSION, // ?dokebiVersion= — when it differs the server closes with 4426 and the client calls location.reload()
query: { room: 'lobby' }, // never put authentication tokens in the URL (it ends up in logs)
onPresence: () => reloadMembers(), // { tenant, joined, left } delta — read the list from the server again
onError: (code, fix) => console.warn(code, fix),
})
channel.on('chat.message', (data) => render(data)) // server messages and broadcasts { type, data }
const reply = await channel.request('chat.send', { text }, { timeoutMs: 5000 }) // { type, data, id } → reply.data
channel.send('typing', { on: true }) // no reply; false when not open (no queue)
channel.close() // does not reconnect- Envelope (Dokebi protocol): request
{type,data,id}→{type:'reply',id,data}or{type:'error',id?,code}; server messages and broadcasts{type,data}(D-0057); presence{type:'presence',data}; keep-alive{"$dokebi":"ping-v1"}every 15 seconds (pingMs; 0 turns it off; D-0033). requestfailures areChannelRequestError.code: a server code (UNKNOWN_TYPE,INVALID_MESSAGE, a handler's code,HANDLER_FAILED) orNOT_CONNECTED(not sent),DISCONNECTED(sent but disconnected before the reply; it may have been processed),TIMEOUT(default 10 seconds,requestTimeoutMs),ABORTED(signal),CLOSED. Whether to resend is the caller's choice.- Reconnecting (as in the original): exponential backoff (default 500 ms → 10 seconds) × jitter [0.5, 1). What is sent while disconnected is dropped (no queue; sealed frames are tied to their ts and would be refused for drift anyway). Pending requests get
DISCONNECTED. - Close codes: permanent (no reconnect) 4426 version differs (the page reloads; replace with
onReload), 4409 replaced by a newer connection with the same clientId (or refused as a duplicate by an older server), 4403 kicked by an operator, 1008. Temporary (reconnect) 4408 idle, 1013 hub unavailable or slow consumer, 1012 server restart, 1011 handler error, 1006, 1001. clientId(?clientId=): by default a new one per connection (so a reconnect neither replaces nor collides with the old connection the server may still hold). A fixed value or a function can be given (a newer connection with the same value replaces the older one, closed with 4409);falsesends none.- In Node, pass an implementation (
wsand so on) in theWebSocketoption and givepathasws://…. - Binary broadcasts arrive in
onBinary(ArrayBuffer)(plain hosts only). - With
logger({ log(level, message, fields) }) connections, closes, reconnects and timeouts are logged at debug. Errors with nowhere to go go toonError→loggererror →console.warn, in that order (never dropped silently, D-0047).
Seal hosts
import { connectChannel } from '@dokebi/seal/client'
import { initSeal, sealBridge } from '@dokebi/seal/client/seal'
await initSeal({ prefix: '' }) // /dokebi/seal/dokebi_seal_bg.wasm
const channel = connectChannel({ path: '/ws', seal: sealBridge })On a sealed connection (when the server picked dokebi.seal) every outgoing frame (send, request, ping) is sealed as E:<ts>:…,
and every incoming frame must open: plain or unopenable frames are refused as SEAL_OPEN_FAILED and binary as SEAL_BINARY
(fail-closed). The seal path is the connection path without the query (dokebiVersion and clientId never enter the key).
Without a bridge it is plain (like the original, nothing is guessed). See "Server connection" above as well.
Vue · React · ESM
// Vue 3 — closes when the scope (component) ends
import { useChannel, useChannelEvent } from '@dokebi/seal/client/vue'
const { channel, status, closed } = useChannel({ path: '/ws', version: APP_VERSION }) // status and closed are ShallowRefs
useChannelEvent(channel, 'chat.message', (data) => messages.value.push(data))
// React — opens in an effect, closes in its cleanup; reconnects only when path, version or deps change; callbacks are the latest
import { useChannel, useChannelEvent } from '@dokebi/seal/client/react'
const { channel, status } = useChannel({ path: '/ws', version: APP_VERSION }) // channel is undefined on the first render
useChannelEvent(channel, 'chat.message', (data) => setMessages((list) => [...list, data]))<!-- Pages without a bundler: this plugin serves /dokebi/seal/* through dokebi-web -->
<script type="module">
import { initSeal, sealBridge } from '/dokebi/seal/seal.mjs' // seal hosts only (when sealed)
import { connectChannel } from '/dokebi/seal/socket.mjs'
await initSeal({ prefix: '' })
const channel = connectChannel({ path: '/ws', seal: sealBridge }) // the bridge takes the server time (now) as the third argument
</script>HTTP sealing (D-0062)
On seal hosts the HTTP data paths are sealed too: REST JSON requests and responses, the Inertia first page (data-page) and visits (XHR, 4xx included), and SSE event data. The server-side decision and wiring are @dokebi/web 0.2's seal slot, which this plugin fills (codec httpProvider, nonces from the store nonces.driver picks, below). Exceptions: EJS HTML (page navigations, their error pages included), GET queries, file uploads (multipart), static files, health, seal: false routes, dokebi-web.sealExclude. The decision and its reasons are in D-0062.
// views/app.js (Inertia + Vue)
import { sealInertia } from '@dokebi/seal/http';
import { createInertiaApp, http } from '@inertiajs/vue3';
await sealInertia(http); // sealed page: opens data-page and seals every visit · plain page: does nothing
createInertiaApp({ /* ... */ });
// REST — the same code on seal hosts and plain hosts
import { currentSealBridge, sealedFetch } from '@dokebi/seal/http';
const answer = await sealedFetch('/api/notes', { method: 'POST', body: { text } });
await answer.json();
// SSE (@dokebi/sse/client)
connectEvents({ path: '/events', seal: currentSealBridge() });- Request body:
Content-Type: application/vnd.dokebi-seal,E:<ts>:<b64>(key = host, request path, UA, ts). Response: the same format +x-dokebi-sealed-path(the final path after redirects, which XHR does not report). 4xx responses are opened too. A failed opening isSealHttpError('SEAL_OPEN_FAILED')(no plain-text fallback). - On plain hosts
sealedFetchis plain fetch, andsealInertiadoes not fetch the WASM (the loader is a dynamic import). sealedFetchrefuses requests to another origin (SEAL_CROSS_ORIGIN: the key belongs to this host) and bodies inside aRequestobject (SEAL_REQUEST_BODY: pass{ body }). A sealed response without sealing on isSEAL_BRIDGE_MISSING.- On seal hosts
sealedFetchaddsx-dokebi-sealed: 1to same-origin requests (a script: its errors come back as sealed JSON even when it asks for HTML, D-0092). - Pages without a bundler:
/dokebi/seal/http.mjs(map it to@dokebi/seal/httpwith an import map; with @dokebi/appdata.seal.headwrites that map). - Inertia apps (Vite):
dokebiInertia()of@dokebi/template-inertia/viteinvite.config.ts(the CLI writes it) makes everyimport … from '@dokebi/seal/http'(and@dokebi/seal/forms) of the app's browser code load the served/dokebi/seal/http.mjs, in the build and the dev server. The bootstrap (sealInertia(http)) andsealedFetchin pages are then one instance, with a plain static import in the page. - It cannot be combined with SSR (
SEAL_SSR_CONFLICT): SSR renders props into plain HTML.
EJS forms on seal hosts (@dokebi/seal/forms, D-0092)
A seal host refuses a plain form body (415 SEAL_REQUIRED), so server-drawn pages (EJS) load the forms module, which sends every POST form sealed. With @dokebi/app the layout writes one line in <head>, before other module scripts:
<%- data.seal.head %> <!-- seal host: the import map of @dokebi/seal/http and @dokebi/seal/forms + the forms module; elsewhere '' -->- What it sends: every same-origin
<form method="post">(the submitter'sformaction,formmethodandformtargetwin) unlessdata-seal="off", another target than this window, or another origin. The form data with the submitter'sname/valuebecomes an object of the urlencoded shape (strings; a repeated name an array of strings in form order; an empty file input its empty file name) and goes throughsealedFetchwithx-dokebi-form: 1. The CSRF field_csrfstays in the sealed body (@dokebi/csrf reads it after the body is opened). With a chosen file the form goes as multipart (file bytes are not sealed, D-0062) with the same header, the_csrffield moved into thex-csrf-tokenheader (the field name:sealForms({ csrfField })). - The server (@dokebi/web 0.4.3) treats the marked request as a form post: the handler gets the same
request.bodya urlencoded form gives (checked server-side), errors take the HTML form flow (422 back witherrorsandold, other errors the HTML error page), and every redirect is told as 204 +x-dokebi-location. - The answer: a told redirect (or one the fetch followed) →
location.assign; HTML → the document is replaced with it (document.open/write/close; the listener is added again); a sealed JSON error → a short message in a<p role="alert" data-seal-form-error>at the top of the form; a failure to send → the same message,console.errorand a rejectedsubmitSealedForm. A form in flight (aria-busy="true") is not sent again. - Start: importing the module listens for submits at once and arms
sealedFetch(armHttpSeal, the prefix taken from its own address<prefix>/dokebi/seal/forms.mjs). It starts HTTP sealing (startHttpSeal, the WASM) at load only when the page has a POST form, else on the first sealed submit or the first same-originsealedFetch; a submit or a fetch before the WASM arrived waits for it, so nothing goes out plain. A read-only page loads no WASM.sealForms(options?)returns the same start;submitSealedForm(form, submitter?)sends a form yourself —form.submit()fires no submit event and goes out plain (415); useform.requestSubmit()orsubmitSealedForm. - One module instance:
/dokebi/seal/forms.mjsimports./http.mjs, the file the import map names@dokebi/seal/http, so the page'ssealedFetchshares the bridge the forms module turned on. Bundled apps import@dokebi/seal/formsand@dokebi/seal/httpfrom the package (one copy), or keep them out of the bundle (Inertia:dokebiInertia(), above). Do not mix the served files with a bundled copy. - A layout with its own import map: write
data.seal.headfirst. Browsers without multiple import maps (Chrome before 133 and the like) take only the first map: then add the two entries to your own map and load/dokebi/seal/forms.mjsyourself. A Content-Security-Policy with script nonces must allow the inline import map.
Replay nonces of sealed HTTP bodies (D-0096)
A captured sealed body is accepted once. With several node:cluster workers or servers that holds only when they share one nonce set, so the plugin takes the store as an option:
dokebi-seal:
nonces: { driver: redis, connection: main } # a dokebi-redis connection; the WebSocket hub is not needed| Option | Default | Meaning |
|---|---|---|
| nonces.driver | auto | auto (the hub's nonces() when @dokebi/websocket-hub is registered, otherwise this process), memory, redis or hub |
| nonces.connection | — | redis: the connection name in dokebi-redis.connections (required) |
| nonces.namespace | dokebi | redis: key prefix (<namespace>:seal:nonce:<nonce>, and the epoch key <namespace>:seal:epoch) |
| nonces.timeoutMs | 2000 | a shared store slower than this refuses the body as 503 |
| nonces.max | 100000 | live nonces of the in-process store |
| nonces.allowInCluster | false | accept per-worker nonces in a cluster worker (warning instead of a failed start) |
- Redis: one atomic
SET NX PXper nonce, expiring just after 2 x drift (requiredTtlSeconds: 121 s for 60 s), on a Redis that never evicts keys (an evicting one logsREDIS_EVICTION_UNSAFEat start). - In a node:cluster worker, per-process nonces with anything sealed fail the start (
SEAL_NONCES_NOT_SHARED): a replay sent to another worker would be accepted there.nonces.allowInCluster: trueaccepts that knowingly. - A store that fails or is too slow (Redis down, its pending-command limit, a timeout) refuses the body with 503
SEAL_NONCE_STORE_UNAVAILABLE+Retry-Afterand an error log with the cause — never a client 400. Only a body proven bad (no prefix, plain, drift, replay, does not open) is 400SEAL_BODY_REFUSED.
Testing a seal host (@dokebi/seal/testing, D-0101)
A seal host refuses plain request bodies (415) and seals its JSON answers, so fetch and curl cannot test it. SealedClient sends what the browser runtime sends — the page and its CSRF token (and the seal wasm), sealed forms (x-dokebi-form: 1, _csrf inside the sealed body), sealed JSON (x-dokebi-sealed: 1, x-xsrf-token from the XSRF-TOKEN cookie) and Inertia visits — and opens every sealed answer. One client is one browser identity (cookie jar, token, clock offset, its own keep-alive http.Agent with connections sockets), so a load test makes one client per user and runs them concurrently:
import { SealedClient } from '@dokebi/seal/testing'
const clients = Array.from({ length: 200 }, () => new SealedClient({ origin: 'http://shop.test:3000', connect: 'http://127.0.0.1:3000', connections: 2 }))
await Promise.all(clients.map(async (client, index) => {
await client.navigate('/login')
await client.submit('/login', { email: `user-${index}@shop.test`, password })
}))
const answers = await Promise.all(clients.map((client) => client.fetch('/api/items/1/reserve', { method: 'POST', json: {} })))
// exactly one 200 for the last item, 409 for the rest, a 503 only with Retry-After, never a 500
await Promise.all(clients.map((client) => client.close()))The full reference is AI.md §5.5; a copyable script is examples/49-sealed-load-test. The cipher is this package's Node mirror of the WASM (wsSeal/wsOpenWithNonce), every request is sealed anew (fresh nonce), and an answer that does not open is an error (SEAL_CLIENT_OPEN_FAILED), never plain text.
Server side (Node)
import { MemoryNonceStore, SealTerminator } from '@dokebi/seal/node'
const terminator = new SealTerminator({
domain: 'app.a.com', path: '/ws', ua: request.headers['user-agent'] ?? '',
nonces: hub.nonces(), // shared: hub.nonces() of websocket-hub (cluster) or new RedisNonceStore(redisConnection); one process: new MemoryNonceStore()
})
const frame = terminator.encode(JSON.stringify(message)) // 'E:<ts>:…'
const plain = await terminator.decode(incoming) // failures are SealError (reason: no-prefix, plain-rejected, drift, replay, capacity, unavailable, open-failed)The global registry of the original x-seal (sealNonces) was not ported; the store is a constructor argument (D-0038, as in @dokebi/crypto).
Without nonces, replay protection is off. A store that rejects (other than full) makes decode reject with SealError SEAL_NONCE_STORE_UNAVAILABLE (reason unavailable, retryable: true, the store error as cause): the frame is refused, and it is the server's fault, not the client's.
Capacity (D-0075, fail closed). new MemoryNonceStore({ max: 100_000, logger }) keeps at most max live nonces. It never forgets a live one to make room (that would let a captured frame be replayed): expired nonces go first (all of them, earliest expiry first, through an expiry heap), and when every held nonce is still live the new frame is refused (NonceStoreFullError, NONCE_STORE_FULL → SealError SEAL_REPLAY_CAPACITY, reason capacity). Clients see a retryable answer (web 503 SEAL_BUSY, websocket 1013 Try again later), and the store warns NONCE_STORE_FULL once per 10 seconds with max, live and repeated. This trades availability under a flood of valid frames for replay protection. Size max above the sealed frames and bodies one process accepts within 2 × drift (120 s by default; 100000 ≈ 830 per second); beyond that use a Redis store (nonces.driver: redis, or websocket-hub mode: cluster) or a smaller drift. websocket-hub's local store behaves the same.
Build and checks
The built WASM and glue (wasm/: four files after the glue and exposure post-processing) are committed (like the original x-seal wasm/). So npm run build
only compiles TypeScript, copies wasm/ → dist/wasm and builds the single-file vite bundles, without Rust (CI: Node 24 slim, no Rust). Only after changing crate/
is it rebuilt with Rust and wasm/ committed with it. The build is reproducible (2026-10-15: the four files rebuilt from an empty target folder were byte-identical).
npm run seal:build # (root) tsc + wasm/ → dist/wasm copy + vite single-file bundles (no Rust needed)
npm run seal:build:wasm # (root) Rust 1.98.0 + wasm-pack 0.13.1: crate/ → wasm/ (--target web, --locked, path folding, glue and exposure post-processing) → dist/wasm
npm run seal:test # (root) build + type check + node --test test/*.test.ts (also part of the root npm test)
npm run seal:test:rust # (root) cargo test --locked (known vectors of the Rust original); not part of npm test
npm run seal:browser-check # (root) real Chromium (Playwright) against a real Dokebi app with sealed and plain hosts: loading /dokebi/seal/*,
# Node ↔ browser, every frame E: on the seal host, the plain host, broadcasts, 4415,
# sealed EJS forms (data.seal.head, sealed wire, told redirect, 422 page, multipart, HTML 415/404)test/: parity (Rust ↔ JS known vectors), terminator (fail-closed, replay) and exposure (no paths, dependency versions, decoy or
key-input names in the wasm and glue), as in the original; node, client, context and replay, moved from the @dokebi/crypto tests;
socket (fake WebSocket: negotiation, hello, offset, 4400/4415), adapters (real Vue; React with a minimal hook runner),
http (HTTP sealing), nonces (store choice, Redis store, 503 for a failing or slow store), public-routes (the assets under dokebi-auth.default: required, a sealed login),
cluster-nonces (four node:cluster workers + Docker Redis: a replay to another worker refused, overload and a Redis outage answered 503, per-process nonces refused at start), testing-client (SealedClient against a real sealed app in one process: page token, sealed login form, sealed JSON and CSRF, Inertia visit and post, 40 clients racing one ticket, connections per client, failures closed), sealed-client-cluster (D-0101: four workers + Docker Redis and PostgreSQL, 200 concurrent clients: one winner of a conditional update, 503 DB_UNAVAILABLE from a saturated pool, never 500), forms (the forms module's shape, request, target and answer; a sealed form on the wire), integration (a real @dokebi/websocket server, plain) and sealed-server (D-0059 §4: two real servers + a disposable Docker Redis:
raw frames E: with no plain text, cross-server replay refused, drift refused, a device clock ±5 minutes off (worker), 4415, the plain host. Needs Docker;
skipped with DOKEBI_DOCKER=off).
