pod-redirect
| Subpath | @noy-db/hub/pod |
| Factory | none — direct named imports |
| Cluster | F — Snapshot & Portability |
| Since | 0.6.0 |
Four situations need the same shape: a redirect pod that points at another pod, a decommissioned store's "moved to…" tombstone, a manifest re-point, and a fleet-member reference. Rather than four ad-hoc pointer formats, @noy-db/hub/pod ships one Redirect record, plus signRedirect / verifyRedirect to mint and check it, readPodRedirect to pull it off a pod header, and followRedirects to walk a chain.
The record
interface Redirect {
readonly v: 1
readonly target: string
readonly reason: 'moved' | 'release' | 'tombstone' | 'repoint'
readonly issuedBy: string // keyId (16-hex) of the signer
readonly sig: string // base64url Ed25519 over the record minus `sig`
}
Five fields, deliberately. No timestamps, no identities beyond the signer's fingerprint. A redirect target is disclosure-appropriate by definition — pointing somewhere is the whole job — so there is nothing else worth withholding.
Fail-closed, not "unverified"
signRedirect signs through the same canonical-JSON Ed25519 convention as the pod header. verifyRedirect(record, trustedKeys) checks it against a caller-supplied keyId → publicKeyB64 map.
The trust semantics differ from the pod header on purpose. A pod may legitimately be unsigned — legacy v1 pods exist and { sign: false } is supported. A Redirect has no legacy install base, so:
- An absent or malformed
sigis invalid. Not "unverified", not a degraded state to pass through. - An
issuedByoutsidetrustedKeysis treated exactly like a forged signature. There is no "untrusted but structurally fine" outcome, unlikeverifyPodHeader'suntrusted.
Why it lives in the plaintext header
The Redirect rides in the pod's plaintext header, not the encrypted body — the same allowlisted-header-key mechanism as species and engineRange.
The reason is structural. Every consumer this record exists for must follow it before authentication:
- A dispatcher or landing page routes a request to the right pod before any unlock happens.
- A connection-pod open flow must know it has been redirected before it has a key to decrypt with.
- A static page — no store, no enclave, no vault — resolves a chain using only the bytes it was handed and WebCrypto.
None of those can decrypt a body. A Redirect in the encrypted body would require exactly the credential the redirect exists to route around.
readPodRedirect(bytes) is a pure, synchronous, secret-free read. It does not verify — it extracts and structurally validates the shape, because a parser has no trustedKeys to check against. Any caller that intends to act on a Redirect must call verifyRedirect first, or use followRedirects, which does it for you.
followRedirects
async function followRedirects(
start: Uint8Array,
fetcher: (target: string) => Promise<Uint8Array | null>,
opts: { trustedKeys: Record<string, string>; maxDepth?: number },
): Promise<FollowRedirectsResult>
Walks the chain HTTP-redirect style, with four protections:
- Verify before follow. Each hop is verified against
trustedKeysbefore it advances the walk. An untrusted or forged hop never moves you. - Capped depth.
maxDepthdefaults to 8. - Loop detection. A target seen twice is a loop, not a longer chain.
- Hop provenance. Each hop's
{ target, reason, issuedBy }accumulates into an orderedhopslist, so a UI can render "moved from X via Y".
Four typed failures:
| Error | code |
|---|---|
RedirectBadSignatureError | REDIRECT_BAD_SIGNATURE |
RedirectLoopError | REDIRECT_LOOP |
RedirectDepthExceededError | REDIRECT_DEPTH_EXCEEDED |
RedirectUnreachableError | REDIRECT_UNREACHABLE |
Success returns { terminal, hops }. A start with no redirect header returns { terminal: start, hops: [] } immediately.
Tombstones — failing forward
A route tombstone is a Redirect with reason: 'tombstone': what a decommissioned store leaves behind so stale connection pods fail forward instead of with a bare connection error.
- Writing one is store wiring, not hub behaviour. The hub defines the record, the resolver, and these semantics. A store being decommissioned is expected to leave a
species: 'redirect'pod — or aredirectheader field on its existing connection pod — at the location clients already fetch. - No new discovery path. It occupies the same location the connection pod already did, so a client that knows how to fetch that connection pod will now fetch the tombstone instead.
- Offline clients show the last-known re-point. A client offline when the tombstone is written cannot learn of it in real time; it shows whatever hop list it cached from its last successful reach. This is precisely why
followRedirectssurfaces the wholehopslist rather than only the terminal — the cached list is the "last known re-point", and re-resolving on reconnect confirms or updates it.
Redirect is not a Locator
A Redirect says "go elsewhere once." A Locator says "where cargo lives."
A Redirect is a signed, single-hop pointer documenting an event — hence the reason. Following one is meant to end the redirect relationship, not establish an ongoing one; chains compose transiently.
A Locator is the steady-state answer to "where does this cargo actually live" — an address, not an event. target is a plain string today specifically because the Locator type does not exist yet; that is a forward seam, noted rather than blurred.
Keep them apart:
- Don't use a Redirect as a long-lived address.
- Don't expect a Locator to carry a signature or a
reason. - A snapshot pod's "newer version at…" hint is a Redirect — an event — never a live route. That line is what keeps static artifacts honest about what they claim.
See also
- pod-signature — the signing convention this shares
- manifest — where a manifest re-point uses this record