Team vault topology
Audience: you are building two apps — an admin console and a member app — against one shared team vault, and you need to know which composition is supported rather than inferring it from
.d.ts. Bundle: core +withSync()+ one local store per instance + at least oneSyncTarget.
One team vault, two apps, three stores. The live copy sits on the LAN, the durable copy is a cloud
sync-peer, and members hold offline replicas. Written against 0.9.0: every symbol below is on
the published surface at that version.
There is no "topology" API, and that is the point — a topology is a local store, a list of sync targets, and a set of properties you can rely on. What is worth writing down is the properties.
Prerequisites
None beyond the Bundle line, with one obligation: every instance needs its own local store. A shared local store is not a supported topology.
What this gets you
An admin console and a member app that converge on one vault, survive going offline, can be restored from a pod under a live target, and whose UI can state honestly what has and has not been validated.
Setup — composition
One local store per instance, and a list of targets. Each app instance opens its own local store — never a shared one — and declares where else the data lives:
import { createNoydb } from '@noy-db/hub'
import { withSync } from '@noy-db/hub/sync'
import { toBrowserIdb } from '@noy-db/to-browser-idb'
import { toAwsDynamo } from '@noy-db/to-aws-dynamo'
const db = createNoydb({
store: toBrowserIdb({ name: 'team-vault' }), // the LOCAL copy — one per instance
syncStrategy: withSync(), // ⛔ required; without it there is no sync at all
targets: [
{ store: toAwsDynamo({ table: 'team' }), role: 'sync-peer', label: 'cloud' },
],
})
SyncTarget.role is one of three values, and the role — not the store — decides direction:
role | Direction | Conflict resolution | Typical use |
|---|---|---|---|
sync-peer | bidirectional | ConflictStrategy | the cloud live copy |
backup | push-only | n/a — receives merged | an S3 or Drive dump |
archive | push-only, cold | n/a | long-term retention |
⚠️ A target with no declared policy syncs on explicit calls only. Automation starts from a
declared policy — per target, or instance-level syncPolicy. A target that appears configured and
never syncs on its own is almost always this.
⛔ by-peer is a sync TARGET, not a local store. Using a session-share transport as the local
store gives an instance with no durable copy of its own: it works while a peer is connected and loses
the vault when that ends. The LAN live store is a real store (to-file, a server-backed adapter); the
peer transport carries sessions, not custody.
Adopt from day one: one local store per instance, withSync(), a declared policy per target, and
label on every target — it is what DevTools and audit logs print, and an unlabelled target is
indistinguishable from another in the same list.
Restore under a live target
A pod is the portable artefact: writePod / readPod on the root barrel, both available in the
browser.
⛔ Before 0.9.0, a restore was local-only and the next pull reverted it — the target still held
the newer _v and won. That is fixed, and the fix is a method, not an import:
await db.replaceRemote(vault) // make the target match this local copy, deliberately
await db.realign(vault) // repair a corrupted LOCAL envelope from the target's healthy copy
The two point in opposite directions and are not interchangeable. replaceRemote asserts this copy is
the truth; realign asserts the target's copy is. ⚠️ An ordinary pull() does neither — it cannot
repair a corrupted local record, because the corrupt row still wins on _v.
What travels in a pod and what does not: records and the keyring travel; _history, _ledger and
_sync do not. So a restored vault is a working vault without its audit trail, and a UI that offers
"restore" should say which of the two the user is getting.
Member credentials for a cloud store
⭐ Every role can mint its own cloud-store credentials as of 0.9.0 (@noy-db/hub/broker). Before
that, broker was admin-only twice over and members had to reach the cloud through an admin-served
store — that interim shape is no longer necessary.
grant() mints a per-grantee credential and enrols the member with the host; revoke() de-registers
it. ⚠️ Existing members need a re-grant (or magic-link recovery) to be enrolled — an already-granted
member from an earlier version is not retroactively enrolled, and the symptom is a member who can
open the vault locally and cannot reach the cloud store.
⛔ The admin path is byte-identical, so an admin console that works proves nothing about a member app. Test the member path on a member's credential.
Same-user handoff between two apps
There is no primitive for this, by doctrine. Two apps on one device are two clients; a vault does not have a notion of "the same human in another tab".
The one-tap route is WebAuthn under one registrable domain — an admin console at admin.example.com
and a member app at app.example.com can share a credential scoped to example.com. ⚠️ That makes the
rpId choice load-bearing and easy to get wrong: a credential registered under the wrong rpId is not
recoverable by changing configuration later, because the authenticator bound it at registration.
Anything else — a link, a QR, a share-link — is an onboarding flow, not a handoff, and should be presented to the user as such.
Rules vs records arriving by sync
⛔ Guards never run on pulled records. A record that arrives by sync was validated by whoever wrote it, not by this instance. So a UI must not claim that everything it shows passed local validation.
What 0.9.0 adds is that refusals are now visible rather than silent:
- An arbiter replicates its admission refusals, so the writer finds out at all. A refusal made on
another device arrives as
origin: 'arbiter'and is report-only — the local record is untouched. - A dirty record re-runs this device's own gates before pushing, so a record refused on the way out
(
origin: 'push-recheck') stays local and dirty, and pushes itself once it passes again. - A refusal carries both timestamps:
recordAt(when it was written) andat(when it was judged). An arbiter judges at pull time, so a record legitimately written while a period was open can be refused later. With both, a UI can say written then, refused now rather than your push was wrong.
So the honest claim is: "validated when written on the device that wrote it, and admitted by the arbiter as of the last pull" — not "valid".
Verify
The composition is right when: each instance has its own local store; every target carries a
label and a declared policy; a member (not an admin) credential reaches the cloud store; and a
restore followed by a pull() does not revert.
Where the pieces live
| Concern | Package |
|---|---|
| LAN live store | @noy-db/to-file, or a server-backed to-* adapter |
Cloud sync-peer | @noy-db/to-aws-dynamo, @noy-db/to-supabase, … |
| Browser local copy | @noy-db/to-browser-idb |
| Session share between peers | @noy-db/by-peer — a target, never the local store |
| Relay for a thin client | @noy-db/in-relay — ⚠️ published from noy-db/core, not noy-db/in |
| Member credentials | @noy-db/hub/broker |
⚠️ The daemon is below this layer, not beside it. It opens and runs pods and binds the store
contract; it does not know what a fleet is. Fleet orchestration is @noy-db/lobby's concern, and a
deployment that needs both takes lobby as a dependency rather than teaching the daemon about vaults.
See also
- Cloud store — configuring a cloud
sync-peerin detail - Browser durability — keeping the local copy alive in a browser
@noy-db/by-peer— session share, and why it is a target not a store- Ports — the store contract a
to-*adapter binds