Skip to main content

Storage v2: encrypted drives

Implementation status: under construction on master (EPIC-020). Do not deploy this branch until the baseline and production migration are verified. WebDAV, path shares, app passwords and v1 sync have been removed. The Tasks and Guestbook reference apps are back as EPIC-026 apps on drives: the Guestbook keeps its entries as Markdown in a public folder of its own drive.

The current system-file API is a transitional adapter, not the drive engine: GET|PUT|DELETE /identities/{identity}/system/{path}. It stores public and relay-readable documents under .poweur, with owner authentication and schema validation. Private history and attachments remain unavailable. Phase 6 replaces its backing with the journalled drive; no client should depend on its disk layout.

Decisions and boundaries​

An identity owns one drive. A drive is a tree of random node IDs, immutable file versions and content-addressed chunks. The relay authorizes operations, sequences commits and accounts for bytes. It never receives a private node key or a private content key. The only reserved root name is .poweur.

One relay process owns a drive at a time. Distributed writers require the leases in E20-T17 and are not supported by the first implementation. A successful commit means its journal record is durable; an uploaded chunk alone is not a commit.

JSON is the wire format. Binary fields use unpadded base64url, hashes use lowercase SHA-256 hex, timestamps use UTC RFC3339. Protocol counters must fit an unsigned JavaScript safe integer (0 through 9007199254740991); implementations reject fractions and overflow. Signatures use explicit canonical field encodings, never arbitrary incoming JSON bytes. Unknown versions and duplicate JSON keys are rejected in signed protocol documents.

System files and writers​

PathWriterReaders
.poweur/public/id.jsonregistration / identity rotation, owner-signedeveryone
.poweur/public/profile.json, capabilities.json, avatar.<ext>ownereveryone
.poweur/relay/contacts.json, inbox-policy.json, analytics.jsonownerowner and relay
.poweur/relay/group.jsongroup identity, self-signedrelay; authenticated group lookup
.poweur/relay/shares/<id>.jsonowner / authorized share administrationrelay and authorized members
.poweur/state/devices.json, connected-apps.jsonrelayowner and relay
.poweur/private/messages/<peer-hash>.jsonlowner's clientsowner, encrypted
.poweur/private/read-state.json, logs/auth.logowner's clientsowner, encrypted

Contact blocking is represented in contacts.json; portable blocklist import / export uses the existing signed blocklist schema. Private log appends use the same encrypted record primitive as other append files. No private file is interpreted by the relay. The transition currently stores connected apps in the owner-written relay zone; moving this to relay-written state is part of Phase 6, not a completed property of the temporary API.

