stable

Team vault topology

Admin console plus member app on one team vault — LAN live store, cloud sync-peer, offline replicas, and what the UI may honestly claim.

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 one SyncTarget.

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:

roleDirectionConflict resolutionTypical use
sync-peerbidirectionalConflictStrategythe cloud live copy
backuppush-onlyn/a — receives mergedan S3 or Drive dump
archivepush-only, coldn/along-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) and at (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

ConcernPackage
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