@lunora/platform is types and a capability matrix, nothing else. It has no
runtime dependencies and near-zero runtime code: it exists so a second target
implements a contract rather than forking the engine.
Two hosts ship today, each in its own package: @lunora/platform-cloudflare
(Durable Objects, hibernatable WebSockets, DurableObjectNamespace — the
adapters plus the createShardPlatform / createWorkerPlatform composition
root) and @lunora/platform-node (better-sqlite3 and an in-process
registry, experimental, and a registered codegen target). A third gets its own
@lunora/platform-<target> package the same way; hosts are never subpaths here,
because each carries its own provider dependencies and this package must stay
installable anywhere.
import type { ShardHost, SocketHost, PlatformCapabilities } from "@lunora/platform";The contracts
| Contract | What it owns |
|---|---|
ShardHost | One shard's execution slot: runSerialized, transaction, sql, alarms, waitUntil, shardKey |
SocketHost | Hibernatable sockets: accept, getSockets(tag?), handleFor, idFor, optional retagging |
ShardDirectory | Name → stub resolution, with optional jurisdiction pinning |
ShardKvStore | Ordered key-value with prefix scan, which ShardHost deliberately omits |
SchedulerHost | schedule/cancel, at-least-once, optional list/deadLetter, optional runtime cron registration (absent on hosts whose crons are declarative) |
SecretValueHost | put/take/discard for a human-supplied value held for one step; a ref travels, the value does not. No get, list or has, and not a ShardKvStore |
Each is documented at its definition. Two contract notes are worth repeating here because they are behavioural, not type-level, and a host that ignores them compiles fine and fails in production:
- Attachment state must survive host recycling. Hibernation on Cloudflare, a process restart elsewhere. Everything a socket needs after a wake lives in its attachment.
getSockets(tag)must return exactly the tagged sockets. A superset fans updates out to subscriptions that never asked for them, which across tenants is a data leak, so a host without native tagging filters in userland.- Return your transport socket as the
SocketHandle. Identity lives out-of-band onidForprecisely so you can. Fan-out is O(subscribers) andSocketHandleis on that loop, so wrapping costs two extra call frames per socket, measured at +11% to +13% on whisper delivery at 128 and 1024 subscribers. A wrapper also gives one socket two identities, since the runtime's own message and close callbacks hand back the transport object: per-socketWeakMapmemos can then key on either and diverge. Wrapping is permitted, but the host that does it pays for it.
Secret values
SecretValueHost holds a human-supplied value between put and a one-claimant
take. A host never stores the plaintext: the raw layer (SecretValueStore)
mints the ref and asks seal(ref) for the bytes to write, and
encryptingSecretValueHost(store, { key, previousKey }) supplies those bytes as
AES-256-GCM ciphertext. The ref and the scope are bound as additional
authenticated data, so a record moved to another ref, or read under another
scope, does not open. The stored record is { keyId, iv, ciphertext }. The
keyId is the first 8 bytes of SHA-256 over the key, and the IV is fresh for
every put.
- Setup. The key is base64 of exactly 32 bytes, bound as a Worker secret
(
LUNORA_SECRET_VALUE_KEY). Generate it withopenssl rand -base64 32. It never goes invarsorwrangler.jsonc, and it never goes in storage. - Fails closed. A missing or malformed key makes
putandtakethrowSecretValueKeyError. Nothing falls back to storing plaintext. - Unreadable records read as absent. A tampered record, one moved to another
ref or scope, or one sealed under a key that is no longer configured, makes
takereturnundefined. It never throws with the plaintext in the error. A failed take does not bind the claimant, so the real claimant can still take the value once the record opens; the first take that opens it binds the ref. - Rotation. Set
LUNORA_SECRET_VALUE_KEY_PREVIOUSto the current key andLUNORA_SECRET_VALUE_KEYto a new one, then deploy. New values seal under the new key, andtakeopens values under either. Wait until every value sealed under the old key has expired:putcaps a lifetime at 24 hours (SECRET_VALUE_MAX_TTL_MS). Then unsetLUNORA_SECRET_VALUE_KEY_PREVIOUSand deploy.
What point-in-time recovery does. On Cloudflare Durable Objects, PITR
restores SQL data and key-value put() data to any point in the past 30 days.
The sealed secret records are key-value data, so they are included. The
documented way to start a restore is the object's own code: call
onNextSessionRestoreBookmark with a bookmark, then ctx.abort(). The docs
describe no other trigger, and this page does not claim there is none. PITR is
not available under local dev.
What that means for erasure. Encryption alone does not defeat a restore. A
restored record is still ciphertext, but it opens while the key that sealed it
is still configured. So erasure needs two steps: discard the value, and rotate
the key out (drop it from LUNORA_SECRET_VALUE_KEY_PREVIOUS and every other
binding). Until the old key is gone, a restore to a bookmark before the discard
can bring a value back readable. Cloudflare documents Worker secrets as managed
per Worker, and its rollback page does not say what a rollback does to them, so
this page does not claim how a rollback treats a secret.
Not claimed. This page does not say that a restored record is unreadable on its own, that Cloudflare keeps Worker secrets out of backups, or who can read retained history.
Capabilities
PlatformCapabilities rates each feature "native" | "emulated" | "unsupported".
Codegen intersects it with the app's feature probe and omits ctx.* surfaces the
target cannot serve, with a platform_unsupported_feature diagnostic. emulated
emits as-is.
import { CLOUDFLARE_CAPABILITIES } from "@lunora/platform";Conformance
A host is only correct if it passes the conformance TCK, which is the executable form of these contracts and ships as a subpath of this package so it can never version apart from what it asserts:
import { defineHostContractSuite } from "@lunora/platform/conformance/suite"; // pure — safe in workerd
import { createReferenceHost } from "@lunora/platform/conformance"; // adds the node:sqlite reference hostHost variance is declared through presence-based optional hooks on
ConformanceHost (createSocket, simulateRecycle, awaitAlarmFired,
cronTicks, scheduler) rather than skipped blocks, so a gap shows up in the
suite output instead of vanishing from it.
secretValues is the one optional surface held to the capability matrix. A host
passes its target's matrix as capabilities, and the suite requires the secret
store, its clock and its raw records exactly when that matrix rates
secretValues supported. A supported host that omits them fails, and so does an
unsupported host that offers them. The reference harness omits capabilities
and must provide every surface.
The TCK covers host primitives. It does not yet cover the engine-level
guarantees (OCC-409, poke ordering, RLS under a live subscription, scheduler
at-least-once). Those are implemented by code still being extracted from
@lunora/do, and they move into the suite as that completes. A passing host
means "the primitives are right", not "the engine will behave".
Adding to the matrix
Any new ctx.* or binding feature states its mapping per target, or its
explicit non-support, in the same change that adds it. That rule lives in
AGENTS.md; the matrix
is only honest if it is updated by the person who knows the answer.
A rating alone gates nothing. @lunora/codegen only acts on a feature it can
detect usage of — a row in CAPABILITY_ROWS mapped to the feature key in
CAPABILITY_TO_FEATURE — and only for the unsupported and undeclared states
(native and emulated emit identically). The full gate-bearing list, and the
keys that are parity reporting only, are in capabilities/types.ts's module header.