Implemented system zone. The relay cannot find a file in the encrypted tree (names are sealed and name hashes are keyed by the parent's private key), so .poweur/public, .poweur/relay and .poweur/state are not encrypted tree nodes. They are the drive's system zone: plaintext documents stored once per content at system/<sha256> and changed by journalled system operations (system.put / system.delete in the changes feed and drive.changed events, with path instead of node). They share the drive's journal, snapshots, quota and restart path; clients present them at the same .poweur/… paths in a synced drive. .poweur/private is an ordinary encrypted folder the relay never interprets. The relay records the writer (owner or relay by zone); owner writes count against quota and are refused over it (507), the relay's own records are counted but never refused. Documents are verified against their journalled hash on read. id.json is written by registration and rotation, served from the relay's identity index and mirrored into the zone. Remaining Phase 6 gaps: connected-apps.json is still owner-written in .poweur/relay/ until sign-in moves to the relay (Phase 9); the spool is one object per entry under relay/spool/ rather than an append file; pending contact requests and sessions stay in memory.

Known settings are validated before durable commit. A successful commit updates the policy cache before its response: adding a contact and then receiving a message must observe the new policy. Unknown relay-enforced documents cannot silently acquire semantics. Owners cannot write the state zone. Schema errors return 422 and do not mutate the head, quota, journal or cache.

/.well-known/poweur/<file> serves the public zone. profile.avatar is a flat filename such as avatar.png, not a /pub path. Images must pass format and size validation. JSON and images receive nosniff; mutable heads have ETags and short cache lifetimes. Private responses use Cache-Control: no-store.

Provider objects and durability​

Both filesystem and S3 providers expose Get (optional byte range), Put, conditional PutIf, Delete, List, and optional presigned PUT/GET. Object keys are relative slash-separated paths; traversal, empty segments, backslashes, and symlink escapes are invalid. Conditional create and replacement are atomic. Missing objects and failed preconditions are distinct errors. ETags are opaque provider tokens, not content hashes.

Under POWEUR_DATA or an S3 bucket prefix, using SanitizeIdentityDirName:

drives/<identity>/journal/<20-digit-sequence>.json
drives/<identity>/snapshots/<20-digit-sequence>.json
drives/<identity>/versions/<node>/<version>.json
drives/<identity>/chunks/<sha256>
drives/<identity>/system/<sha256>
drives/<identity>/pages/<sha256>.json
relay/identities/<identity>.json
relay/spool/{messages,acks}/<identity>/<20-digit-sequence>.json
relay/keystore/<identity>.json
relay/group-shares/<group>/<drive>
relay/group-rosters/<group>.json

Journal segments are immutable JSON envelopes containing a format version, first and last sequence, previous segment hash and ordered accepted operations. The record includes the resulting node head, append position and accounting delta. Write required chunks and manifests first, then publish the journal segment by conditional create. Only then acknowledge and notify subscribers. An interrupted write before journal publication creates collectible orphans, not a visible node. After uncertain publication, retry by operation ID and compare the stored hash; an operation ID cannot be reused with different content.

Snapshots contain the applied sequence and journal hash, tree, retained versions, append cursors and author sequences, and quota/accounting state. They are immutable accelerators, not a second source of truth. Cold start validates a snapshot against the journal and replays its suffix. Missing or invalid snapshots trigger a full replay; a corrupt or missing committed journal segment fails closed. Never start an empty writable drive after an I/O or integrity error.

Filesystem writes sync the temporary file before atomic rename and sync the containing directory before acknowledgement. Presigned PUTs bind the expected SHA-256 checksum. A missing-chunk probe does not prove durability: commit verifies the referenced bytes and sizes.

S3-compatible stores​

Conditional publication uses PutObject with If-None-Match: * (create) and If-Match (replace). Startup writes a probe object, requires the second create to fail and an ETag replace to succeed. A bucket that overwrites instead is refused. The bucket must already exist.

A conditional write whose precondition fails but finds exactly its own bytes stored reports success, on every provider. SDKs retry writes that may have landed (and Ceph answers some racing writes with HTTP 500), so a 412 can answer the retry of a write that won; identical bytes are the same outcome for the journal.

Direct upload (S3_PRESIGN=1, the default) signs x-amz-checksum-sha256 into the PUT. The store must reject a body that does not match that full-object digest; the startup probe checks this and refuses to run with presign on otherwise.

StoreConditional PutObjectFull-object SHA-256 on a presigned PUTConfiguration
AWS S3YesYesSTORAGE_PROVIDER=s3, presign on
MinIO (current)YesYes. The conformance suite, including a mismatched checksum, passes against local MinIO when POWEUR_TEST_S3_ENDPOINT is setSTORAGE_PROVIDER=s3, presign on
Cloudflare R2Yes (If-Match and If-None-Match on PutObject)SHA-256 is documented as a composite checksum only, not a full-object checksumSTORAGE_PROVIDER=s3 and S3_PRESIGN=0 until a full-object checksum is available
Hetzner Object Storage (hel1, Ceph)If-None-Match: * works. A specific If-Match succeeds only when the ETag is sent without quotes; the provider detects that and signs the bare value. A burst of racing replaces can return HTTP 500, which is retried onceA presigned x-amz-checksum-sha256 is ignored. A body that does not match is storedSTORAGE_PROVIDER=s3 and S3_PRESIGN=0, so chunk bytes go through the relay. The startup probe uploads mismatched bytes to a presigned URL and refuses to start with presign on when the store accepts them

Any other S3-compatible service stays unsupported until that same suite passes against it. Set POWEUR_TEST_S3_ENDPOINT, POWEUR_TEST_S3_ACCESS_KEY and POWEUR_TEST_S3_SECRET_KEY, then run go test ./internal/drive/... from apps/api.

S3_PRESIGN=0, and the filesystem provider, do not give clients an upload URL. Chunk bytes then go through the relay, which still writes with a conditional put. That is the fallback when a store can sequence writes and cannot enforce the checksum. Conditional writes stay mandatory on every store.

Key tree and encryption​

Every node has an X25519 key pair. The root private key is sealed to the identity encryption public key. Other private keys are sealed to the parent's node public key. A file has a random 32-byte content key sealed to its node public key.

Sealing reuses message encryption: ephemeral X25519, HKDF-SHA256 with salt ephemeral-public || recipient-public, and ChaCha20-Poly1305 with a random 12-byte nonce. Messages retain poweur/msg/v1 byte for byte. Drive key seals use HKDF info poweur/drive/seal/v1; names and sealed records use distinct domains poweur/drive/name/v1 and poweur/drive/record/v1. AAD starts with the domain plus newline and both public keys, followed by an unambiguous context binding the drive, node, purpose and key generation. Reject malformed nonce/key lengths before AEAD. The context is four fields in order: owner identity, node ID, purpose, generation. Each is prefixed with a uint32 big-endian UTF-8 byte length; generation is unsigned decimal ASCII. Fields are 1–1024 bytes, valid UTF-8, without NUL. Generation must be a safe integer. Message seals have no extra context. Key-wrap purposes are node-key and content-key; chunks use content. No age or HPKE dependency is introduced.

Names are UTF-8 NFC, case-sensitive, nonempty, contain neither slash nor NUL, and are not . or ... Clients seal names to the parent public key. Name lookup uses HMAC-SHA256 over the normalized name. Derive its 32-byte key with HKDF-SHA256, IKM = the 32-byte parent private key, salt = parent public key, info = poweur/drive/name-index/v1. Names are at most 255 UTF-8 bytes after normalization. The root's .poweur name is reserved. A create-only outsider cannot compute the private name index; its sealed create is assigned a random opaque name token until the owner accepts and indexes it. Do not reveal the parent's private index key to drop-box writers.

Chunks use XChaCha20-Poly1305 with a random 24-byte nonce. Maximum user plaintext is 4 MiB. The encrypted payload contains its true byte length followed by content and zero padding; round to 4 KiB buckets (including the length header). The public chunk is nonce || ciphertext-with-tag; its ID is SHA-256 of those bytes. AAD binds the drive, node and content-key generation, but not a version or ordinal: unchanged chunks can be reused in later versions. Signed manifests bind their ordering. Equal content is not deterministically encrypted or deduplicated across identities. Public system files are explicitly plaintext and never claim padding or ciphertext confidentiality.

Versions, appends and changes​

A version contains format version, drive, node, version ID, parent version, operation, author, key generation, encrypted name and name hash (when changing), wrapped keys, chunk references, and signature. Each reference includes hash and stored byte length. Large lists use immutable pages of at most 1024 references; the signed manifest binds ordered page hashes and total count. An append adds pages and may replace only the last partial page, not the preceding history. Manifest canonicalization and its vectors are specified below and pinned in drive-manifests.json.

A replace commit names the version it replaces as its parent; the relay refuses (409) a parent that is no longer the head. Editors use this for optimistic saves: poweur drive put --base <version> exits 3 with {"error":"conflict","head":…} when someone saved first, and poweur drive get --version <version> reads the common base for a three-way merge (see the Markdown reference app, EPIC-026 E26-T2). poweur drive link get <url> <file> [--path] [--password] opens a key-in-fragment link without a Poweur ID, like the web viewer.

Replace commits compare base_version to the current head and return 409 with the head on mismatch. Creating a node requires a nonexistent ID and an unused sibling name hash. A move verifies the destination is not a descendant and changes only the wrapped node key and encrypted name; bytes remain encrypted as before.

Each append record includes node, author, per-author sequence, previous author record hash, encrypted payload or sealed record, and signature. The relay assigns a total per-node position. Duplicate operation IDs return the prior result; gaps, reordered author sequences and conflicting duplicates are rejected. Clients verify signatures and per-author continuity: signatures do not prove that the relay showed every reader the same inter-author order. Checkpoint gossip is future work; a malicious relay can withhold or fork views until clients compare them.

The group-commit window is at most 100 ms. All records in a batch become visible only after one durable segment publication. Trim requires owner/admin authority and a committed snapshot reference covering the discarded prefix. Readers behind the retained prefix receive a resync response naming the snapshot; they never silently skip missing records.

Retain versions for 30 days by default. Uncommitted uploads expire after 24 hours. Quota counts unique stored chunk bytes reachable from live and retained versions, plus explicit system/relay state accounting; per-member/link accounting controls write caps. GC marks retained versions and pins in-flight commits under the same per-drive serialization boundary before sweeping. It must not race a new commit into referencing a chunk already selected for deletion.

Implemented append-record encoding​

AppendRecord has format: 1, drive, node, author, generation, sequence, previous, chunks, optional sealed, and signature. Drive/author are canonical lowercase identities without surrounding whitespace. Node IDs are 16 random bytes encoded as 32 lowercase hex characters. Generation and sequence start at 1 and are safe integers. Sequence 1 has an empty previous hash; subsequent records name the SHA-256 hash of their author's preceding record on that node.

Exactly one payload form is allowed: 1–1024 encrypted chunk references {id,size}, or a sealed envelope with an empty chunks: []. A chunk ID is 32 bytes of lowercase hex; size includes nonce/padding/tag and must have the chunk format's size/alignment. The sealed envelope uses ephemeral_public_key, nonce, ciphertext, all strict unpadded base64url. Its encryption context uses purpose record:<author>:<sequence>:<previous> so author-chain substitution also fails AEAD.

The Ed25519 signing bytes are the UTF-8 prefix poweur/drive/record-sign/v1\n followed by uint32 big-endian length-prefixed UTF-8 fields, in this exact order: format (1), drive, node, author, generation, sequence, previous, chunk count; then each chunk's ID and stored size in order; then sealed ephemeral key, nonce, ciphertext (three empty fields for a chunk record). Counters are unsigned decimal ASCII. The signature is not included in its own input.

Record hash = SHA-256(poweur/drive/record-hash/v1\n || canonical signing bytes || raw 64-byte signature), encoded as lowercase hex. Verify the signature with the resolved author's key and check its role before applying content; knowing a record hash alone is not verification. Keep separate (sequence,hash) cursors for each (drive,node,author). A duplicate/reordered sequence, gap, or mismatched previous hash is an error and must not advance the cursor. Retry idempotency is the engine's separate operation-ID contract.

Implemented manifest and page encoding​

A ChunkPage is {format: 1, drive, node, chunks} with 1–1024 chunk references. Its hash (its content address, lowercase hex) is SHA-256 of poweur/drive/page/v1\n followed by length-prefixed fields: format, drive, node, reference count, then each chunk's ID and stored size. A page names its drive and node, so it cannot be spliced into another node's content.

A Manifest is one signed version of a node: format: 1, drive, node, version (16 random bytes, hex), parent (the base version; empty only on create), operation, author, generation, kind (file or folder), mode (replace or append for files, empty for folders), folder (the containing folder's node ID), name and name_hash, node_key, content_key, count and ordered pages, and signature. Every version carries the node's complete content: every page but the last is full, and count must fit the page list exactly. Operations constrain the fields:

OperationCarries
createno parent; node key; folder and name (both absent only for the drive root); a file's content key
replaceparent; a file's new content only
moveparent; new folder, new name and hash, node key re-sealed to the new folder; content unchanged
rotateparent; new node key (and a file's new content key) at a higher generation; content re-encrypted
removeparent; nothing else, count 0

Envelopes are bound with Context(drive, node, purpose, generation) using purposes name (sealed to the containing folder's public key), node-key (to the containing folder, or the identity key for the root) and content-key (to the node's own public key). A create by a writer who holds only the folder's public key (a drop box) seals its name and node key to the folder and uses a random 32-byte name_hash token; the owner, who can open both, re-indexes it with a move.

Signing bytes are poweur/drive/manifest-sign/v1\n followed by length-prefixed fields: format, drive, node, version, parent, operation, author, generation, kind, mode, folder, name (ephemeral key, nonce, ciphertext), name hash, node key (three fields), content key (three fields), count, page count, then each page hash. An absent envelope is three empty fields. Manifest hash = SHA-256(poweur/drive/manifest-hash/v1\n || signing bytes || raw signature). Readers verify the signature with the author's resolved key, the author's role, and that the fetched pages hash to the signed list in order and total count.

These formats are pinned by drive-names.json, drive-seals.json, drive-chunks.json, drive-records.json and drive-manifests.json. Do not infer a network API implementation from the primitive package.

Implemented drive engine​

apps/api/internal/drive/engine implements the journal described above with these current choices:

  • One operation per segment. Each accepted commit (manifest, append batch of up to 1024 records, trim or GC) is its own segment journal/<seq>.json, published with a create-only write after its pages and version manifests are stored. Group commit is not implemented yet.
  • Snapshots are written every 64 segments. Cold start takes the newest snapshot whose sequence and segment hash match the journal, then replays; a gap fails closed.
  • Several processes, one store. Before validating a commit or collecting, the engine replays any segments another process published (one missing-object read when nothing changed). A lost publish race returns ErrStale and the caller retries. Reads are served from the cache and may lag another process until its next write or event.
  • Idempotency. A request ID (16 bytes hex) returns the prior result for the same content and is refused for different content, across restarts.
  • Envelope versions. Node info carries key_version, name_version and content_version: the newest versions holding each envelope. A reader fetches a listing or a node's ancestry in one request and decrypts each child with the folder key it already holds, instead of walking every node's history and re-opening every ancestor. Readers still verify every version they use; the relay learns nothing new, since it already stores those versions.
  • GC never deletes a version that carries a live node's envelope: past the retention period such a version keeps its manifest and releases only its content. Otherwise it drops superseded versions through a journalled gc operation, deletes chunks no retained version or record references, and deletes uncommitted uploads older than the upload TTL. Clients that pause longer than the TTL between upload and commit must re-run chunks/missing. Run GC in one process per drive until E20-T17 leases exist.
  • Trim releases the chunks of trimmed records; the segments that hold them stay until journal compaction.

Public folders​

A public node is published on purpose (E20-T5). Its manifest sets public: true, carries its name in plain_name and a file's content key in plain_key, and has no sealed envelopes; its name hash is hex(SHA-256("poweur/drive/public-name/v1\n" + folder + "\n" + name)), which anyone can compute. Chunks keep the normal padded encrypted format, but since the key is public the relay (and any cache) can serve them. The canonical signed form appends public, plain_name and plain_key only for public manifests, so private manifests encode exactly as before. Public nodes never rotate.

The engine keeps public trees coherent: everything under a public folder is public, a public tree's top folder sits directly under the drive root, and no node switches between public and private (publishing a private item means uploading a public copy). The relay serves them at https://<identity>/pub/<folder>/<path>: files decrypted, folders as JSON (or an HTML index for browsers), /pub/ lists the public folders at the top. Responses carry an ETag (the head version), Cache-Control: public, max-age=60 and Content-Security-Policy: sandbox …, so a published page's scripts run in an opaque origin, away from the identity host's viewer and storage. Nothing private resolves under /pub.

HTTP surface​

All drive endpoints are under /drive/{identity} (apps/api/internal/relay/drive_api.go). Every request is a signed request: the caller signs method, path, query, time, a nonce and the body's hash with the identity key or a session key (X-Poweur-Session-Id), so there is no challenge round trip. The challenge flow of an inbox pickup is still accepted. The caller may be a visitor homed on another relay; its key is resolved like any peer's. Until node shares (E20-T7) only the drive's owner is permitted (403 otherwise) — superseded by shares below. Knowing a hash is never permission to read a chunk.

OperationRequestResponse
Drive root, usage, quotaGET /drive/{identity}{drive, root, used, quota, seq}; seq is the changes cursor now
Missing chunks / transfer URLsPOST /chunks/missing {chunks:[{id,size}]} (≤ 1024){missing:[{id,size,upload:{method,url,headers}}]}
Upload through the relayPUT /chunks/{hash} (body = encrypted chunk){id,size}; 422 if bytes do not hash to {hash}
CommitPOST /commit {id, manifest, pages} or {id, records} or {id, trim:{node,before,snapshot:{node,version}}}{seq, head, positions}; 409 {head} on a stale base
ChangesGET /changes?cursor=N&limit={changes, cursor}
Node at headGET /nodes/{node}node info (ETag = head)
ListingGET /nodes/{node}/listing?cursor=&limit={folder, children, cursor, shares, revoked}: each node with its signed versions (head first, then the versions carrying its key, name and content-key envelopes), and the share evidence on the folder's chain and those children
AncestryGET /nodes/{node}/path{path}: the node and the ancestors the caller may read, nearest first, each with its versions
ChildrenGET /nodes/{node}/children?cursor=&limit={children, cursor} (IDs only)
Retained historyGET /nodes/{node}/history{versions}
Signed manifest / pageGET /nodes/{node}/versions/{version}, …/pages/{page}immutable JSON
Chunk through a versionGET /nodes/{node}/versions/{version}/chunks/{hash}immutable bytes
Chunk of an append recordGET /nodes/{node}/chunks/{hash}immutable bytes
Author cursorGET /nodes/{node}/author-cursor (append role){sequence, previous}: the caller's chain position, so appending never re-reads the log
Append tailGET /nodes/{node}/records?from=N&limit={records:[{position,record}], next}; 410 {trimmed_before, snapshot}
SharesGET /shares{shares}: all for the owner; own and administered for members
Event streamGET /eventsSSE drive.changed, filtered per caller; drive.revoked then close
Link parametersGET /links/{link} (no auth){role, expires, kdf, salt, pow, password, remaining_opens}

upload.url is either the relay's own PUT /chunks/{hash} or, on S3 with S3_PRESIGN=1, a 15-minute presigned PUT whose x-amz-checksum-sha256 header binds the chunk hash. Commit requires the manifest or record author to be the authenticated caller, so a signed object cannot be replayed through someone else's session. Commit request IDs are 16 random bytes (hex); retrying one returns the first result. Limits: 16 MiB commit bodies, 4 MiB chunks, 1000 entries per listing page. Content-addressed responses carry Cache-Control: private, max-age=31536000, immutable; everything else is no-store.

After a commit is durable the owner's event stream (GET /events/{identity}) receives

{"type":"drive.changed","identity":"alice.example","timestamp":"…",
"drive":{"drive":"alice.example","seq":7,"node":"…","operation":"append","position":3,
"records":[{"position":3,"record":{…}}]}}

Appends whose records serialize to at most 16 KiB carry them inline. Events are advisory: a reconnecting client reads changes from its cursor. Cross-relay member subscriptions, presigned downloads and serving /.well-known/poweur/ from .poweur/public come with shares (E20-T7) and system files (E20-T6).

Shares identify nodes, not paths. Roles are capabilities: read, write, append, create, admin. Effective access is the union of applicable grants; append/create are not automatically read permissions. Each member gets the appropriate node key sealed to their identity key. Revocation immediately removes API access, then the owner rotates keys before further private writes. Previously downloaded bytes remain readable. Ownership transfer copies ciphertext to the new drive, re-seals the root key and re-issues shares under the new owner.

Implemented share format and enforcement. A share (drive/share.go, src/drive/share.ts, pinned by drive-shares.json) names the drive, a random share ID, the node, exactly one member identity or link ID, the role, the node key generation, the node private key sealed for the member (only for read, write and admin; append and create get only node_public), an optional RFC3339 expiry, caps (bytes, files, records, downloads, per_hour), link-password parameters (kdf argon2id-m65536-t3-p1, 16-byte salt, verifier_hash = SHA-256 of the verifier half), a proof-of-work difficulty for anonymous link writes, the issuer and issue time, and the issuer's Ed25519 signature over poweur/drive/share/v1 length-prefixed fields. Shares are committed through POST /commit ({"share": …} / {"unshare": {"id": …}}) and journalled; the relay verifies the issuer's signature and that the issuer owns the drive or holds admin on the node, that the generation matches the node, and that the node is not waiting for a rotation. Commits need: create → create on the folder; replace/remove → write; move → write on the node and create on the destination; rotate, trim, share and revoke → admin (a member may also revoke their own share). Reads need read on the node. Expired shares stop working at their expiry without a commit. Revoking a read/write/ admin share marks every live node below the shared node rotate_required: new content, new children and new key-bearing shares there return 409 until that node's key is rotated. Members discover their grants and sealed keys at GET /shares, read GET /changes filtered to what they can read, and stream GET /events; a revocation that leaves them no share closes the stream with drive.revoked.

Implemented links and caps. A link holder sends the link ID in X-Poweur-Link (and, for a password-protected link, the verifier half of the argon2id output, base64url, in X-Poweur-Link-Verifier) instead of a signed challenge, and acts as link:<id> with the link share's role. GET /drive/{identity}/links/{link} is unauthenticated and returns only what opening needs: role, expiry, kdf, salt, pow, whether a password is set and the opens left; unknown, expired and exhausted links are all 404. Listing GET /shares with the link opens it and is journalled against caps.downloads. Each link is limited to caps.per_hour requests and 20 wrong passwords per hour (429); a wrong or missing password is 401 link_password. Share caps are charged, durably, to the closest share that allowed the write: caps.files counts creates, caps.records appended records (both 429 share_limit), caps.bytes new chunk bytes (507). The owner is never capped. The viewer: https://<owner>/s/<link>#<secret> serves the standalone web page viewer.html (no app state, no identity) under default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self' https:; frame-ancestors 'none' and no-referrer. It removes the fragment from the address bar and history at load, derives the key (and password verifier) in the browser, opens the link with openLink (SDK), verifies authors from public identity documents, and decrypts file names and downloads locally. Both CLIs print this URL.

Implemented anonymous writes (file requests). Someone with only a link signs with a throwaway Ed25519 key as a guest author g<base32 key>.guest.invalid (drive/guest.go, guestAuthor in TS; pinned in drive-shares.json). .invalid is reserved, so the key is read from the name and never resolved. The engine accepts guest-authored manifests and records only when a link commits them, authorizes and charges them as that link, and refuses an identity committing anyone's writes but its own. When the link's share sets pow, every link commit carries X-Poweur-PoW-Token / X-Poweur-PoW-Solution for a single-use challenge from GET /auth/pow?purpose=drive-link&identity=…&link=… (at most 30 bits). A create-only submitter seals the new node's key to the folder's node_public, uses a random name token, and can neither list the folder nor read any submission — including their own.

Implemented groups as members. A share may name a group identity hosted on the same relay; its signed, verified roster (.poweur/relay/group.json) decides who the share reaches — members and admins alike. On a group identity's own drive (the group folder), roster admins act with admin everywhere and a share may name the group itself to reach every member. The engine reads rosters from a relay cache that never touches a drive (warmed at start-up and on first use, replaced when the relay accepts a new roster). A roster update that drops anyone journals a grouprevoke on the group's drive and every drive indexed as sharing with it (relay/group-shares/<group>/<drive>): their access ends at once, their streams close, and the subtrees of key-bearing shares to the group become rotate_required.

A group identity hosted on another relay can be a member too. Its roster is self-verifying, so a member presents it with any drive request (X-Poweur-Group-Roster, base64url of the signed group.json). The host relay verifies the group's signature, that the caller is on the roster, and that its epoch equals the one the group's relay reports now (GET /groups/{group}/epoch, public; 0 for identities that are not groups). A verified roster is cached and rechecked against the epoch at least every minute; a moved epoch drops it until a member presents the new one, so a removed member's copy stops working at once. The last verified roster is kept at relay/group-rosters/<group>.json; a newer one is diffed against it and departed members are revoked as for local groups.

Implemented offers and mounts (PCP-0008). Sharing with a member sends them an end-to-end encrypted sys.share.offer carrying the signed share, the drive's relay and the shared node's name and kind (a member cannot decrypt a shared root's own name). Accepting requires the offered share to be exactly the one the relay holds for the member (same signed hash) and to open the node; the client then records a mount in the member's own encrypted drive at .poweur/private/mounts.json and answers sys.share.accept. Revoking sends sys.share.revoked. None of these messages grants access. CLI: poweur drive share add (offers by default), drive accept <offer.json>, drive mounts, and --drive <owner> /<node>/… to work inside a mount.

A new private file: generate node/content keys, seal the name and keys, encrypt and upload chunks, then sign and commit its manifest. Editing one chunk reuses the other chunk IDs in a new manifest. Two replacements against one base produce one success and one 409; the losing client loads the base and head, merges locally, and submits a newly signed version.

Concurrent appenders submit individually signed author chains. The relay assigns positions 1 and 2 and durably publishes them together; both readers fold the same ordered tail. A create-only file request seals its new child key to the folder's public key, and cannot list or download any prior submission. Caps and existing proof-of-work protect anonymous writes.

A link uses /s/<token>#<secret>; only the viewer reads the fragment. A password uses argon2id with a random salt and separately domain-derived encryption and verification outputs. Exact KDF parameters and link documents remain E20-T7 work; no server password, fragment or decryption key may appear in logs or requests. The viewer uses strict CSP and no-referrer and clears the fragment after capture.

An agent editing contacts validates and uploads the plaintext system document. The relay publishes its commit and applies the policy before acknowledging. Cold start rebuilds that same policy from the provider, including when every local cache has been deleted.

Sync client​

poweur sync keeps a local directory and a drive folder in step (your own drive, or --drive <owner> --folder /<node> for a folder shared with you):

poweur sync run ~/Poweur            # pull, merge, push until both sides agree
poweur sync watch ~/Poweur # keep doing it: change stream + local polling
poweur sync status ~/Poweur # pending local changes, remote changes, conflicts
poweur sync service ~/Poweur --launchd # or --systemd: run watch at login

The client merges; the relay only refuses a stale write (409). Markdown and text files merge line by line against the last synced version; JSON merges by top-level key; .jsonl, .log and .csv files are created as append files, so each machine's new lines become records in the relay's order; anything else keeps the losing side as name (conflicted copy <device> <time>). Remote deletes move local files to .poweur-trash/<date>/. .poweurignore and --path limit what syncs; .poweur system files are excluded by default. Each sync reports its position, so devices.json shows when every device last synced.

Threat model and privacy inventory​

The relay and bucket see identity IDs, tree shape, opaque node IDs, padded stored sizes, timestamps, versions, authors, share members, public/relay-readable files, IP addresses and access patterns. Contacts and policies are intentionally visible to the relay. Private names, content and content keys are not. A stolen bucket reveals this same stored metadata; encryption does not hide the social graph of explicit shares. A compromised unlocked device can disclose keys and plaintext.

Signatures and AEAD detect mutation, substitution and invalid author chains, but not all rollback or fork attacks by a malicious relay. Clients retain last-seen heads and must report regressions. A fresh device needs an independently trusted checkpoint to rule out rollback. Revocation cannot erase a recipient's cached keys. Fragments can leak through browser history, copying or a compromised viewer; an abuse report deliberately supplies the fragment to its recipient.

Privacy copy and store labels must say: encrypted private content; provider-visible metadata and public/system data; operational network metadata; optional analytics subject to consent. They must not claim zero metadata or that the relay cannot read contacts. Final legal/store copy remains an explicit E20-T1 release gate.

Cutover​

Only plaintext system data is migrated: identity document, profile and its avatar, capabilities, contacts, policy, analytics, devices, connected apps and group roster. Old files, shares, links and history are deliberately dropped. The operator command must support dry run and idempotent restart, validate every imported document and move the old tree to identities.v1-backup/ only after successful migration. Rehearse against a copy of production before deployment. Private history, attachments, read state and consent logs must work on v2 before that cutover.