From 2bd2d6a160b61c8a2a4a121c87ecd77ef1ff5b6c Mon Sep 17 00:00:00 2001 From: iacore Date: Sun, 2 Aug 2026 17:41:27 +0800 Subject: [PATCH] S017-S021 logs: PWA parity, invites, prod deploy, external sign-in, bootstrap scope fix - s017: PWA feature parity (multi-space, members, edit/move/delete, export) + per-user data rights - s018: space invite links end to end + gruvbox UI redesign - s019: production deploy on Fly.io (zds.linji.at + app0.linji.at) - s020: external sign-in (any atproto handle) + handle-resolution bug addendum - s021: bsky bootstrap 403 - request rpc scope for service-auth minting - roadmap: mark S017-S020 done, update dogfood note --- log/s017.dj | 69 +++++++++++++++++++++++++++++++++ log/s018.dj | 80 ++++++++++++++++++++++++++++++++++++++ log/s019.dj | 67 ++++++++++++++++++++++++++++++++ log/s020.dj | 98 +++++++++++++++++++++++++++++++++++++++++++++++ log/s021.dj | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++ roadmap.dj | 18 ++++++++- 6 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 log/s017.dj create mode 100644 log/s018.dj create mode 100644 log/s019.dj create mode 100644 log/s020.dj create mode 100644 log/s021.dj diff --git a/log/s017.dj b/log/s017.dj new file mode 100644 index 0000000..69f8a06 --- /dev/null +++ b/log/s017.dj @@ -0,0 +1,69 @@ +# S017 — PWA feature parity + per-user data rights (2026-07-31) + +## Context + +The web app could only read/move in one hardcoded space. Meanwhile the CLI +had grown: multiple spaces, create space, member management, edit/delete of +posts, thread export — plus account export/delete existed only as zds +endpoints. The user asked for per-user data rights (own-data export + +deletion) **before** demoing to the 同喜 class community; multi-space +support is needed anyway, so ship both in one PWA pass. + +## What changed + +**BFF (`app0/src/server.zig`, `core/spaces.zig`, `core/push.zig`)** +- `GET /api/spaces` — caller's spaces; space accounts see `owned`; others + see the BFF-configured community space if a member. +- `POST /api/space` — create space `{name?}` → `{uri}`. +- `GET /api/space/members?space=` — owner-gated roster. +- `POST /api/space/add-member` / `/api/space/remove-member` — owner-gated. +- `GET /api/account/export` — JSON of the caller's own records across + every space they belong to (`{did, handle, exportedAt, spaces[]}` with + their threads and posts). NOTE: mvp.dj (revised 2026-07-31) now says + data export should live in a separate program and "the API deliberately + does not own export" — this endpoint satisfies the user's direct request + and matches the doc's export *shape* (own records, as JSON), but is + interim until the standalone export program exists. +- `POST /api/account/delete` — deletes the caller's own posts and threads + from every space they belong to (their records only; other members' + records — including replies to them — are untouched), prunes their push + subscriptions, and ends the session. The account itself is preserved: + the user can log straight back in to an empty slate. Returns + `{posts, threads}` counts. +- `deletePost` now routes through a generic `deleteRecord` (collection + parsed from the record URI) — CLI `delete` and `move --delete` work for + threads too, not just posts. `collectionFromUri` handles both the + canonical and the space-qualified URI forms. + +**Web (`app0/web/`)** +- Sidebar: auto-listed spaces (no paste-URI-to-enter), owner ★, create + space (name input), join by URI, per-user account controls (export → + `.car` download; delete → irreversible, full-page confirm). +- Space page: thread list + thread composer for everyone; owner gets a + manage-members panel (roster, add by handle, remove) and a 导出 + (markdown thread export) action; posts show author/time; 编辑 / 移动 / + 删除 per post (delete own; all actions when authority). +- Route `#/s/` now takes any space URI; entering a space persists + it to localStorage so it survives relogin. + +## Verification + +- `zig build test`, `zig build scenario-space` (member-only space) — PASS. +- `zig build scenario-serve` — extended checkpoints incl. sections + 6d (spaces listing + create + members owner-gating), 6e (move via API, + thread export), 6g (account-data export JSON; delete cascades: session + 401, own records gone from spaces, others' records survive, account + still able to log in and post afterwards) — PASS. +- `bun run test` — 35 vitest (FakeBff parity for every new endpoint) — PASS. +- Real-browser smoke against a live rig: login → sidebar spaces/★/create/ + join/account links → thread view with 编辑/移动/删除 → owner members + panel with roster/add/remove. + +## Not in slice + +- Account (identity) deletion — only the user's linji records are deleted; + the DID/handle and PDS account survive. mvp.dj's "messages, then the + account" second step is deliberately not here: zds account deletion is + dev-tools-only and DID-censoring; production needs a real flow. +- Standalone export program (mvp.dj 2026-07-31) — see note above. +- Webauthn/passkey, markdown rendering in posts. diff --git a/log/s018.dj b/log/s018.dj new file mode 100644 index 0000000..fa7cef4 --- /dev/null +++ b/log/s018.dj @@ -0,0 +1,80 @@ +# S018 — space invite links + UI redesign (2026-08-01) + +## Context + +S017 gave the PWA full space CRUD, but the only way in was owner-side +add-member (you must already know the member's handle/DID) — no way to +hand a prospective member a link, which is how the 同喜班 class actually +invites people. Meanwhile the UI was still the S010 accordion with +Chinese copy and a dark-only theme; dogfooding needs something calm and +presentable on phones. + +## What changed + +**zds (`src/atproto/space.zig`, `src/http/router.zig`)** +- `com.atproto.simplespace.inviteInfo` — non-member-safe invite preview. + Validates in contract order (not-found → wrong-space → wrong-authority + → expired) reading the invite record through the store, bypassing the + space read ACL; returns `{valid, space, skey, inviter, inviterHandle, + expiresAt}` or `{valid:false, reason}`. Scope-gated only. +- `com.atproto.simplespace.join` — redeem. Same validation (403 + InviteInvalid with a `reason` field), idempotent member add, and the + `at.linji.join` event written into the joiner's repo via the normal + createRecord path. Deliberately NOT behind requireSpaceAccess — the + invite record IS the authorization. +- Both registered in the HTTP router table: the space.zig dispatch arms + alone are unreachable — router entries are what route requests. + +**BFF (`app0/src/server.zig`, `core/spaces.zig`)** +- `POST /api/space/invite` `{space, expiresAt}` → `{uri, url, expiresAt}`. + Owner-only: `createInvite` refuses non-authority callers up front — + member-minted invite records would be inert anyway (zds join requires + inviter == authority), so allowing them only writes confusing data. +- `GET /api/invite?space=&invite=` and `POST /api/space/join` — zds + passthrough. Passthrough bodies are duped to `req.arena`: httpz writes + the response after the handler returns, and scratch-arena bodies were + an EFAULT crash in writev. +- The invite record carries `$type` — zds's `validateRecordForWrite` + hard-requires `$type == collection` for any record written to a space. + +**Web (`app0/web/`)** +- Full redesign: gruvbox light + dark (system preference, toggle in the + account menu), sidebar shell + single stream + thread chips, sticky + composer, hover actions, native `` modals, mobile drawer + + scrim. English copy throughout (international-first). +- Invite UX: owner's Invite dialog (24h / 7d / 30d expiry → copyable + `#/join//` link); recipient side: logged-out landing + banner → sign in → join screen preview (space, inviter, expiry; human + reasons for expired / not-found / wrong-space / wrong-authority) → + Join → lands in the space. The add-space dialog also redeems pasted + invite URLs. +- Bug-class fixes found by the rewritten test suite + browser pass: + Solid dialog reactivity (uniform open-prop + createEffect pattern — + body-run sync, `props.ref.close()`, and the nonexistent `show` event + all silently did nothing), JoinScreen `Show` narrowing, composer + default-thread one-shot (`touched` flag), sign-out reload race, + Chromium's `flex-direction: column` UA quirk on `