Skip to content
DocspackagesDocumentation

@lunora/platform

The host contracts every Lunora target implements — ShardHost, SocketHost, ShardDirectory, ShardKvStore, SchedulerHost — plus the capability matrix codegen reads.

PackagesPlatform

@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

ContractWhat it owns
ShardHostOne shard's execution slot: runSerialized, transaction, sql, alarms, waitUntil, shardKey
SocketHostHibernatable sockets: accept, getSockets(tag?), handleFor, idFor, optional retagging
ShardDirectoryName → stub resolution, with optional jurisdiction pinning
ShardKvStoreOrdered key-value with prefix scan, which ShardHost deliberately omits
SchedulerHostschedule/cancel, at-least-once, optional list/deadLetter, optional runtime cron registration (absent on hosts whose crons are declarative)
SecretValueHostput/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 on idFor precisely so you can. Fan-out is O(subscribers) and SocketHandle is 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-socket WeakMap memos 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 with openssl rand -base64 32. It never goes in vars or wrangler.jsonc, and it never goes in storage.
  • Fails closed. A missing or malformed key makes put and take throw SecretValueKeyError. 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 take return undefined. 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_PREVIOUS to the current key and LUNORA_SECRET_VALUE_KEY to a new one, then deploy. New values seal under the new key, and take opens values under either. Wait until every value sealed under the old key has expired: put caps a lifetime at 24 hours (SECRET_VALUE_MAX_TTL_MS). Then unset LUNORA_SECRET_VALUE_KEY_PREVIOUS and 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 host

Host 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.