Pay-to-open content, previews, Discord-model public channels · qalpub/README.md
qalpub — Paid-Unlock Content & Public Channels
Service: python3 qalpub/qalpub.py → 127.0.0.1:8832 · Suite: bash test_qalpub.sh (42 assertions, green) + bash test_workspaces.sh (54 assertions, green — v2) · Date built: 2026-09-09 (v2 same day)
What: the public content layer of the Qals communication stack — deliberately on its own port, separate from the private relay (:8830, separation doctrine).
A. Paid-unlock content ("send NFTs/images/PDFs/data that only open once a chosen QALS amount is paid — previews allowed")
seller qalpub :8832 buyer
│ encrypt file (AES-GCM) │ │
├─ content blob ─────────► store │
├─ key wrapped for ESCROW► listing {price, preview?, title} │
│ │◄──── intent ───────────────────┤
│ │──── payment request ──────────►│ (loopd transfer → escrow agent)
│ │◄──── redeem + receipt id ──────┤
│ │ verify receipt vs loopd │
│ escrow→seller payout ◄───┤ release key WRAPPED TO BUYER ──►│ decrypt → sha256 must match
│ │ anchor unlock record on-chain │
- No payment → no key, ever (receipt-verified redemption; forged receipts 402, replays across listings 402, reuse 409)
- Previews: image or text-blur preview blobs shown before purchase (seller's choice)
- Trust model (honest): content confidentiality is cryptographic (escrow-wrapped keys), but v1's escrow trustee is the qalpub operator — on-chain upgrade path noted in code; unlocks are anchored so misbehavior is provable.
B. Public channels (Discord-model)
#channelswith topics; posts are public plaintext + ed25519 author signatures (that's the point of public);sig ✓/⚠verification on read- Daily Merkle digest of every channel's posts anchored on Qalnet (background thread + manual force) — tamper-evident public history without putting every post on-chain
- Discord lineage: channels now; servers/roles/threads are the next layer (see wallet Chat v3 note)
CLI
| Command | Who | What |
|---|---|---|
qalpay.py send-locked <to> <file> --price-cents N [--preview F] [--title T] [--public] |
seller | seal + list (+ direct manifest message to buyer) |
qalpay.py listings [--mine] / buy-unlock <id> |
buyer | browse / pay→redeem→decrypt→verify |
qalpay.py serve --as <handle> |
seller | payout-watching daemon (optional) |
qalchannels.py list / create <name> --topic / post <ch> <text\|--file F> / read <ch> |
everyone | public boards |
MCP tool specs (JSON block in source) are wired for agents: send_locked, list_listings, buy_unlock, channels_list, channels_post.
APIs
/health /escrow/pubkey · blobs POST /blobs GET /blobs/{id} · listings POST/GET /listings[/{id}] · POST /listings/{id}/intent POST /listings/{id}/redeem · channels POST/GET /channels POST/GET /channels/{id}/posts · GET /channels/{id}/digest
C. Workspaces & the Slack-class layer (v2, 2026-09-09)
On top of the v1 channels: workspaces, threads, reactions, mentions, pins, presence, search, and digest v2. Proof suite: bash qalpub/test_workspaces.sh (54 assertions, green).
- Workspaces
{workspace_id, name, topic, owner, channels[], members[]}—POST /workspaces(create; owner auto-member),POST /workspaces/{id}/join|leave(owner can't leave),GET /workspaces[/{id}](detail joinschannels_meta).POST /workspaces/{id}/channelscreates a channel inside the ws and links it. Membership is handle-based TOFU — exactly as strong (and as honest) as the v1 identity model. - Threads — posts take an optional
parent_post_id("<chid>:<seq>", same channel, must exist). The parent is signed into the canonical (v1 canonical is byte-identical when no parent, so old sigs/digests verify unchanged).GET /channels/{id}/thread/{seq}→{parent, replies[]}; every served post carriesreply_count. - Reactions —
POST /reactions {post_id, emoji, by}unique per (post, emoji, by) → dup = 409;DELETE /reactionsretracts (404 if absent);GET /reactions?post_id=→{emoji: {count, by[]}}; reactions also ride inline on every post read. - Mentions — server-side parse of
@handlein post bodies into a derivedmentions[]field (syntactic, unsigned — honest).GET /mentions?handle=&workspace=|channel=across a workspace, newest first, withsig_okflags. - Pins —
POST /channels/{id}/pins {post_id, by}(dup = 409),DELETE /channels/{id}/pins {post_id},GET /channels/{id}/pins→ pinned posts withpinned_by. - Presence —
POST /presence {handle, status(online/away/offline), ts}with 90 s TTL;GET /presence[?workspace=&now=](thenowknob lets auditors evaluate expiry at any instant).typing_tofield = DM typing beacons with a 5 s TTL. Honest note: presence is relay-observed liveness, NOT cryptographically attested. - Search —
GET /search?q=&workspace=|channel=— case-insensitive plaintext search over public post bodies + attachment names, results carrysig_ok. - Digest v2 — the daily Merkle digest now folds activity leaves (threads with reply counts, reaction counts+by, pins) into the tree:
type: qalpub-channel-digest-v2,merkle_root(v2),counts {threads, replies, reactions, pins}, plusmerkle_root_posts_v1so a v1 verifier recomputing leaves from served posts lands byte-exactly on the posts-only root (proven in the suite). Both v1 and v2 records verify on Qalnet via qalpipe.
Trust deltas (v2, honest): posts/thread-placement stay ed25519-signed + tamper-evident; reactions/pins/presence/workspace-membership are server-trusted, handle-claimed (paper edition) — the same trust level as v1 channel creation. Signed social actions are a phase-3 upgrade path.
CLI: qalchannels.py post <ch> <text> --reply-to <seq> --as HANDLE signs the parent into the reply; read shows ↳#parent and reply counts.