github.com/.../listam
A local-first app family with a real embedded P2P backend.
Listam is a React Native shopping list app where the visible UI is deliberately thin.
The durable list state lives in a Bare worklet that runs inside the app process, writes
to Corestore/Autobase, and synchronizes peers over Hyperswarm. The same backend is
planned to power desktop and headless surfaces.
Runtime
React Native + Bare
State Authority
Backend worklet
Replication
Autobase + Hyperswarm
Tests Found
0
Core thesis
What this project is trying to prove
Offline is the default
The README frames the database as the source of truth and networking as opportunistic.
That design appears in the code: UI actions optimistically change React state, then send
RPC commands to the backend for canonical log append and replication.
README.md
The backend is not simulated
The mobile app starts a packed Bare bundle using react-native-bare-kit.
That bundle creates its own RPC server, Corestore, Autobase, Hyperswarm, and blind
pairing state.
app/hooks/_useWorklet.ts:81
System map
Architecture
React Native UI
Header, list/grid views, join dialogs, loyalty cards, paywall
app/index.tsx
RPC over BareKit IPC
Bare backend worklet
Owns mutable list state, writer membership, networking, persistence
backend/backend.mjs
Corestore
Local Hypercore storage under app documents
Autobase
Encrypted append log plus materialized JSON view
Hyperswarm
DHT discovery and peer replication
BlindPairing
Invite-based writer onboarding
Boundary
Frontend files never open Corestore directly. They read/write through numeric RPC
commands defined in rpc-commands.mjs.
Reason for the split
React Native stays focused on interaction and rendering, while the Bare runtime can run
Node-like P2P libraries that are a poor fit for normal RN components.
Important coupling
The RPC command numbers are a shared ABI, short for Application Binary Interface: the
low-level contract that lets separately built pieces of software call each other
correctly. In Listam, that contract is the fixed numeric vocabulary both runtimes use
to understand each other over bare-rpc. The symbolic names in
rpc-commands.mjs make the code readable, but the
value that crosses the React Native/Bare boundary is the number itself. If
RPC_ADD stopped meaning 2 in one bundle while the other side
still dispatches command 2 as "add item," the frontend and backend would no
longer agree on what request was sent. Treat existing numbers as stable, append new
commands with new values, and rebuild/update both sides together when the table changes.
Shapes and contracts
Data Structures
ListEntry
{
text: string
isDone: boolean
timeOfCompletion: EpochTimeStamp
}
This is the public frontend item type. The backend appends richer items with
id, listId, updatedAt, and
timestamp, but the UI only relies on these three fields.
app/components/_types.ts
Autobase operation
{ type: "add", value: item }
{ type: "update", value: item }
{ type: "delete", value: item }
{ type: "add-writer", key: hex }
Operations are appended to Autobase, then the apply function materializes
them into a JSON view and notifies the frontend.
backend/backend.mjs:239
Invite
{
id: hex,
invite: hex,
publicKey: hex,
expires: number
}
Blind pairing invites are encoded with z32 for sharing. A runtime invite
is reused up to ten times before rotation.
backend/lib/network.mjs:168
Identity rule:
The effective item key is text, not id. Add/update/delete logic
filters and maps by text, so duplicate grocery names collapse into one item. This is simple
and human-friendly, but it makes quantities or duplicate stores hard to model later.
Local-first stack
Corestore, Autobase, Hyperswarm, and BlindPairing
Simple mental model:
Corestore is the local disk container, Autobase is the shared append-only database,
Hyperswarm is how devices find and connect to each other, and BlindPairing is the
invitation handshake that lets a new device safely learn which Autobase to join.
Persistence layer
Corestore
Corestore is a storage manager for Hypercore feeds. In this project it is the durable
home for the local writer core, the Autobase system cores, the materialized view, and
any peer cores that are opened by key.
Why Listam uses it
The app needs offline-first data that survives restarts. Corestore gives Autobase a
single local storage root under the app document directory, so the backend can rebuild
the list from persisted operations without a server.
Implementation details
initAutobase creates new Corestore(`${storagePath}-local`).
- The worklet receives
baseDir from Expo FileSystem and derives storagePath from it.
- The local core's user data is inspected and sometimes cleared when joining a different base.
- Static peer writer cores can be opened with
store.get({ key: peerKey }).
- On teardown, the store is flushed and closed after Autobase and swarm cleanup.
backend/lib/network.mjs:301
backend/backend.mjs:50
Shared database layer
Autobase
Autobase is the multi-writer append log. Devices do not call a central API to change
the list. They append operations to their local writer, replicate those operations, and
let Autobase produce a deterministic view.
Why Listam uses it
A shopping list is a good fit for an operation log: add item, update item, delete item,
add writer. That log can be replayed after a restart and merged with peer logs when the
network becomes available.
Implementation details
- Autobase is created with
valueEncoding: 'json', encrypt: true, and the saved or paired encryption key.
- The
open function returns a JSON view named test.
- The
apply function reduces add, update, delete, and add-writer operations.
- Frontend writes are rejected until
autobase.writable is true.
- A promise chain serializes writes to avoid concurrent append/flush races.
backend/backend.mjs:230
backend/lib/item.mjs:8
Network layer
Hyperswarm
Hyperswarm is the peer discovery and connection layer. It finds other devices that are
interested in the same discovery topic, then gives the backend live connections that can
be handed to Autobase replication.
Why Listam uses it
The project goal is no central sync server. Hyperswarm lets devices discover each other
by topic and replicate directly when both sides are online.
Implementation details
- The main swarm joins
autobase.discoveryKey, not the raw base key.
- Connections update peer count and call
autobase.replicate(conn).
- Both
server and client modes are enabled for the topic.
- A temporary swarm is also created during invite joining to bootstrap pairing and early replication.
- The temp swarm is cleaned up once the main swarm connects or the join flow finishes.
backend/lib/network.mjs:404
backend/lib/network.mjs:494
Invitation layer
BlindPairing
BlindPairing is the controlled first-contact mechanism. A new device starts with only
an invite code. The host uses that invite to decide whether to reveal the base key,
encryption key, and writer permission path.
Why Listam uses it
A collaborator cannot replicate a private encrypted Autobase without the right base and
encryption credentials, and the host must add the collaborator as a writer. BlindPairing
provides that join handshake without a Listam-owned server.
Implementation details
- The host creates an invite with
BlindPairing.createInvite(autobase.key) and shares it as z32 text.
- The guest sends its local writer key in
userData.
- The host appends
{ type: 'add-writer', key: writerKeyHex } when writable.
- The host confirms with
autobase.key and autobase.encryptionKey.
- The invite is reusable for up to ten successful pairings before rotation.
backend/lib/network.mjs:168
backend/lib/network.mjs:182
How the four pieces compose in Listam
- Boot: the frontend starts the Bare worklet and passes the app document directory.
- Storage: the backend opens Corestore at
{document}/lista-local.
- Database: Autobase opens or creates the encrypted multi-writer base inside that Corestore.
- Discovery: Hyperswarm joins the Autobase discovery key and replicates connections.
- Sharing: BlindPairing creates an invite so another device can receive base credentials.
- Authorization: the host appends
add-writer, making the guest writable after replication.
- Rebuild: on restart, Listam replays the Autobase view and sends the rebuilt list to React Native.
Interesting implementation details
The temp swarm is doing real work.
During joining, the temporary Hyperswarm connection is intentionally kept alive after
blind pairing. The code comments explain that closing it would kill the live Noise
connection that can immediately bootstrap replication while the main DHT swarm is still
finding peers.
Changing bases requires clearing stale Autobase boot data.
When the guest joins a host base, initAutobase clears local-core user data
like autobase/encryption and autobase/boot if the stored
referrer does not match the new base key. That prevents the guest from accidentally
using an old encryption key for a new base.
Writable state is the real permission signal.
The guest may receive credentials before it is actually writable. The backend polls
autobase.update() for up to 120 seconds, syncs any replicated items along
the way, and only declares join success once write access or peer connection criteria are met.
The materialized view is intentionally simple.
The view stores entries with op fields and reconstructs the current list
with a Map keyed by item text. This keeps rebuild logic small, but it also
means duplicate item names collapse into one logical item.
Audit lens
Security and Privacy Notes
Strengths
- No central list server is present in the app architecture.
- Autobase is configured with
encrypt: true.
- Mutations are rejected when the local Autobase is not writable.
- Backend duplicate instances are guarded by an exclusive lock file.
- Camera permission is requested only for loyalty card scanning.
Review Carefully
- Base keys, encryption keys, and invite data are stored as plain files, not Keychain/Keystore entries.
- Verbose logs print base keys, local writer keys, invite codes, peer keys, and item payloads.
- Invites are reusable up to ten pairings and do not have an obvious user-facing revocation control.
- Joining shares the encryption key and adds the guest as a full writer/indexer. Membership is append-only — there is no
remove-writer, so a peer cannot be removed once joined.
- Any writer can add further writers via
add-writer in apply; there is no owner-only membership authority.
- Read access requires the encryption key, which also grants full decrypt — there is no read-only or sync-only credential to back "storage helper" / "relay-only" roles.
- Deep links (
listam.ch/join?invite=) auto-join with no confirmation and tear down the current base.
- Invite expiry (
expires) is stored but never enforced; lista-invite.json is persisted but never read back.
- Loyalty card barcodes are stored in AsyncStorage rather than secure storage.
- Autobase corruption recovery deletes the base and recreates a fresh one, silently destroying data and identity.
- The app has no automated tests for join, corruption recovery, or storage migration.
Recommended Hardening
- Design a membership-authority + encryption-key-rotation model so members can actually be removed; separate "revoke invite" (stop new joins) from "revoke access" (re-key and re-invite).
- Restrict
add-writer to an owner key, signature-verified in apply.
- Require explicit user confirmation before any link-initiated join, and do not tear down the current base until confirmed.
- Default invites to single-use with enforced expiry; delete the unused
lista-invite.json persistence.
- Move encryption keys, writer keys, owner-control tokens, invite secrets, pairing secrets, and loyalty card barcode data into platform secure storage.
- Redact secrets and item/card payloads from logs, then add production log-level gates, a
no-console lint rule, a CI secret-grep gate, and automated redaction tests.
- Back up / export and require consent before any corruption-triggered wipe; never auto-wipe a headless storage node.
- Add invite revoke/rotate controls, visible lifetime, bounded use counts, access-mode labels, expiration, and audit events.
- Add tests for item reduction, duplicate handling, category lookup, join rollback, corruption recovery, storage migration, and invite lifecycle behavior.
- Add direct dependencies for loyalty-card rendering imports such as
react-native-svg and qrcode-terminal, then add an undeclared-import check.
Deeper analysis:
The
Review Findings in the Plans section rank these
issues by severity (C1–C3, H1–H3, M1–M5) and pair each with a concrete fix,
implementation plan, acceptance signal, and test expectation.