- TypeScript 91%
- CSS 9%
- Docker <1%
- HTML <1%
priiv #
Small thoughts. Smaller circles.
A minimal dark web app for 120-character posts on atproto Spaces. Each user owns two real permissioned spaces: followers and mutuals. Priiv has its own follow graph. Likes live in the same space as their post.
Develop #
Use Node 24 and pnpm 11.23.0.
pnpm install
pnpm dev
Open http://127.0.0.1:5173. “Take a peek” opens a local demo: nothing is sent to a PDS, and demo data disappears on exit. Sign-in uses atproto OAuth; Priiv never asks for your password.
For live Spaces development, create .env using .env.example and set PUBLIC_URL to a public HTTPS origin forwarding to port 3001. The alpha PDS must be able to resolve the app's DID and call its membership endpoint. Keep APP_URL=http://127.0.0.1:5173 for local OAuth. A temporary managing-app URL becomes part of newly created spaces, so use a stable development hostname if you want to keep them.
pnpm test # protocol, access, sessions, suggestions, concurrency
pnpm test:ui # desktop + mobile; requires Chrome and pnpm dev running
pnpm build # generate Lexicons, typecheck, build the web app
Workspace #
apps/web: React + Vite, responsive dark UI, isolated demo.apps/server: Hono, atproto Node OAuth, SQLite, local access checks and durable background Spaces sync.packages/protocol: source Lexicons and generated@atproto/lexschemas, shared types and character validation.
Generated schemas are rebuilt with pnpm codegen; edit the JSON Lexicons, not their generated TypeScript. @atproto/lex supplies the typed client, schema validation, builders, URI types, and Unicode grapheme counting. Alpha packages are pinned together because prerelease caret ranges can otherwise resolve to incompatible 0.0.0 packages.
Protocol model #
The namespace is space.priiv, for priiv.space.
| Lexicon | Storage | Purpose |
|---|---|---|
space.priiv.posts |
space declaration | Space type; keys followers and mutuals |
space.priiv.post |
author's permissioned repo | Text, creation time; max 120 graphemes |
space.priiv.like |
liker's repo in the post's space | Strong reference to the post |
space.priiv.follow |
public repo | Priiv follow, keyed by SHA-256 of subject DID |
space.priiv.profile |
public repo, key self |
Independent Priiv display name and bio |
space.priiv.permissions |
permission-set declaration | OAuth permissions for Priiv |
at://{ownerDid}/space/space.priiv.posts/followers
at://{ownerDid}/space/space.priiv.posts/mutuals
at://{ownerDid}/space/space.priiv.posts/{audience}/{authorDid}/space.priiv.post/{rkey}
The space authority is the post author's account. com.atproto.simplespace.createSpace configures managingAppPolicy for both read and write access. The managing service is did:web:priiv.space#priiv in production. The PDS calls /xrpc/com.atproto.simplespace.checkUserAccess; Priiv verifies the authority's signed service-auth JWT, then checks the synced Priiv follow graph. The browser uses the same local access rules.
Onboarding requires a Spaces-enabled PDS. Setup errors distinguish unsupported methods (including the reference PDS's unknown-method RPC proxy fallback) from expired credentials, missing Spaces permissions, and temporary failures. The form retains the saved profile and offers the existing sign-out action; raw PDS error details are not displayed or logged by the setup endpoint.
Followers can read follower-space posts. Mutual-space reads require both directions. The owner always has access. Readers can write likes in their own repos; only authority-authored post records enter a feed, because Spaces write policies govern writer discovery rather than preventing people from writing arbitrary records to their own repos.
Feeds, likes, profiles, people, and sessions read SQLite, without restoring OAuth or contacting a PDS. An in-process daemon maintains the replica even with no browsers open:
- Discover accessible spaces from existing members with saved OAuth grants and their synced Priiv follows. The daemon restores persisted OAuth sessions and mints a short-lived, DPoP-bound app credential using an eligible member, as permitted by the Spaces spec. Credentials/private DPoP keys stay in memory; per-viewer authorization is separate.
- Register
did:web:priiv.space#atproto_space_syncerwith the authority before enumeratinglistRepos, falling back to the existing#priividentifier if a PDS has an older cached DID document. Both deliver to the same endpoint. Renew the registration before its returned expiry. SignednotifyWritecalls persist coalesced jobs before returning success; issuer, audience, method, and space ownership are checked. - Initially download
getRepoCARs, verifying the signed commit, MAC, complete LtHash index, and each record CID. Keep all collections/fields in Lex JSON, with separate post/like projections. Fetch referenced blobs and verify their size and CID separately. - Advance with
listRepoOpsfrom the last verified revision. Check operation predecessors, values, final signature/MAC, and LtHash. Missing history, migration, or divergence first fetches an authenticated index-only CAR, reuses records whose CIDs still match, and retrieves only changed values; large or racing deltas fall back to a full CAR. No incomplete page or partial batch is exposed. - SQLite WAL transactions publish records, projections, and checkpoints together. Durable leased jobs have capped exponential retries; a killed process's lease expires after 60 seconds. Partial downloads can be repeated, while the last verified snapshot survives. Acknowledged writes update projections immediately and fence older sync work.
- Listen to
com.atproto.sync.subscribeReposon tracked accounts' PDSes for public follows/profiles and account/identity lifecycle. Persist cursors alongside relevant invalidations. Reconnect with backoff; cursor gaps force reconciliation. Public snapshots check the repo commit before/after pagination and fence concurrent events. - Reconcile public accounts in the background roughly every minute; deny private reads if either account's graph is dirty, inactive, or older than two minutes. Reconcile space writer hints independently of notifications, and verify already-known repos at least every five minutes. Conservative credential renewal (at most 90 seconds) detects missed deletion notifications.
- Authenticated
notifySpaceDeletedor credential renewal's explicitSpaceDeletedpurges records, blobs, projections, jobs, and staging. Tombstones fence in-flight work; SQLite secure deletion and WAL checkpointing remove old page copies. Other renewal errors retain the replica but suspend serving it.
Browsers check /api/feed/updates every 15 seconds while visible. This returns only a change flag against an opaque, viewer/filter-scoped revision from the last feed response. Posts are not automatically downloaded or replaced; new changes highlight the refresh button, including on paginated feeds. Checks read only local metadata, never initiate protocol sync, and disclose no inaccessible activity. While relevant credentials or graphs are being revalidated, feed reads return pending instead of a partial replacement; existing browser views remain visible. Credentials are renewed 30 seconds before the conservative local expiry. Explicit first-login Bluesky suggestions, profile prefill, handle lookup, and protocol writes remain network operations.
Handle verification stores the claimed handle, last verified handle, and lookup status separately. DNS/HTTPS outages preserve a matching last-verified display label; a changed claim or definitively mismatched proof does not. These labels never grant access. Unverified first-time identities display their DID without an @ prefix. The external handle domain still needs valid DNS or HTTPS verification.
A graph snapshot being rebuilt is reported as pending, not as an empty replacement feed. The browser keeps that account's already-loaded view in memory and replaces it when a ready snapshot arrives. Home snapshots are separated by account/tab/audience and cleared on sign-out; unfollowing immediately removes the author's posts from saved views. The server still withholds private records while access is stale, and a ready empty feed clears the old view normally.
Follows and likes use deterministic keys, preventing duplicates on retry. Posts and likes are never written through the public repo APIs.
OAuth requests atproto include:space.priiv.permissions. The published permission set grants public follow create/update/delete, profile create/update, reads in Priiv spaces, like create/update/delete, and post create/delete plus space creation only for the authenticated user's own authority. Explicit collections constrain writes; read grants cover the whole space.
The source Lexicons must be published before sign-in works as com.atproto.lexicon.schema records under the DID controlling this namespace. The DNS TXT record _lexicon.priiv.space must contain did=<publisher DID>. The alpha authorization server resolves both space.priiv.permissions and space.priiv.posts for consent. Missing publication or DNS causes invalid_scope errors retrieving permission sets or space declarations. When switching an existing deployment to the include scope, the provider may retain old client metadata for up to ten minutes.
Run pnpm publish:lexicons --did <publisher DID> and open the printed loopback URL. This separate OAuth login requests only creation of public Lexicon schemas, verifies the selected account, publishes the six space.priiv.* source files, and reads each back. It does not write posts, read spaces, save credentials to disk, or overwrite existing schemas; identical declarations are skipped. The temporary grant is revoked when the callback completes. No app password is required. Keep the terminal running while authorizing. Use --port <port> if the default port 39831 is occupied.
Priiv profiles #
Onboarding creates at://{did}/space.priiv.profile/self with a display name (64 graphemes) and bio (256 graphemes). These fields are public, unlike posts. “Prefill from Bluesky” explicitly reads the same account's app.bsky.actor.profile/self into the form; it writes nothing until you save. An alpha account does not automatically inherit a different Bluesky account's profile. Later logins use the Priiv record and never repeat prefill. Edit from Your posts; version checks prevent overwriting a concurrently changed record. Existing sessions from before profiles were added may need to sign in again to grant the new collection permission.
First-login suggestions #
On a user's first successful OAuth sign-in, Priiv snapshots all app.bsky.graph.follow records from that account's PDS. The snapshot is stored once in SQLite and intersected with registered Priiv members for the People screen. A profile being searched or cached does not make it a Priiv account. Existing Priiv follows are excluded from suggestions. Someone joining later can match the saved snapshot without refetching the Bluesky graph.
Suggestions never create follows automatically. An unavailable PDS does not block sign-in: the failed import is recorded, and the People screen offers an explicit retry. Completed imports are not repeated. This uses the authenticated account's Bluesky graph; a separate alpha account does not automatically inherit a different main account's follows.
Railway #
Deploy the workspace root as one service. The Dockerfile builds the React app and runs the TypeScript server, which serves both the UI and API. The process binds to 0.0.0.0 and uses Railway's PORT.
- Attach a persistent volume at
/data. Keep one replica: SQLite and the OAuth lock are local to the process. - Set
APP_URL=https://priiv.space,PUBLIC_URL=https://priiv.space,DATABASE_PATH=/data/priiv.sqlite,HOST=0.0.0.0,NODE_ENV=production. UsePORT=3001when the custom domain targets port 3001. - Add
priiv.spaceto the service and point the apex CNAME/ALIAS at Railway's assigned target. Wait for its TLS certificate. - Connect the GitHub repository with
railway service source connect --repo mozzius/priiv --branch main --service priiv. Pushes tomaindeploy automatically.railway up --service priivis available for manual uploads. - Check
/api/health,/oauth-client-metadata.json, and/.well-known/did.json, then sign in with an alpha-enabled account and finish onboarding. Space creation and sync start automatically.
The SQLite volume holds OAuth state, sessions, membership, follow snapshots, the private replica, blob copies, sync jobs, and checkpoints. Users' PDSes remain the source of truth. Protect the volume and its backups as private data; keep backups bounded and purge deleted spaces from any externally maintained backups too. Log out invalidates the browser session; OAuth consent can also be revoked at the PDS.
The Docker container runs Node directly from the server workspace (no pnpm lifecycle wrapper). SIGTERM closes the HTTP listener and aborts the worker/streams, checkpoints SQLite, and exits zero. An eight-second drain deadline exits cleanly with unfinished jobs left durable for the next process; unexpected runtime failures still exit nonzero. Railway allows 15 seconds to drain. Keep one replica and the persistent /data volume attached across deployments.
Infrastructure lives in .railway/railway.ts using railway/iac, including the Dockerfile builder, health check, single replica, domain, and volume. Run railway config plan before railway config apply: omitted resources can be deleted. Imported environment values use preserve() so secrets do not enter Git. Set the listed variables before applying this configuration to a new environment.
Alpha limits #
Spaces provides access control, not end-to-end encryption. The PDS and authorized apps can read the data. Follow changes affect new access checks; already issued credentials can remain valid until expiration, and downloaded content cannot be recalled. Newly added followers can read older posts in that space.
This uses the experimental Spaces SDK release 0.0.0-spaces-alpha-20260915165437. Use non-sensitive test data you are prepared to lose. No live account credentials are included. Automated checks use synthetic data and mocked PDS responses; a real-account, cross-PDS flow also needs live validation.
Sync jobs have a three-minute deadline. CAR downloads are bounded to 256 MiB / 120 seconds and one million records; oplogs and writer listings to 1,000 pages; public Priiv snapshots to 100,000 follows / 2,000 pages; individual blobs to 64 MiB. Oversized/incomplete syncs retry and retain the prior checkpoint, never silently truncate. The one-time Bluesky import remains bounded to 100,000 records / 1,000 pages. HTTPS and WebSocket connections to DID-controlled servers block private-network IPs and redirects.
Research #
- Spaces alpha announcement
- Permissioned data proposal
- Official Bulletin example
@atproto/lexREADME@atproto/oauth-client-nodeREADME
Upstream JSON Lexicons were vendored from bluesky-social/atproto, branch permissioned-data-alpha, commit de009e6e83faea80d92d2fc32a8ad07863586b50. They retain their upstream MIT/Apache-2.0 licensing. Priiv's space.priiv.* definitions are local to this app.