diff --git a/deno.json b/deno.json index a40bc13..9dcfd88 100644 --- a/deno.json +++ b/deno.json @@ -16,11 +16,11 @@ "build:icons": "deno run -A tools/make-icons.ts", "dev": "deno task build:ui && deno run -A --watch main.ts", "relay": "deno run -A relay/main.ts", - "test:spaces": "deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/provider.test.ts", + "test:spaces": "deno run -A src/sync/spaces/scopes.test.ts && deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/session-fetch.test.ts && deno run -A src/sync/spaces/provider.test.ts", "devnet:spaces": "bash tools/spaces-devnet.sh", "lex:publish": "deno run -A tools/spaces-publish-lexicons.ts", - "test:tiles": "deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts", - "test:sync": "deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/provider.test.ts && deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts && deno run -A src/sync/newcomer.test.ts && deno run -A src/sync/reconnect.test.ts && deno run -A src/sync/restart-invite.test.ts && deno run -A src/sync/signedout.test.ts && deno run -A src/server/keeper.test.ts && deno run -A src/server/deeplink.test.ts", + "test:tiles": "deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A src/sync/richtext-editing.test.ts", + "test:sync": "deno run -A src/sync/spaces/scopes.test.ts && deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/session-fetch.test.ts && deno run -A src/sync/spaces/provider.test.ts && deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A src/sync/richtext-editing.test.ts && deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts && deno run -A src/sync/newcomer.test.ts && deno run -A src/sync/reconnect.test.ts && deno run -A src/sync/restart-invite.test.ts && deno run -A src/sync/signedout.test.ts && deno run -A src/server/keeper.test.ts && deno run -A src/server/deeplink.test.ts", "desktop": "deno task build:ui && deno desktop --hmr -A --include static --include vendor --include tiles --include build main.ts", "build": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-mac-1024.png --output dist/Ziran.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran.app", "build:mac": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-mac-1024.png --target aarch64-apple-darwin --output dist/Ziran-arm64.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran-arm64.app", @@ -77,8 +77,9 @@ "@noble/curves/secp256k1.js": "npm:@noble/curves@^2.2.0/secp256k1.js", "@scure/base": "npm:@scure/base@^2.0.0", "@atproto/space": "npm:@atproto/space@0.0.0-spaces-alpha-20260818163953", + "@atproto/oauth-scopes": "npm:@atproto/oauth-scopes@0.0.0-spaces-alpha-20260818163953", "@atproto/jwk-jose": "npm:@atproto/jwk-jose@^0.2.4", - "@atproto/oauth-client-node": "npm:@atproto/oauth-client-node@^0.5.2", + "@atproto/oauth-client-node": "npm:@atproto/oauth-client-node@0.0.0-spaces-alpha-20260818163953", "@atproto/api": "npm:@atproto/api@^0.17.0", "yjs": "npm:yjs@^13.6.27", "y-prosemirror": "npm:y-prosemirror@^1.3.7", diff --git a/deno.lock b/deno.lock index 21fe407..50123e0 100644 --- a/deno.lock +++ b/deno.lock @@ -10,7 +10,8 @@ "npm:@atproto/api@0.17": "0.17.7", "npm:@atproto/jwk-jose@0.2.4": "0.2.4", "npm:@atproto/jwk-jose@~0.2.4": "0.2.4", - "npm:@atproto/oauth-client-node@~0.5.2": "0.5.2", + "npm:@atproto/oauth-client-node@0.0.0-spaces-alpha-20260818163953": "0.0.0-spaces-alpha-20260818163953", + "npm:@atproto/oauth-scopes@0.0.0-spaces-alpha-20260818163953": "0.0.0-spaces-alpha-20260818163953", "npm:@atproto/space@0.0.0-spaces-alpha-20260818163953": "0.0.0-spaces-alpha-20260818163953", "npm:@dasl/tile-lexicon@2": "2.0.0", "npm:@dasl/tile-loader@2": "2.0.0_@atcute+cbor@2.3.5__@atcute+cid@2.4.2", @@ -108,7 +109,7 @@ "dependencies": [ "@atproto-labs/fetch", "@atproto-labs/pipe", - "ipaddr.js@2.4.0", + "ipaddr.js@2.5.0", "undici_v6@npm:undici@6.28.0", "undici_v7@npm:undici@7.29.0", "undici_v8@npm:undici@8.10.0" @@ -188,15 +189,6 @@ "zod" ] }, - "@atproto/common-web@0.5.8": { - "integrity": "sha512-GiYY2Jgbg1aWe9QGT3TswC/wZNicipBDz0nFo4FrP6XqENEJJRxGKAf/zjvqbesfIQFhHXi1VbSFY7GStB8v8w==", - "dependencies": [ - "@atproto/lex-data@0.1.7", - "@atproto/lex-json@0.1.6", - "@atproto/syntax@0.7.3", - "zod" - ] - }, "@atproto/common@0.0.0-spaces-alpha-20260818163953": { "integrity": "sha512-lzXomMvIHMFbmXPpsnuNUok4yZB4E67FJyNL/D3RVqxzL+OgkyPilsOYPIVzCRLZFzrVdy8Lah7kfLdxaHptgQ==", "dependencies": [ @@ -283,6 +275,15 @@ "tslib" ] }, + "@atproto/lexicon@0.0.0-spaces-alpha-20260818163953": { + "integrity": "sha512-ncg3KVMl4aLj+fEJp1vu3Og6ZkKo/tBzz+5XClsk8zYAwRe6qNsJ+cYW5GrECxPvxuCvUra7ko9wMCwz3VJFmg==", + "dependencies": [ + "@atproto/common-web@0.0.0-spaces-alpha-20260818163953", + "@atproto/syntax@0.0.0-spaces-alpha-20260818163953", + "multiformats@13.4.2", + "zod" + ] + }, "@atproto/lexicon@0.5.2": { "integrity": "sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ==", "dependencies": [ @@ -303,17 +304,8 @@ "zod" ] }, - "@atproto/lexicon@0.7.10": { - "integrity": "sha512-HH/3d3z5Qt0JoJ9iOuLP/YT++6lb5wWMQsvgQ2TGfgkbuzDQ6AX6X1H5uJ9luPci4/LaADfpwLZfeqyy/Hjfaw==", - "dependencies": [ - "@atproto/common-web@0.5.8", - "@atproto/syntax@0.7.3", - "multiformats@13.4.2", - "zod" - ] - }, - "@atproto/oauth-client-node@0.5.2": { - "integrity": "sha512-rTGhniY1ukH3CcJqZ6GcV3GLybwY1JxhkC3au/ksnpuHQ9Yttrng67cZRcIw6gQWGWrGzEGOqXX/+Z41i/WyRw==", + "@atproto/oauth-client-node@0.0.0-spaces-alpha-20260818163953": { + "integrity": "sha512-XEJS6GMk5mG136FVcoSzzFynnMKGsJ3J2Z0oVWotwkYdCyawN1UCoxtKalMqzFRSsk6uWyiNKig1OK+XwC+weQ==", "dependencies": [ "@atproto-labs/did-resolver", "@atproto-labs/handle-resolver-node", @@ -326,8 +318,8 @@ "@atproto/oauth-types" ] }, - "@atproto/oauth-client@0.8.2": { - "integrity": "sha512-clYhVu/Y0prv3JGu8a4IOnC9jjwwkXFwgySgDXBpgStJ/JNmtH2Sss9XLVq5CDoAZBVLxdb/oT1W1iQJrkoTpA==", + "@atproto/oauth-client@0.0.0-spaces-alpha-20260818163953": { + "integrity": "sha512-LYCLFJuIkf3hgZxpFdN+tIkOtdRtszshS8aURcv/tEwlDWwIL86nryf/ORh+FCo/AxgJpXEZmr/zkyNBlGmdlw==", "dependencies": [ "@atproto-labs/did-resolver", "@atproto-labs/fetch", @@ -338,12 +330,19 @@ "@atproto/did", "@atproto/jwk", "@atproto/oauth-types", - "@atproto/xrpc@0.8.9", + "@atproto/xrpc@0.0.0-spaces-alpha-20260818163953", "core-js", "multiformats@13.4.2", "zod" ] }, + "@atproto/oauth-scopes@0.0.0-spaces-alpha-20260818163953": { + "integrity": "sha512-bQz4BwNk/FAPd8bXGO3EyhlIKu/UJzN5XIgmNeiZXtJbhbe30J78A3WnvBuA+SILiGFw7/M9hfgq9FcJKCzjCw==", + "dependencies": [ + "@atproto/did", + "@atproto/syntax@0.0.0-spaces-alpha-20260818163953" + ] + }, "@atproto/oauth-types@0.7.5": { "integrity": "sha512-x75O0HsKB1IGfBikAQrrTX6EL8Rt4Q0+wMcwhZQRGPk/N/WqbYbsW3Powj4R8ZJKSfWCpVfaw32Piu1pTi891Q==", "dependencies": [ @@ -384,11 +383,11 @@ "tslib" ] }, - "@atproto/syntax@0.7.3": { - "integrity": "sha512-7LU8Ra79L5wCqVAZZst2352VMMwkU4KNagVMm6pnURrY/uS7+HrJl3XCuA4V2Gy2dEpkt1r6DBxlsP43/VjDBw==", + "@atproto/xrpc@0.0.0-spaces-alpha-20260818163953": { + "integrity": "sha512-AW2zqwpUh8C4pkiDNbmM1FLjmVm5TShaGKDlGAiTMuxf43rfQ+FeBucb9H5w2iaIUQJ594dN3lHXijBATkGNcw==", "dependencies": [ - "iso-datestring-validator", - "tslib" + "@atproto/lexicon@0.0.0-spaces-alpha-20260818163953", + "zod" ] }, "@atproto/xrpc@0.7.7": { @@ -398,13 +397,6 @@ "zod" ] }, - "@atproto/xrpc@0.8.9": { - "integrity": "sha512-3jRiAuHCqYFoT8Nv25xFRmgrfmoo84yBnB5PfVP95JoRP+szIkwHS6mq72ZETYbWmLA1tNyR5QVEHrhHZ1LQhw==", - "dependencies": [ - "@atproto/lexicon@0.7.10", - "zod" - ] - }, "@borewit/text-codec@0.2.2": { "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==" }, @@ -798,8 +790,8 @@ "ipaddr.js@1.9.1": { "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, - "ipaddr.js@2.4.0": { - "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==" + "ipaddr.js@2.5.0": { + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==" }, "is-promise@4.0.0": { "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" @@ -1356,7 +1348,8 @@ "npm:@atcute/cid@^2.4.2", "npm:@atproto/api@0.17", "npm:@atproto/jwk-jose@~0.2.4", - "npm:@atproto/oauth-client-node@~0.5.2", + "npm:@atproto/oauth-client-node@0.0.0-spaces-alpha-20260818163953", + "npm:@atproto/oauth-scopes@0.0.0-spaces-alpha-20260818163953", "npm:@atproto/space@0.0.0-spaces-alpha-20260818163953", "npm:@dasl/tile-lexicon@2", "npm:@dasl/tile-loader@2", diff --git a/docs/SPACES.md b/docs/SPACES.md new file mode 100644 index 0000000..bbf164c --- /dev/null +++ b/docs/SPACES.md @@ -0,0 +1,192 @@ +# How Ziran Uses Spaces + +The reference for the shipped behavior — what Ziran actually writes, reads, +and enforces on [AT Proto Spaces](https://atproto.com/blog/atproto-spaces-alpha). +The design rationale and build history live in `SYNC-SPACES.md`; this +document describes the system as it runs. Spaces are the **primary sharing +method**: `ZIRAN_SYNC` unset means the Spaces provider; the parked relay+MLS +stack sits behind `ZIRAN_SYNC=relay`. + +Everything below is enforced by tests: the scope grants run through the PDS's +own matcher (`src/sync/spaces/scopes.test.ts`), the flows run against an +in-memory network (`client.test.ts`, `provider.test.ts`), and the whole loop +has been verified live against `pds.ziran.space` (spike 22/22 + a +three-device, two-account provider smoke, 2026-08-30). + +## The shape of a Ziran space + +A space is an access-control boundary at +`at://{ownerDid}/space/{type}/{skey}` containing **one repo per member, each +hosted on that member's own PDS**. Members read everything in the space and +write only to their own repo. Ziran defines two space types (published as +lexicons under the `lexicons.pds.ziran.space` authority, resolvable via the +`_lexicon.ziran.space` DNS TXT record): + +- **`space.ziran.space`** — the core type. One space = one shared context of + live documents, member-list policy, owned by its creator. Renamed from + `space.ziran.workspace` on 2026-08-30; nothing real ever shipped under the + old name (the OAuth scope bug blocked every go-live), so there is no + migration — the provider drops legacy registry entries on load. +- **`space.ziran.inbox`** — every signed-in identity keeps one at skey + `self` with **public policy**, so anyone can construct the ref from a DID + alone, mint a credential, and deliver an invitation into their own repo + there. It carries only `space.ziran.invite` records. + +### Collections in a `space.ziran.space` + +| Collection | rkey | Written by | Content | +|---|---|---|---| +| `space.ziran.info` | `self` | space owner | `{name, createdAt}` — the human name (simplespace has no name field) | +| `space.ziran.doc` | TID | doc creator | partial manifest: `{name, model, roles, createdAt}` | +| `space.ziran.op` | TID (client-assigned) | any member | `{doc, parents: [{author, rkey}], bytes}` — one tp-sync frame | +| `space.ziran.checkpoint` | doc rkey | any member | atile-style manifest + `frontier: {did → rev}` | +| `space.ziran.presence` | doc rkey | any member | `{doc, at}` — refreshed while the doc is open | + +Doc discovery in a space = the union of `space.ziran.doc` records across +member repos. The doc record's at-URI is stamped into the on-disk tile +manifest (`at` field) — the durable link between a `.tile` file and its +online identity. + +## Sign-in and permissions + +The OAuth scope lives in **`src/sync/spaces/scope.ts`** — the single source +imported by the sign-in (`atsession.ts`), the provider, and the scope test, +so they cannot drift. It is: + +``` +atproto include:space.ziran.permissions space:*?authority=*&action=read_self +``` + +- `include:space.ziran.permissions` pulls in the published permission set: + full read/write/manage on `space.ziran.space` (all five collections), + invite delivery on `space.ziran.inbox`, and blob upload. +- `space:*?authority=*&action=read_self` is requested **literally** because a + permission set can never grant a wildcard space type (the server's + authority rule silently drops such entries), and `listSpaces` — "which + spaces do I write into?" — needs exactly that wildcard. `read_self` lists + space refs only; it grants no document content. + +Grants are materialized into the token **at consent time**, so any change to +the scope or the published set bumps `SCOPE_VERSION` in `atsession.ts` +(currently 5, for the type rename): stored sessions under an older version +are retired at launch and the UI explains why a fresh sign-in is needed. +Dev/test sessions bypass OAuth entirely via `ZIRAN_SPACES_HANDLE/_PASSWORD/ +_PDS` password credentials (the space endpoints accept legacy sessions). + +## The flows + +### Going live + +1. Pick an existing space you're a member of, or name a new one — + `createSpace('space.ziran.space', memberList)` + a `space.ziran.info` + record with the name. +2. Write the `space.ziran.doc` record (name, model id, role assignments); + stamp its at-URI into the tile manifest. +3. Publish the **mandatory go-live checkpoint** so others can instantiate. + +### Syncing + +Pull-only, per member repo — there is no push a desktop app can use: + +- `listSpaces` sweep every ~60 s (multi-device discovery), `listRepos` + + `listRepoOps` per space every ~2 s while one of its docs is open, ~30 s + otherwise, and immediately after an own write. +- Outgoing frames coalesce ~300 ms (max 2.5 s under sustained typing) into + one `applyWrites` batch with **client-assigned TIDs**, so intra-batch + parent chains are correct in the records as written. +- Delivery is **causal, not total**: each op names its parents; an op waits + until its parents are settled; concurrent siblings deliver in + deterministic `(rev, author)` order. Dedup is per-doc rev watermarks + (revs are the only per-repo order — one DID can be many devices) plus an + own-echo list. Own frames come back through the same delivery path as + everyone else's: one tile code path. + +### Checkpoints (rare + lazy) + +Full state so joiners never replay from zero. Triggers: go-live (mandatory), +~500 own ops or ~512 KB since the last own checkpoint (at most one per +hour), or an explicit publish. Following `@dasl/atile`: every tile resource +(self-storage data included) uploads as a blob (skipped when the CID is +unchanged — blob CIDs are the same CIDv1/raw/sha-256 as tile resource CIDs); +the record carries the manifest with blob refs plus the rev `frontier` it +represents. Ops are **never deleted** — a doc is always rebuildable from the +first checkpoint plus the full op history. + +### Adopting (second device, or an invitee) + +Newest checkpoint from any member → download blobs → verify CIDs → assemble +the `.tile` via the normal writer → open → seed the dedup lines from the +checkpoint frontier → rewind the space cursors and let the ordinary poll +replay everything past the frontier. + +### Invitations + +Being added to a space is **not discoverable** (`addMember` does not notify; +`listSpaces` only shows repos you've written), so invites travel two +co-equal paths: + +1. The owner calls `addMember`, records the invitee's per-doc role (doc + author only), and writes a `space.ziran.invite` into the invitee's public + inbox space. The invitee's ~60 s inbox poll surfaces it in the + invitation strip; accept = adopt. Dismissals are app-local (the record + belongs to the inviter). +2. A `ziran://space?ref=…&doc=…` link (shown in the panel, never + auto-copied) adopts directly — it also covers invitees who had no inbox + yet (membership is granted regardless; the invite reports `pending`). + +**Membership is per-space, not per-doc** — invitees see every document in +the space — and **only the space owner can invite** (simplespace +restriction). The connections panel says both, verbatim. + +### Presence + +While a shared doc is open, the provider refreshes a `space.ziran.presence` +record (~60 s); peers read the beacons off the oplog and show a member as +present while their timestamp is under ~150 s. That is the entire ephemeral +story — no cursor streaming, `sendEphemeral` is a no-op. + +## What's enforced where + +| Concern | Enforced by | +|---|---| +| Read access, membership | protocol (credential mint + credentialed reads) | +| Write isolation (own repo only) | protocol | +| Invite rights (owner-only) | protocol (`manage=update`) | +| Role assignments | app — the doc record's author is the sole authority | +| Role meaning (who may edit, etc.) | the tile, from frame attribution | + +Honest caveat: there is no protocol write-gate below space membership — a +rogue member's records reach honest clients, which ignore them +deterministically. + +## Trust and privacy (alpha posture, chosen deliberately) + +- **No E2EE**: PDS operators can read document content. The trade is + credible exit — every member's contribution lives in their own repo under + a standard protocol, with no Ziran service in the critical path. The + relay+MLS stack is the recorded road back. +- **Public inboxes are enumerable** — invitations leak the collaboration + graph beyond "trust your PDS operator". Revisit via `appAccess: allowList` + or the E2EE return. +- Alpha data loss is acceptable; ops-never-deleted plus checkpoints make + documents rebuildable, not immortal. + +## Local state and failure behavior + +- `sync/spaces.json` (app data): known spaces, per-doc delivery cursors, + checkpoint bookkeeping. Everything except localId/path bindings and acks + is reconstructible from the network. +- Space-endpoint refusals are classified (`unsupported` / `permission` / + `other`) so the panel can say the true thing: "your PDS doesn't support + live documents yet" vs "sign out and back in to grant permission" vs an + actual reachability problem — with the raw error selectable and copyable. + A refused call never tears down a working session. + +## Verifying + +- `deno task test:sync` — the full suite (scope grants through the server's + own matcher, client + provider against the in-memory network, tile + convergence proofs, and the parked relay stack's suites). +- `SPIKE_INVITE= deno run -A tools/spaces-spike.ts https://pds.ziran.space https://pds.ziran.space https://plc.directory` + — 22 protocol checks against the live PDS. +- `deno task lex:publish` — idempotent lexicon publish + DNS check. diff --git a/docs/SYNC-SPACES.md b/docs/SYNC-SPACES.md index 302422d..7bc9830 100644 --- a/docs/SYNC-SPACES.md +++ b/docs/SYNC-SPACES.md @@ -1,5 +1,8 @@ # Ziran Sync on AT Proto Spaces — Design & Build Plan +> The as-shipped reference is **`SPACES.md`** — read that for how spaces are +> actually used; this document is the design rationale and build history. + Decisions confirmed by Robin, 2026-08-25. This is the **interim in-app sync protocol**, built on [AT Proto Spaces](https://atproto.com/blog/atproto-spaces-alpha) ([permissioned data proposal](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data)). @@ -57,7 +60,10 @@ member's own PDS. ### Spaces and documents -- A Ziran space = a `space.ziran.workspace` simplespace, `memberListPolicy`, +- A Ziran space = a `space.ziran.space` simplespace (renamed from + `space.ziran.workspace` on 2026-08-30, before any go-live had succeeded + under the old name — no migration path exists or is needed), + `memberListPolicy`, owned by its creator. Its human name lives in a `space.ziran.info` record (rkey `self`, written by the authority — simplespace has no name field). - **Go live** offers: add the doc to any known space where you're a member @@ -82,7 +88,7 @@ member's own PDS. ### Data model: ops + rare atile-style checkpoints -Collections in a workspace space: +Collections in a Ziran space: | Collection | rkey | Written by | Content | |---|---|---|---| @@ -263,7 +269,7 @@ documents survive Ziran-the-service entirely. `src/sync/spaces/client.test.ts` — 22 offline checks green (`deno task test:spaces`, also first in `test:sync`). - **D. Lexicons + record schemas — DONE (2026-08-25)** except DNS. - `lexicons/space/ziran/*.json`: the `space.ziran.workspace` + `space.ziran.inbox` + `lexicons/space/ziran/*.json`: the core + `space.ziran.inbox` space types and the info/doc/op/checkpoint/presence/invite records. Published as `com.atproto.lexicon.schema` records (rkey = NSID, bulletin's pattern; the spaces-alpha PDS validates the `space` def type natively) in diff --git a/lexicons/space/ziran/doc.json b/lexicons/space/ziran/doc.json index cdc4c02..c9f3753 100644 --- a/lexicons/space/ziran/doc.json +++ b/lexicons/space/ziran/doc.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "A live document in a workspace, written by the document's creator: the partial manifest linking a Ziran tile to its online identity. Document discovery in a space is the union of these records across member repos. The record's author is the sole authority for the document's role assignments; roles are enforced by tiles, not by the protocol.", + "description": "A live document in a space, written by the document's creator: the partial manifest linking a Ziran tile to its online identity. Document discovery in a space is the union of these records across member repos. The record's author is the sole authority for the document's role assignments; roles are enforced by tiles, not by the protocol.", "key": "tid", "record": { "type": "object", diff --git a/lexicons/space/ziran/inbox.json b/lexicons/space/ziran/inbox.json index a380587..5b06d58 100644 --- a/lexicons/space/ziran/inbox.json +++ b/lexicons/space/ziran/inbox.json @@ -6,7 +6,7 @@ "type": "space", "key": "literal:self", "name": "Ziran Invitation Inbox", - "description": "A public-policy space through which anyone can deliver a Ziran workspace invitation to this account. Invites are written into the sender's own repo; the recipient polls the space to discover them.", + "description": "A public-policy space through which anyone can deliver a Ziran space invitation to this account. Invites are written into the sender's own repo; the recipient polls the space to discover them.", "collections": [ "space.ziran.invite" ] diff --git a/lexicons/space/ziran/info.json b/lexicons/space/ziran/info.json index 9ff3944..401c7f8 100644 --- a/lexicons/space/ziran/info.json +++ b/lexicons/space/ziran/info.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "Workspace metadata, written by the space authority (rkey self). Holds what simplespace itself has no field for.", + "description": "Space metadata, written by the space authority (rkey self). Holds what simplespace itself has no field for.", "key": "literal:self", "record": { "type": "object", @@ -12,7 +12,7 @@ "properties": { "name": { "type": "string", - "description": "Human-readable workspace name.", + "description": "Human-readable space name.", "maxGraphemes": 200, "maxLength": 2000 }, diff --git a/lexicons/space/ziran/invite.json b/lexicons/space/ziran/invite.json index 376c2bf..3fe5f74 100644 --- a/lexicons/space/ziran/invite.json +++ b/lexicons/space/ziran/invite.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "An invitation to a Ziran workspace, written by the inviter into their own repo inside the invitee's public-policy inbox space. The inviter must already have added the invitee as a member of the workspace; this record only makes that membership discoverable. Dismissal is app-local: the record belongs to the inviter.", + "description": "An invitation to a Ziran space, written by the inviter into their own repo inside the invitee's public-policy inbox space. The inviter must already have added the invitee as a member of the space; this record only makes that membership discoverable. Dismissal is app-local: the record belongs to the inviter.", "key": "tid", "record": { "type": "object", @@ -13,11 +13,11 @@ "space": { "type": "string", "format": "at-uri", - "description": "The workspace space the invitee has been added to." + "description": "The space the invitee has been added to." }, "name": { "type": "string", - "description": "Human-readable workspace name, so the invite renders without a credential mint.", + "description": "Human-readable space name, so the invite renders without a credential mint.", "maxGraphemes": 200, "maxLength": 2000 }, diff --git a/lexicons/space/ziran/permissions.json b/lexicons/space/ziran/permissions.json new file mode 100644 index 0000000..db6dd74 --- /dev/null +++ b/lexicons/space/ziran/permissions.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "space.ziran.permissions", + "defs": { + "main": { + "type": "permission-set", + "title": "Ziran", + "detail": "Sync your live documents and receive invitations", + "permissions": [ + { + "type": "permission", + "resource": "space", + "spaceType": "space.ziran.space", + "authority": "*", + "skey": "*", + "collection": [ + "space.ziran.info", + "space.ziran.doc", + "space.ziran.op", + "space.ziran.checkpoint", + "space.ziran.presence" + ], + "action": ["read", "create", "update", "delete"], + "manage": ["create", "update", "delete"] + }, + { + "type": "permission", + "resource": "space", + "spaceType": "space.ziran.inbox", + "authority": "*", + "skey": "self", + "collection": ["space.ziran.invite"], + "action": ["read", "create", "update", "delete"], + "manage": ["create", "update", "delete"] + }, + { + "type": "permission", + "resource": "blob", + "accept": ["*/*"] + } + ] + } + } +} diff --git a/lexicons/space/ziran/workspace.json b/lexicons/space/ziran/space.json similarity index 50% rename from lexicons/space/ziran/workspace.json rename to lexicons/space/ziran/space.json index 5440a2f..c984a50 100644 --- a/lexicons/space/ziran/workspace.json +++ b/lexicons/space/ziran/space.json @@ -1,12 +1,12 @@ { "lexicon": 1, - "id": "space.ziran.workspace", + "id": "space.ziran.space", "defs": { "main": { "type": "space", "key": "tid", - "name": "Ziran Workspace", - "description": "A shared workspace of live Ziran documents. Membership is space-wide: every member sees every document in the workspace, and each member's contributions are written to their own repo.", + "name": "Ziran Space", + "description": "A shared space of live Ziran documents. Membership is space-wide: every member sees every document in the space, and each member's contributions are written to their own repo.", "collections": [ "space.ziran.info", "space.ziran.doc", diff --git a/main.ts b/main.ts index 1e39fd3..7750993 100644 --- a/main.ts +++ b/main.ts @@ -123,7 +123,7 @@ function makeSpacesProvider(): SpacesProvider { displayName: at.displayName ?? undefined, hasAvatar: Boolean(at.avatar), } - : { signedIn: false }, + : { signedIn: false, staleScope: at.staleScope }, devIdentity: () => envAuth, handle: () => envHandle ?? at.handle ?? undefined, }); diff --git a/src/components/zn-base-window.ts b/src/components/zn-base-window.ts index acc8d7a..d542b2f 100644 --- a/src/components/zn-base-window.ts +++ b/src/components/zn-base-window.ts @@ -6,6 +6,9 @@ import type { ModelEntry, RecentEntry } from '../types.ts'; import { icons, zmark } from './icons.ts'; +/** How many documents "Most recent" shows before it stops being useful. */ +const RECENT_LIMIT = 20; + /** Ziran's home base: command on top, history on the left, models on the right. Everything here is a route into an open document. */ export class ZnBaseWindow extends LitElement { @@ -671,7 +674,11 @@ export class ZnBaseWindow extends LitElement { override render() { const q = this.query.trim().toLowerCase(); - const recents = desk.recents.filter((d) => !q || d.name.toLowerCase().includes(q)); + const matching = desk.recents.filter((d) => !q || d.name.toLowerCase().includes(q)); + // Most recent shows a window on history, not the whole archive: past a + // screenful the list stops being a memory aid. Searching looks through + // everything, and By location groups the full set. + const recents = this.docSort === 'recent' && !q ? matching.slice(0, RECENT_LIMIT) : matching; const models = desk.models.filter( (m) => !q || m.name.toLowerCase().includes(q) || (m.description ?? '').toLowerCase().includes(q), ); @@ -884,6 +891,12 @@ export class ZnBaseWindow extends LitElement { }} > + ${desk.sync?.at?.staleScope + ? html`

+ Your last sign-in didn’t include permission for live documents. + Signing in again grants it. +

` + : nothing}

@@ -950,8 +963,13 @@ export class ZnBaseWindow extends LitElement { if (d.space) inSpace.set(id, d.space); } const q = this.query.trim().toLowerCase(); - const local = recents.filter((r) => !inSpace.has(r.id)); - const spaces = sync?.spaces ?? []; + // Within a place, documents are a library, not a history: alphabetical. + const byName = (list: T[]) => + [...list].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + const local = byName(recents.filter((r) => !inSpace.has(r.id))); + const spaces = [...(sync?.spaces ?? [])].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ); return html`

Local Device · ${local.length} @@ -960,8 +978,8 @@ export class ZnBaseWindow extends LitElement { : html`

Nothing lives only on this machine.

`}
${spaces.map((s) => { - const here = recents.filter((r) => inSpace.get(r.id) === s.ref); - const away = s.available.filter((a) => !q || a.name.toLowerCase().includes(q)); + const here = byName(recents.filter((r) => inSpace.get(r.id) === s.ref)); + const away = byName(s.available.filter((a) => !q || a.name.toLowerCase().includes(q))); return html`
${s.name || 'unnamed space'} · ${here.length + away.length} diff --git a/src/components/zn-connections.ts b/src/components/zn-connections.ts index d84d660..ec7b059 100644 --- a/src/components/zn-connections.ts +++ b/src/components/zn-connections.ts @@ -119,6 +119,11 @@ export class ZnConnections extends LitElement { box-shadow: var(--shadow-pop); color: var(--ink); font-size: var(--text-sm); + /* The window's title bar sets user-select: none and this popover + lives inside it. Everything here — paths, fingerprints, links, + errors — exists to be read and copied. */ + user-select: text; + -webkit-user-select: text; } .panel::backdrop { background: transparent; @@ -163,6 +168,34 @@ export class ZnConnections extends LitElement { text-overflow: ellipsis; white-space: nowrap; } + /* Diagnostics are for reading and reporting: full text, wrapped, + selectable — a truncated error nobody can copy is not an error + message, it is a rumour. */ + .mono.wrap { + white-space: pre-wrap; + overflow-wrap: anywhere; + overflow: visible; + text-overflow: clip; + user-select: text; + -webkit-user-select: text; + margin: 0 0 var(--sp-2); + max-height: 9rem; + overflow-y: auto; + } + details.detail summary { + cursor: pointer; + font-size: var(--text-xs); + color: var(--ink-muted); + user-select: none; + padding: 2px 0; + } + details.detail summary:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; + } + details.detail[open] summary { + margin-bottom: var(--sp-2); + } .ok { color: var(--ink-muted); display: inline-flex; @@ -332,9 +365,13 @@ export class ZnConnections extends LitElement { // noise here. (ZIRAN_DID dev identities are exempt — developer mode.) const canHost = sync?.at?.signedIn === true || sync?.devIdentity === true; if (!canHost) { - return this.renderSignInPrompt(s - ? 'This document is shared, but live sessions need your AT identity — sign in to reconnect it.' - : 'Going live needs your AT identity, so people can find you.'); + return this.renderSignInPrompt( + sync?.at?.staleScope + ? 'Your last sign-in didn’t include permission for live documents. Signing in again grants it — nothing on this machine is affected.' + : s + ? 'This document is shared, but live sessions need your AT identity — sign in to reconnect it.' + : 'Going live needs your AT identity, so people can find you.', + ); } if (!sync?.relayOk) return this.renderRelayDown(Boolean(s)); if (!s) { @@ -508,18 +545,46 @@ export class ZnConnections extends LitElement { private renderRelayDown(shared: boolean) { const sync = desk.sync; const spaces = sync?.provider === 'spaces'; - const relay = sync?.relay?.replace(/^wss?:\/\//, '').replace(/\/ws$/, ''); - const detail = [relay, sync?.relayError].filter(Boolean).join(' — '); - const backendName = spaces ? 'your PDS' : 'the relay'; + const server = sync?.relay?.replace(/^wss?:\/\//, '').replace(/\/ws$/, ''); + const kind = sync?.errorKind; + const raw = sync?.relayError; + // Say what actually happened. A server that has never heard of Spaces + // and a sign-in missing its grant are both answers, not outages, and + // retrying forever fixes neither. + const explanation = !spaces + ? shared + ? 'Live sharing is paused: Ziran can’t reach the relay. Your edits stay on this machine and flow to the others when it reconnects.' + : 'The relay is unreachable right now — Ziran keeps trying and will reconnect on its own. This document keeps working on this machine either way.' + : kind === 'unsupported' + ? `${server ?? 'Your server'} doesn’t support live documents: they need a server running the AT Proto Spaces protocol, which most don’t yet.` + : kind === 'permission' + ? `${server ?? 'Your server'} refused the request: this sign-in doesn’t carry permission for live documents. Signing out and back in grants it.` + : `Ziran couldn’t reach ${server ?? 'your server'} just now. Your edits stay on this machine either way, and Ziran keeps trying.`; return html` -

- ${shared - ? `Live sharing is paused: Ziran can’t reach ${backendName}. Your edits stay on this machine and flow to the others when it reconnects.` - : `${spaces ? 'Your PDS is' : 'The relay is'} unreachable right now — Ziran keeps trying and will reconnect on its own. This document keeps working on this machine either way.`} -

- ${detail ? html`

${detail}

` : nothing} +

${explanation}

+ ${kind === 'unsupported' + ? html`

+ You can create an account + that supports it, then sign in with that handle. +

` + : nothing} + ${raw + ? html` +
+ What the server said +

${raw}

+
+ + +
+
+ ` + : nothing}
+ ${kind === 'permission' + ? html`` + : nothing}
`; diff --git a/src/server/atsession.ts b/src/server/atsession.ts index b6ae5b4..e0169ae 100644 --- a/src/server/atsession.ts +++ b/src/server/atsession.ts @@ -7,6 +7,7 @@ import { Agent } from '@atproto/api'; import { + buildAtprotoLoopbackClientMetadata, NodeOAuthClient, type NodeSavedSession, type NodeSavedState, @@ -17,7 +18,22 @@ import { syncDir } from './paths.ts'; import { RELAY_LXM } from '../../relay/auth.ts'; import { resolveIdentity } from '../sync/atproto.ts'; -const SCOPE = 'atproto transition:generic'; +// The space endpoints are permissioned: `transition:generic` does not reach +// them at all. See scope.ts for why the request is part permission-set, +// part literal scope. +import { ZIRAN_SCOPE } from '../sync/spaces/scope.ts'; +import { makeSessionFetch } from '../sync/spaces/session-fetch.ts'; + +const SCOPE = ZIRAN_SCOPE; +/** Bumped when the SCOPE *or the permission set it names* changes: the + granted permissions are materialized into the token at consent time, so + editing the published set does nothing for sessions already issued. A + stored session under an older grant is retired rather than left to fail + on every space call. (v4: `space:*?authority=*&action=read_self` is now + requested literally — an include: cannot grant a wildcard space type. + v5: the core space type renamed space.ziran.workspace → space.ziran.space; + tokens consented under v4 only carry grants for the retired type.) */ +const SCOPE_VERSION = 5; const KP_COLLECTION = 'space.ziran.keypackage'; /** One-file JSON store for the OAuth client's state + session maps. */ @@ -74,6 +90,10 @@ export class AtSession { displayName: string | null = null; /** Cached avatar bytes, served same-origin from /api/at/avatar. */ avatar: { bytes: Uint8Array; type: string } | null = null; + /** A stored sign-in was dropped because it predated the current + permissions; the UI says so rather than looking spontaneously logged + out. Cleared by the next successful sign-in. */ + staleScope = false; #client?: NodeOAuthClient; #session?: OAuthSession; @@ -85,36 +105,36 @@ export class AtSession { async init(appPort: number): Promise { this.#currentFile = join(syncDir(), 'at-current.json'); const redirectUri = `http://127.0.0.1:${appPort}/oauth/callback`; - // Loopback development client: no hosted metadata document needed; the - // authorization server derives the metadata from the client_id itself. - const clientId = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${ - encodeURIComponent(SCOPE) - }`; this.#client = new NodeOAuthClient({ // Deno: the default AtprotoHandleResolverNode eagerly builds a // Node-undici SSRF wrapper that doesn't exist here. Native fetch + // the public HTTP resolver sidestep the whole node-only path. fetch: globalThis.fetch, handleResolver: 'https://public.api.bsky.app', - clientMetadata: { - client_id: clientId, - client_name: 'Ziran', + // Loopback client: no hosted metadata document needed — the + // authorization server derives everything from the client_id, which + // carries the redirect URI and the scope. + clientMetadata: buildAtprotoLoopbackClientMetadata({ redirect_uris: [redirectUri], scope: SCOPE, - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - application_type: 'native', - token_endpoint_auth_method: 'none', - dpop_bound_access_tokens: true, - }, + }), stateStore: new FileStore('at-oauth-state.json'), sessionStore: new FileStore('at-oauth-sessions.json'), }); - // Restore the previous sign-in, if any. + // Restore the previous sign-in, if any. A session granted under an older + // scope cannot reach the space endpoints — retire it so the person is + // asked to sign in again instead of hitting refusals forever. try { - const { did } = JSON.parse(await Deno.readTextFile(this.#currentFile)); - if (typeof did === 'string') await this.#activate(did); + const saved = JSON.parse(await Deno.readTextFile(this.#currentFile)); + if (typeof saved.did !== 'string') return; + if ((saved.scopeVersion ?? 1) !== SCOPE_VERSION) { + console.log('sign-in: the stored session predates the current permissions — signing out'); + this.staleScope = true; + await Deno.remove(this.#currentFile).catch(() => {}); + return; + } + await this.#activate(saved.did); } catch { // signed out } @@ -190,7 +210,11 @@ export class AtSession { /** OAuth redirect landing: exchange the code, persist, activate. */ async callback(params: URLSearchParams): Promise<{ did: string; handle: string | null }> { const { session } = await this.#client!.callback(params); - await Deno.writeTextFile(this.#currentFile, JSON.stringify({ did: session.did })); + await Deno.writeTextFile( + this.#currentFile, + JSON.stringify({ did: session.did, scopeVersion: SCOPE_VERSION }), + ); + this.staleScope = false; await this.#activate(session.did); await this.onChange?.(); return { did: this.did!, handle: this.handle }; @@ -213,22 +237,17 @@ export class AtSession { /** The signed-in account's PDS endpoint, when known. */ pds: string | null = null; - /** A fetch that signs own-PDS requests with the OAuth session (DPoP per - request) and passes everything else through untouched — the SpaceClient - injection point. Whether the session's scope satisfies the space - endpoints is the PDS's call; a 403 there surfaces in the sync summary. */ + /** A fetch that signs bare own-PDS requests with the OAuth session (DPoP + per request) and passes everything else — including the credential + dance's self-authorized requests — through untouched. See + session-fetch.ts for why the distinction is load-bearing. Whether the + session's scope satisfies the space endpoints is the PDS's call; a 403 + there surfaces in the sync summary. */ spacesFetch(): typeof fetch | null { const session = this.#session; const pds = this.pds; if (!session || !pds) return null; - const base = pds.replace(/\/+$/, ''); - return ((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input instanceof Request ? input.url : input); - if (url.startsWith(base)) { - return session.fetchHandler(url.slice(base.length), init); - } - return fetch(input as URL, init); - }) as typeof fetch; + return makeSessionFetch(pds, (pathname, init) => session.fetchHandler(pathname, init)); } /** Mint a relay token at the user's PDS (service-auth, ~60s, DID-signed). */ diff --git a/src/sync/provider.ts b/src/sync/provider.ts index 77a72f0..5fd5cc9 100644 --- a/src/sync/provider.ts +++ b/src/sync/provider.ts @@ -51,13 +51,28 @@ export interface ProviderSummary { provider: 'relay' | 'spaces'; identity: { did: string; handle: string }; devIdentity: boolean; - at: { signedIn: boolean; handle?: string; did?: string; displayName?: string; hasAvatar?: boolean }; + at: { + signedIn: boolean; + handle?: string; + did?: string; + displayName?: string; + hasAvatar?: boolean; + /** A stored sign-in was retired because it predated the permissions + live documents need; the UI asks for a fresh sign-in by name. */ + staleScope?: boolean; + }; /** The collaboration backend is reachable (relay socket up / PDS session live). Field names kept from the relay era; the UI treats them as "backend ok / backend address / why not". */ relayOk: boolean; relay: string; + /** The raw failure, verbatim — the UI shows it in full and lets it be + copied; a truncated error is an error nobody can report. */ relayError?: string; + /** What kind of "no" this is, so the chrome can offer the right way out + instead of a generic retry: 'unsupported' = the server doesn't speak + Spaces at all, 'permission' = the sign-in lacks the grant. */ + errorKind?: ProviderErrorKind; invitations: Array>; docs: Record; /** Spaces only: the identity's known spaces, for the go-live picker and @@ -65,6 +80,8 @@ export interface ProviderSummary { spaces?: SpaceSummary[]; } +export type ProviderErrorKind = 'unsupported' | 'permission' | 'other'; + export interface ShareOptions { /** Spaces: an existing space ref to add the doc to. */ space?: string; diff --git a/src/sync/richtext-editing.test.ts b/src/sync/richtext-editing.test.ts new file mode 100644 index 0000000..5738f61 --- /dev/null +++ b/src/sync/richtext-editing.test.ts @@ -0,0 +1,122 @@ +// Editing behaviours of the richtext tile that have no business regressing: +// list splitting (the "can't make a second bullet" bug) and suggestion marks +// that can actually be left behind. Commands and schema run headless; only +// the EditorView needs a browser, and none of this does. +// Run: deno run -A src/sync/richtext-editing.test.ts + +import { EditorState, TextSelection } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; +import { schema as basic } from 'prosemirror-schema-basic'; +import { addListNodes, splitListItem, sinkListItem, wrapInList } from 'prosemirror-schema-list'; + +let failures = 0; +const check = (label: string, ok: boolean, detail = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); + if (!ok) failures++; +}; + +// Same schema as the tile (tiles/richtext/index.html). +const schema = new Schema({ + nodes: addListNodes(basic.spec.nodes, 'paragraph block*', 'block'), + marks: basic.spec.marks.append({ + comment: { attrs: { id: {} }, inclusive: false, toDOM: (m) => ['span', { 'data-cmt': m.attrs.id }, 0] }, + sug_ins: { + attrs: { id: {}, who: { default: '' } }, + inclusive: false, // the fix under test + toDOM: (m) => ['ins', { 'data-sug': m.attrs.id }, 0], + }, + sug_del: { + attrs: { id: {}, who: { default: '' } }, + inclusive: false, + toDOM: (m) => ['del', { 'data-sug': m.attrs.id }, 0], + }, + }), +}); + +const stateFrom = (doc: unknown) => EditorState.create({ doc: schema.nodeFromJSON(doc as never) }); +const run = (state: EditorState, cmd: (s: EditorState, d: (tr: unknown) => void) => boolean) => { + let next = state; + const ok = cmd(state, (tr) => { + next = state.apply(tr as never); + }); + return { ok, state: next }; +}; +const listItems = (state: EditorState) => { + let n = 0; + state.doc.descendants((node) => { + if (node.type === schema.nodes.list_item) n++; + }); + return n; +}; + +/* ——— lists: Enter must split the item ——— */ +{ + const doc = { + type: 'doc', + content: [{ + type: 'bullet_list', + content: [{ type: 'list_item', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }] }], + }], + }; + let state = stateFrom(doc); + check('fixture starts with one bullet', listItems(state) === 1); + // Caret at the end of the bullet's text, then Enter. + state = state.apply(state.tr.setSelection(TextSelection.atEnd(state.doc))); + const split = run(state, splitListItem(schema.nodes.list_item)); + check('Enter splits the list item into a second bullet', split.ok && listItems(split.state) === 2, `items: ${listItems(split.state)}`); + + // Typing into the fresh bullet keeps it a separate item. + const typed = split.state.apply(split.state.tr.insertText('two')); + check( + 'the second bullet accepts text', + typed.doc.textContent === 'onetwo' && listItems(typed) === 2, + `${JSON.stringify(typed.doc.textContent)}, ${listItems(typed)} items`, + ); + + // Tab nests the second bullet under the first. + const sunk = run(typed, sinkListItem(schema.nodes.list_item)); + check('Tab nests a bullet', sunk.ok); + + // And wrapInList still makes a list out of a bare paragraph. + const bare = stateFrom({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'plain' }] }] }); + const wrapped = run(bare, wrapInList(schema.nodes.bullet_list)); + check('the bullet-list button wraps a paragraph', wrapped.ok && listItems(wrapped.state) === 1); +} + +/* ——— suggestions: leaving the mode actually leaves it ——— */ +{ + const sug = schema.marks.sug_ins.create({ id: 's1', who: 'robin' }); + let state = stateFrom({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'kept' }] }] }); + // Suggest "added" at the end of the paragraph. + state = state.apply(state.tr.insertText('added', 5).addMark(5, 10, sug)); + const marked = (from: number, to: number) => state.doc.rangeHasMark(from, to, schema.marks.sug_ins); + check('suggested text carries the mark', marked(5, 10)); + + // Typing immediately after it, with no stored marks, must NOT inherit — + // this is what an inclusive mark got wrong. + let after = state.apply(state.tr.setSelection(TextSelection.create(state.doc, 10))); + after = after.apply(after.tr.insertText('plain')); + check( + 'typing after a suggestion is plain text', + !after.doc.rangeHasMark(10, 15, schema.marks.sug_ins), + after.doc.toJSON().content[0].content.map((n: { text: string; marks?: unknown[] }) => `${n.text}:${(n.marks ?? []).length}`).join(' '), + ); + + // Accepting: the mark comes off and the caret stops carrying it. + let accepted = state.apply(state.tr.removeMark(5, 10, schema.marks.sug_ins)); + check('accepting a suggestion clears its styling', !accepted.doc.rangeHasMark(5, 10, schema.marks.sug_ins)); + accepted = accepted.apply(accepted.tr.setSelection(TextSelection.create(accepted.doc, 10)).setStoredMarks([])); + accepted = accepted.apply(accepted.tr.insertText('more')); + check( + 'typing after accepting stays plain', + !accepted.doc.rangeHasMark(10, 14, schema.marks.sug_ins), + accepted.doc.textContent, + ); + + // Rejecting an insertion removes the text entirely. + const rejected = state.apply(state.tr.delete(5, 10)); + check('rejecting an insertion removes the text', rejected.doc.textContent === 'kept'); +} + +console.log(failures ? `\n${failures} FAILED` : '\nall green'); +Deno.exit(failures ? 1 : 0); diff --git a/src/sync/spaces/client.test.ts b/src/sync/spaces/client.test.ts index f0a108b..8ae4e35 100644 --- a/src/sync/spaces/client.test.ts +++ b/src/sync/spaces/client.test.ts @@ -39,10 +39,10 @@ const carol = await client('carol'); check('password sessions carry the DID', alice.auth.did.startsWith('did:plc:fake')); // Space + records. -const space = await alice.createSpace('space.ziran.workspace', { policy: 'memberList' }); +const space = await alice.createSpace('space.ziran.space', { policy: 'memberList' }); check( 'createSpace returns the canonical space uri', - space === `at://${alice.auth.did}/space/space.ziran.workspace/${space.split('/').pop()}`, + space === `at://${alice.auth.did}/space/space.ziran.space/${space.split('/').pop()}`, space, ); await alice.putRecord(space, 'space.ziran.info', 'self', { $type: 'space.ziran.info', name: 'Fake space' }); @@ -137,7 +137,7 @@ check('deletion shows up in the oplog', postDelete.ops.some((o) => o.action === // Credential expiry: reads keep working through a transparent re-mint. net.credentialTtlMs = 60; -const shortSpace = await alice.createSpace('space.ziran.workspace', { policy: 'memberList' }); +const shortSpace = await alice.createSpace('space.ziran.space', { policy: 'memberList' }); await alice.createRecord(shortSpace, 'space.ziran.doc', { $type: 'space.ziran.doc', name: 'short' }); await alice.addMember(shortSpace, bob.auth.did); await bob.listRepos(shortSpace); diff --git a/src/sync/spaces/fake.ts b/src/sync/spaces/fake.ts index 1f0274e..e2b7fa4 100644 --- a/src/sync/spaces/fake.ts +++ b/src/sync/spaces/fake.ts @@ -58,6 +58,9 @@ export class FakeSpacesNetwork { /** Credential lifetime; tests shrink it to exercise the re-mint path. */ credentialTtlMs = 15 * 60 * 1000; + /** Stand in for an ordinary PDS: every space/simplespace method answers + the way an atproto server answers a method it has never heard of. */ + spacesUnsupported = false; constructor() { this.#server = Deno.serve({ port: 0, hostname: '127.0.0.1', onListen: () => {} }, (req) => this.#handle(req)); @@ -123,6 +126,13 @@ export class FakeSpacesNetwork { const nsid = path.slice('/xrpc/'.length); const p = Object.fromEntries(url.searchParams); + if ( + this.spacesUnsupported && + (nsid.startsWith('com.atproto.space.') || nsid.startsWith('com.atproto.simplespace.')) + ) { + return err(501, 'MethodNotImplemented', `Method Not Implemented: ${nsid}`); + } + switch (nsid) { case 'com.atproto.server.createSession': { const body = await req.json(); diff --git a/src/sync/spaces/provider.test.ts b/src/sync/spaces/provider.test.ts index 855b7c3..5684698 100644 --- a/src/sync/spaces/provider.test.ts +++ b/src/sync/spaces/provider.test.ts @@ -115,7 +115,7 @@ const a = await fixture('alice', 'deviceA'); const tilePath = await makeTile(a.dir, 'Shared Doc'); const { id: aDocId } = await a.openPath(tilePath); const rec = await a.provider.share(aDocId, { newSpaceName: 'Test Space' }); -check('share created a workspace space', rec.space.includes('/space/space.ziran.workspace/'), rec.space); +check('share created a space of the core type', rec.space.includes('/space/space.ziran.space/'), rec.space); check('doc record uri stamped into the tile manifest', (await parseTile(tilePath)).manifest.at === rec.at); const inspector = new SpaceClient({ @@ -334,6 +334,30 @@ carolAgain.close(); const inboxless = await a2.provider.invite(aDocId, net.account('dave').did, 'editor'); check('inviting an inboxless identity reports pending + link', inboxless.invitation.pending === true); +/* ——— a server that doesn't speak Spaces is an answer, not an outage ——— */ + +net.spacesUnsupported = true; +const dave = await fixture('dave', 'dave'); +const daveSummary = dave.provider.summary(); +check( + 'an unsupported server is classified, not called unreachable', + daveSummary.errorKind === 'unsupported', + `${daveSummary.errorKind}: ${daveSummary.relayError}`, +); +check( + 'the raw server message is kept verbatim for the person to read', + (daveSummary.relayError ?? '').includes('MethodNotImplemented'), + daveSummary.relayError ?? '(none)', +); +// The session survives a refusal: recovery is a retry away, not a restart. +net.spacesUnsupported = false; +dave.provider.retryNow(); +await sleep(300); +await dave.provider.pollNow(true); +const recovered = dave.provider.summary(); +check('recovery needs no restart', recovered.errorKind === undefined && recovered.relayOk === true, JSON.stringify(recovered.relayError)); +dave.provider.close(); + b.provider.close(); bobFx.provider.close(); a2.provider.close(); diff --git a/src/sync/spaces/provider.ts b/src/sync/spaces/provider.ts index a44b7d5..3c251a2 100644 --- a/src/sync/spaces/provider.ts +++ b/src/sync/spaces/provider.ts @@ -16,6 +16,7 @@ import type { AttachState, BufferedFrame, CollabProvider, + ProviderErrorKind, ProviderSummary, ShareOptions, SpaceSummary, @@ -28,6 +29,7 @@ import { type DocRec, loadRegistry, saveRegistry, type SpaceRec, type SpacesRegi import { type CheckpointRecord, instantiateFromCheckpoint, publishCheckpoint } from './checkpoint.ts'; import { setTileAt } from '../../server/tilefile.ts'; import { resolveIdentity } from '../atproto.ts'; +import { ZIRAN_INBOX_TYPE, ZIRAN_SPACE_TYPE } from './scope.ts'; /** Runtime-enforced defaults when a tile's manifest declares no sync roles. */ const DEFAULT_MANIFEST: SyncManifest = { @@ -59,7 +61,31 @@ function tid(): string { /** Every signed-in identity gets a public-policy inbox space at a ref anyone can construct from the DID alone — that is how invites travel, since addMember does not notify the member. */ -const inboxRef = (did: string) => `at://${did}/space/space.ziran.inbox/self`; +const inboxRef = (did: string) => `at://${did}/space/${ZIRAN_INBOX_TYPE}/self`; + +/** Classify a space-endpoint failure into something a person can act on. + The two that matter are both invisible from the outside: a server that + has never heard of Spaces (most PDSes today), and a session whose grant + predates the permission set. Both used to read as "unreachable", which + is exactly the wrong word — the server answered, it just said no. */ +function classifySpaceError(err: unknown): { kind: ProviderErrorKind; message: string } { + const xrpc = err as { status?: number; error?: string; message?: string }; + const name = xrpc.error ?? ''; + const raw = (err as Error)?.message ?? String(err); + // Unsupported first: an unknown XRPC method answers 404/501 with a + // MethodNotImplemented-ish name, and that is not an auth problem. + if ( + xrpc.status === 404 || xrpc.status === 501 || + /notimplemented|methodnotimplemented|unknownmethod|invalidrequest.*method/i.test(name) || + /methodnotimplemented|unknown xrpc/i.test(raw) + ) { + return { kind: 'unsupported', message: raw }; + } + if (xrpc.status === 401 || xrpc.status === 403 || /scope|permission|forbidden|auth|token/i.test(name)) { + return { kind: 'permission', message: raw }; + } + return { kind: 'other', message: raw }; +} const HOT_POLL_MS = 2_000; const WARM_POLL_MS = 30_000; @@ -130,6 +156,7 @@ export class SpacesProvider implements CollabProvider { #live = new Map(); // by localId #client: SpaceClient | null = null; #error: string | undefined; + #errorKind: ProviderErrorKind | undefined; /** Per space: discovered docs (adopted or not) and last seen writer set. */ #catalog = new Map>(); #writers = new Map(); @@ -152,6 +179,19 @@ export class SpacesProvider implements CollabProvider { async init(): Promise { this.#registry = await loadRegistry(this.#opts.registryFile); + // The core type was renamed space.ziran.workspace → space.ziran.space + // before any go-live succeeded under the old name (the scope bug blocked + // them all), so legacy refs are dead by construction. A registry that + // kept them would poll refusals forever; drop them instead. + const legacy = (ref: string) => ref.includes('/space/space.ziran.workspace/'); + const stale = this.#registry.spaces.filter((s) => legacy(s.ref)).length + + this.#registry.docs.filter((d) => legacy(d.space)).length; + if (stale > 0) { + console.warn(`spaces: dropping ${stale} registry entr${stale === 1 ? 'y' : 'ies'} under the retired space.ziran.workspace type`); + this.#registry.spaces = this.#registry.spaces.filter((s) => !legacy(s.ref)); + this.#registry.docs = this.#registry.docs.filter((d) => !legacy(d.space)); + await this.#persist(); + } for (const rec of this.#registry.docs) this.#reviveDoc(rec); await this.#connect(); if (this.#opts.autoPoll !== false) { @@ -193,22 +233,44 @@ export class SpacesProvider implements CollabProvider { } async #connect(): Promise { + let auth: SessionAuth | null = null; try { - const auth = await this.#opts.auth(); - if (!auth) { - this.#client = null; - this.#error = undefined; - this.#opts.host.broadcast(); - return; - } - if (this.#client?.auth.did !== auth.did) this.#inboxReady = false; - this.#client = this.#opts.clientFor?.(auth) ?? new SpaceClient({ auth }); + auth = await this.#opts.auth(); + } catch (err) { + // The session itself could not be established (bad credentials, PDS + // down). There is nothing to talk to: park with the reason. + this.#client = null; + this.#error = (err as Error).message; + this.#errorKind = 'other'; + console.warn('spaces: could not establish a session:', this.#error); + this.#opts.host.broadcast(); + return; + } + if (!auth) { + this.#client = null; this.#error = undefined; + this.#errorKind = undefined; + this.#opts.host.broadcast(); + return; + } + if (this.#client?.auth.did !== auth.did) this.#inboxReady = false; + this.#client = this.#opts.clientFor?.(auth) ?? new SpaceClient({ auth }); + this.#error = undefined; + this.#errorKind = undefined; + // Discovery and inbox setup are best-effort: a refusal here (a session + // whose grant does not cover the space endpoints, say) must NOT throw + // away a working session — that turned one refused call into "your PDS + // is unreachable" with a retry button that could never help. + try { await this.#refreshSpaces(); await this.#ensureInbox(); } catch (err) { - this.#client = null; - this.#error = (err as Error).message; + const { kind, message } = classifySpaceError(err); + this.#error = message; + this.#errorKind = kind; + // The full failure belongs in the log too: the panel is where you + // notice it, the terminal is where you can grep it. + console.warn(`spaces: ${auth.pds} refused a space call (${kind}):`, message); } this.#opts.host.broadcast(); } @@ -219,7 +281,7 @@ export class SpacesProvider implements CollabProvider { async #ensureInbox(): Promise { if (this.#inboxReady || !this.#client) return; try { - await this.#client.createSpace('space.ziran.inbox', { skey: 'self', policy: 'public' }); + await this.#client.createSpace(ZIRAN_INBOX_TYPE, { skey: 'self', policy: 'public' }); this.#inboxReady = true; } catch (err) { const name = (err as XrpcError).error ?? ''; @@ -240,7 +302,7 @@ export class SpacesProvider implements CollabProvider { const refs = await this.#client.listSpaces(); let changed = false; for (const ref of refs) { - if (!ref.includes('/space/space.ziran.workspace/')) continue; + if (!ref.includes(`/space/${ZIRAN_SPACE_TYPE}/`)) continue; if (!this.#registry.spaces.some((s) => s.ref === ref)) { this.#registry.spaces.push({ ref, @@ -273,8 +335,11 @@ export class SpacesProvider implements CollabProvider { try { await this.#pollSpace(space); this.#error = undefined; + this.#errorKind = undefined; } catch (err) { - this.#error = (err as Error).message; + const classified = classifySpaceError(err); + this.#error = classified.message; + this.#errorKind = classified.kind; } const hot = this.#registry.docs.some((d) => d.space === space.ref && this.#opts.host.isOpen(d.localId)); this.#nextPoll.set(space.ref, Date.now() + (hot ? HOT_POLL_MS : WARM_POLL_MS)); @@ -529,7 +594,7 @@ export class SpacesProvider implements CollabProvider { if (!spaceRef) { const name = opts.newSpaceName?.trim(); if (!name) throw new Error('pick a space, or name a new one'); - spaceRef = await client.createSpace('space.ziran.workspace', { policy: 'memberList' }); + spaceRef = await client.createSpace(ZIRAN_SPACE_TYPE, { policy: 'memberList' }); await client.putRecord(spaceRef, 'space.ziran.info', 'self', { $type: 'space.ziran.info', name, @@ -974,6 +1039,7 @@ export class SpacesProvider implements CollabProvider { retryNow(): void { this.#error = undefined; + this.#errorKind = undefined; this.#connect() .then(() => this.pollNow(true)) .catch(() => {}); @@ -987,7 +1053,7 @@ export class SpacesProvider implements CollabProvider { if (!this.#client) { throw new Error( this.#error - ? `your PDS is unreachable (${this.#error}) — collaboration is paused` + ? `Ziran can’t reach your PDS: ${this.#error}` : 'going live needs your AT identity — sign in first', ); } @@ -1033,6 +1099,7 @@ export class SpacesProvider implements CollabProvider { relayOk: Boolean(this.#client) && !this.#error, relay: this.#client ? new URL(this.#client.auth.pds).host : 'your PDS', relayError: this.#error, + errorKind: this.#errorKind, invitations: [...this.#invitations.values()].map((entry) => entry.inv), docs, spaces, diff --git a/src/sync/spaces/scope.ts b/src/sync/spaces/scope.ts new file mode 100644 index 0000000..9b5a598 --- /dev/null +++ b/src/sync/spaces/scope.ts @@ -0,0 +1,43 @@ +// Ziran's protocol names and the OAuth scope it asks for, in one place so +// the provider, the sign-in, and the test that proves the sign-in is +// sufficient can never drift apart. + +/** The core space type: one Ziran space = one `space.ziran.space`, + member-list policy, owned by its creator. */ +export const ZIRAN_SPACE_TYPE = 'space.ziran.space'; + +/** The invitation inbox space type: every signed-in identity keeps a + public-policy `space.ziran.inbox` at skey `self`, so anyone can deliver + an invite to a ref constructible from the DID alone. */ +export const ZIRAN_INBOX_TYPE = 'space.ziran.inbox'; + +// The OAuth scope. +// +// Two sources, because the protocol needs both: +// +// 1. `include:space.ziran.permissions` — the published permission set +// (lexicons/space/ziran/permissions.json), which carries everything +// scoped to our own space types. +// +// 2. Literal scopes for what a permission set is NOT allowed to grant. +// An `include:` may only grant permissions for NSIDs under its own +// authority (`isParentAuthorityOf` in @atproto/oauth-scopes), and it +// rejects the `*` wildcard outright — a published set must not be able +// to hand out access to everyone's space types. But `listSpaces` is an +// account-level question ("which spaces do I write into at all?") whose +// required scope is exactly `space:*?authority=*&action=read_self`, so +// it can only ever be requested directly, and consented to by name. +// `read_self` grants no document content: it lists space refs. + +export const SPACE_READ_SELF = 'space:*?authority=*&action=read_self'; + +/** Scopes requested literally, beyond the permission set. */ +export const LITERAL_SCOPES = [SPACE_READ_SELF] as const; + +export const ZIRAN_PERMISSION_SET = 'space.ziran.permissions'; + +export const ZIRAN_SCOPE = [ + 'atproto', + `include:${ZIRAN_PERMISSION_SET}`, + ...LITERAL_SCOPES, +].join(' '); diff --git a/src/sync/spaces/scopes.test.ts b/src/sync/spaces/scopes.test.ts new file mode 100644 index 0000000..16a5836 --- /dev/null +++ b/src/sync/spaces/scopes.test.ts @@ -0,0 +1,99 @@ +// Does our published permission set actually grant what Ziran does? +// +// This runs the PDS's OWN scope matcher (@atproto/oauth-scopes, alpha pin) +// over the permission entries in lexicons/space/ziran/permissions.json, so a +// missing grant fails here instead of at a person's first "go live". It +// exists because one did: listSpaces needs `read_self`, which is NOT in the +// default action set and matches only a type:*/authority:* grant — every +// per-type grant in the set sailed past it, and the app reported the +// refusal as "your PDS is unreachable". +// Run: deno run -A src/sync/spaces/scopes.test.ts + +import { IncludeScope, ScopesSet, SpacePermission } from '@atproto/oauth-scopes'; +import { fromFileUrl } from '@std/path'; +import { LITERAL_SCOPES, ZIRAN_INBOX_TYPE, ZIRAN_PERMISSION_SET, ZIRAN_SCOPE, ZIRAN_SPACE_TYPE } from './scope.ts'; + +let failures = 0; +const check = (label: string, ok: boolean, detail = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); + if (!ok) failures++; +}; + +const ME = 'did:plc:me00000000000000000000000'; +const THEM = 'did:plc:them0000000000000000000'; + +const doc = JSON.parse( + await Deno.readTextFile(fromFileUrl(new URL('../../../lexicons/space/ziran/permissions.json', import.meta.url))), +); +const permissionSet = { permissions: doc.defs.main.permissions }; + +// Expand exactly as the server does: IncludeScope applies the authority +// rule that silently drops anything not under our own NSID prefix — the +// rule that made an earlier `spaceType: "*"` entry vanish without a word. +const granted = [ + ...new IncludeScope(ZIRAN_PERMISSION_SET).toScopes(permissionSet as never), + ...LITERAL_SCOPES, +]; +// Resolve `self` authorities to the granting DID (the token-issuance step). +const scopes = new ScopesSet(); +for (const scope of granted) { + const parsed = SpacePermission.fromString(scope); + scopes.add(parsed ? parsed.withResolvedAuthority(ME).toString() : scope); +} +console.log(` ${scopes.size} space scope(s) granted (permission set + literal)`); + +// The scope the app requests must be the scope this test verifies. +check( + 'the requested scope names the permission set and every literal grant', + ZIRAN_SCOPE.includes(`include:${ZIRAN_PERMISSION_SET}`) && + LITERAL_SCOPES.every((s) => ZIRAN_SCOPE.includes(s)), + ZIRAN_SCOPE, +); +// The lesson, encoded: a permission set cannot grant a wildcard space type, +// so if one ever reappears there it fails here rather than in someone's face. +check( + 'a wildcard space type in a permission set is dropped, not granted', + new IncludeScope(ZIRAN_PERMISSION_SET).toScopes( + { permissions: [{ type: 'permission', resource: 'space', spaceType: '*', authority: '*', action: ['read_self'] }] } as never, + ).length === 0, +); + +// The very NSIDs the provider uses — a rename that misses the permission +// set (or vice versa) fails here. +const SPACE = ZIRAN_SPACE_TYPE; +const INBOX = ZIRAN_INBOX_TYPE; + +// Each case is a real call the provider makes, in the matcher's own terms. +const cases: Array<[string, Parameters[1] & Record]> = [ + // Discovery: the account-level "which spaces do I write into" (listSpaces). + ['listSpaces (read_self, account-level)', { type: '*', authority: '*', skey: '*', action: 'read_self' }], + // Going live: create a space, then write its records. + ['createSpace (manage create)', { type: SPACE, authority: ME, skey: '*', manage: 'create' }], + ['write space.ziran.info', { type: SPACE, authority: ME, skey: 'abc', action: 'create', collection: 'space.ziran.info' }], + ['write space.ziran.doc', { type: SPACE, authority: ME, skey: 'abc', action: 'create', collection: 'space.ziran.doc' }], + ['write ops', { type: SPACE, authority: ME, skey: 'abc', action: 'create', collection: 'space.ziran.op' }], + ['update a checkpoint', { type: SPACE, authority: ME, skey: 'abc', action: 'update', collection: 'space.ziran.checkpoint' }], + ['refresh presence', { type: SPACE, authority: ME, skey: 'abc', action: 'update', collection: 'space.ziran.presence' }], + // Someone else's space: membership means writing into THEIR space. + ['read a space owned by someone else', { type: SPACE, authority: THEM, skey: 'xyz', action: 'read' }], + ['write ops into someone else’s space', { type: SPACE, authority: THEM, skey: 'xyz', action: 'create', collection: 'space.ziran.op' }], + ['add a member (manage update)', { type: SPACE, authority: ME, skey: 'abc', manage: 'update' }], + // Invitations: my own inbox, and writing one into someone else's. + ['create my inbox', { type: INBOX, authority: ME, skey: 'self', manage: 'create' }], + ['read my inbox', { type: INBOX, authority: ME, skey: 'self', action: 'read' }], + ['deliver an invite into their inbox', { type: INBOX, authority: THEM, skey: 'self', action: 'create', collection: 'space.ziran.invite' }], +]; + +for (const [label, target] of cases) { + check(label, scopes.matches('space', target as never), JSON.stringify(target)); +} + +// And the guard that makes this test worth keeping: a grant we deliberately +// do NOT want must still be refused. +check( + 'we do not silently hold read on unrelated space types', + !scopes.matches('space', { type: 'com.example.other', authority: THEM, skey: '*', action: 'read' } as never), +); + +console.log(failures ? `\n${failures} FAILED` : '\nall green'); +Deno.exit(failures ? 1 : 0); diff --git a/src/sync/spaces/session-fetch.test.ts b/src/sync/spaces/session-fetch.test.ts new file mode 100644 index 0000000..f9315c7 --- /dev/null +++ b/src/sync/spaces/session-fetch.test.ts @@ -0,0 +1,102 @@ +// Does the OAuth-session fetch adapter keep the credential dance alive? +// +// This exists because the first real OAuth sign-in failed where every +// headless test had passed: password sessions use plain fetch end to end, +// but OAuthSession.fetchHandler OVERWRITES the Authorization header with the +// session token (oauth-session.js: `headers.set('Authorization', +// initialAuth)`), so routing by URL alone destroyed the delegation token on +// its way to getSpaceCredential — 401 "missing delegation token" in +// Robin's face. The handler below mimics the library's verified behavior +// faithfully; the fake PDS, like the real one, refuses a session token where +// a delegation token belongs. +// Run: deno run -A src/sync/spaces/session-fetch.test.ts + +import { passwordSession, SpaceClient, XrpcError } from './client.ts'; +import { FakeSpacesNetwork } from './fake.ts'; +import { makeSessionFetch } from './session-fetch.ts'; + +let failures = 0; +const check = (label: string, ok: boolean, detail = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); + if (!ok) failures++; +}; + +const net = new FakeSpacesNetwork(); +const resolver = { plcDirectory: net.url }; + +/** A faithful stand-in for OAuthSession.fetchHandler: resolves the pathname + against the PDS, then sets ITS OWN Authorization and DPoP proof — exactly + what the real one does, including to headers the caller already set. */ +function mimicFetchHandler(sessionAuth: string, log: { sessions: number; sawAuthorized: boolean }) { + return async (pathname: string, init?: RequestInit): Promise => { + log.sessions++; + if (new Headers(init?.headers).has('authorization')) log.sawAuthorized = true; + const headers = new Headers(init?.headers); + headers.set('Authorization', sessionAuth); // the library's line, verbatim behavior + headers.set('dpop', 'session-proof'); + return await fetch(new URL(pathname, net.url), { ...init, headers }); + }; +} + +async function oauthStyle(name: string) { + const acct = net.account(name); + const session = await passwordSession(net.url, acct.handle, acct.password); + const sessionAuth = (await session.headers()).authorization!; + const log = { sessions: 0, sawAuthorized: false }; + const handler = mimicFetchHandler(sessionAuth, log); + // OAuth-style SessionAuth: no headers of its own — the handler is the only + // way a bare request can authenticate, so a passing session call PROVES + // the adapter routed it through the handler. + const auth = { did: acct.did, pds: net.url, headers: () => Promise.resolve({}) }; + return { acct, auth, handler, log }; +} + +/* ——— the bug, encoded: route-by-URL-alone loses the delegation token ——— */ +{ + const { auth, handler } = await oauthStyle('clobbered'); + const routeAll = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url.startsWith(net.url)) return handler(url.slice(net.url.length), init); + return fetch(input as URL, init); + }) as typeof fetch; + const client = new SpaceClient({ auth, fetch: routeAll, resolver }); + const space = await client.createSpace('space.ziran.space', { policy: 'memberList' }); + let failed: XrpcError | undefined; + try { + await client.listRepos(space); + } catch (err) { + failed = err as XrpcError; + } + check( + 'route-by-URL-alone breaks the credential mint (the OAuth bug)', + failed?.status === 401 && /getSpaceCredential/.test(failed?.message ?? ''), + failed?.message ?? 'no error', + ); +} + +/* ——— the fix: self-authorized requests bypass the session handler ——— */ +const a = await oauthStyle('alice'); +const aClient = new SpaceClient({ auth: a.auth, fetch: makeSessionFetch(net.url, a.handler), resolver }); + +const space = await aClient.createSpace('space.ziran.space', { policy: 'memberList' }); +check('session call (createSpace) authenticates via the handler', space.includes('/space/space.ziran.space/'), space); +await aClient.putRecord(space, 'space.ziran.info', 'self', { $type: 'space.ziran.info', name: 'Adapter Test' }); +const repos = await aClient.listRepos(space); +check('credentialed read works — the delegation token survived', repos.some((r) => r.did === a.acct.did)); +const records = await aClient.listRecords(space, a.acct.did, 'space.ziran.info'); +check('read round-trips the record', records[0]?.value?.name === 'Adapter Test', JSON.stringify(records[0] ?? null)); + +// Cross-account on the SAME base — the exact hazard shape (authority PDS == +// own PDS): bob reads alice's space through his own session adapter. +const b = await oauthStyle('bob'); +await aClient.addMember(space, b.acct.did); +const bClient = new SpaceClient({ auth: b.auth, fetch: makeSessionFetch(net.url, b.handler), resolver }); +const bRecords = await bClient.listRecords(space, a.acct.did, 'space.ziran.info'); +check('a member on the same base mints and reads too', bRecords[0]?.value?.name === 'Adapter Test'); + +check('the handler carried real session traffic', a.log.sessions > 0, `${a.log.sessions} calls`); +check('…and never saw a self-authorized request', !a.log.sawAuthorized && !b.log.sawAuthorized); + +await net.close(); +console.log(failures ? `\n${failures} FAILED` : '\nall green'); +Deno.exit(failures ? 1 : 0); diff --git a/src/sync/spaces/session-fetch.ts b/src/sync/spaces/session-fetch.ts new file mode 100644 index 0000000..bc43574 --- /dev/null +++ b/src/sync/spaces/session-fetch.ts @@ -0,0 +1,41 @@ +// The OAuth-session fetch adapter for SpaceClient — with the one rule that +// makes the credential dance survive it. +// +// OAuthSession.fetchHandler UNCONDITIONALLY overwrites the Authorization +// header with the session's token and attaches its own DPoP proof (verified +// in @atproto/oauth-client oauth-session.js: `headers.set('Authorization', +// initialAuth)` before dpopFetch). That is right for session-authenticated +// calls (listSpaces, createSpace, writes, getDelegationToken) — under OAuth +// they carry no auth of their own and the handler supplies it. +// +// But the space credential dance authorizes ITSELF: getSpaceCredential sends +// the delegation token, credentialed reads send `DPoP ` plus a +// proof bound to the request. And the authority PDS is often the user's own +// PDS (every space they created), so routing by URL alone shoves those +// through the handler, which replaces the delegation token with the session +// token — the server answers 401 MissingJwt ("missing delegation token") and +// the app reads as unable to go live. Password sessions never hit this +// (plain fetch end to end), which is exactly why every headless test passed +// while the first real OAuth sign-in failed. +// +// The rule: a request that already carries an authorization header is +// self-authorized — hand it to the raw fetch untouched, even on the own-PDS +// base. Only bare requests to the own PDS go through the session handler. + +export type SessionFetchHandler = (pathname: string, init?: RequestInit) => Promise; + +export function makeSessionFetch( + pds: string, + handler: SessionFetchHandler, + rawFetch: typeof fetch = fetch, +): typeof fetch { + const base = pds.replace(/\/+$/, ''); + return ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + if (url.startsWith(base) && !headers.has('authorization')) { + return handler(url.slice(base.length), init); + } + return rawFetch(input as URL, init); + }) as typeof fetch; +} diff --git a/src/types.ts b/src/types.ts index c3cd347..de34432 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,12 +72,25 @@ export interface SyncState { identity: { did: string; handle: string }; /** True under an explicit ZIRAN_DID or env Spaces credentials (dev mode). */ devIdentity?: boolean; - at: { signedIn: boolean; handle?: string; did?: string; displayName?: string; hasAvatar?: boolean }; + at: { + signedIn: boolean; + handle?: string; + did?: string; + displayName?: string; + hasAvatar?: boolean; + /** The stored sign-in was retired: it predated the permissions live + documents need, so a fresh sign-in is required. */ + staleScope?: boolean; + }; /** The collaboration backend is reachable (field names kept from the relay era: read as "backend ok / backend address / why not"). */ relayOk: boolean; relay?: string; + /** The raw failure text, verbatim (shown in full, selectable, copyable). */ relayError?: string; + /** 'unsupported' = the server doesn't speak Spaces; 'permission' = the + sign-in lacks the grant; 'other' = anything else. */ + errorKind?: 'unsupported' | 'permission' | 'other'; invitations: PendingInvitation[]; docs: Record`; // src/components/zn-base-window.ts +var RECENT_LIMIT = 20; var ZnBaseWindow = class extends i4 { static properties = { query: { @@ -2058,7 +2059,8 @@ var ZnBaseWindow = class extends i4 { ]; render() { const q = this.query.trim().toLowerCase(); - const recents = desk.recents.filter((d3) => !q || d3.name.toLowerCase().includes(q)); + const matching = desk.recents.filter((d3) => !q || d3.name.toLowerCase().includes(q)); + const recents = this.docSort === "recent" && !q ? matching.slice(0, RECENT_LIMIT) : matching; const models = desk.models.filter((m2) => !q || m2.name.toLowerCase().includes(q) || (m2.description ?? "").toLowerCase().includes(q)); const open = desk.openDocIds; const enterDoc = q ? recents[0] : void 0; @@ -2250,6 +2252,10 @@ var ZnBaseWindow = class extends i4 { }} > + ${desk.sync?.at?.staleScope ? b2`` : A}
Local Device · ${local.length} ${local.length ? local.map((d3) => this.renderRow(d3, open.has(d3.id))) : b2`

Nothing lives only on this machine.

`}
${spaces.map((s4) => { - const here = recents.filter((r4) => inSpace.get(r4.id) === s4.ref); - const away = s4.available.filter((a3) => !q || a3.name.toLowerCase().includes(q)); + const here = byName(recents.filter((r4) => inSpace.get(r4.id) === s4.ref)); + const away = byName(s4.available.filter((a3) => !q || a3.name.toLowerCase().includes(q))); return b2`
${s4.name || "unnamed space"} · ${here.length + away.length} @@ -2574,6 +2589,11 @@ var ZnConnections = class extends i4 { box-shadow: var(--shadow-pop); color: var(--ink); font-size: var(--text-sm); + /* The window's title bar sets user-select: none and this popover + lives inside it. Everything here — paths, fingerprints, links, + errors — exists to be read and copied. */ + user-select: text; + -webkit-user-select: text; } .panel::backdrop { background: transparent; @@ -2618,6 +2638,34 @@ var ZnConnections = class extends i4 { text-overflow: ellipsis; white-space: nowrap; } + /* Diagnostics are for reading and reporting: full text, wrapped, + selectable — a truncated error nobody can copy is not an error + message, it is a rumour. */ + .mono.wrap { + white-space: pre-wrap; + overflow-wrap: anywhere; + overflow: visible; + text-overflow: clip; + user-select: text; + -webkit-user-select: text; + margin: 0 0 var(--sp-2); + max-height: 9rem; + overflow-y: auto; + } + details.detail summary { + cursor: pointer; + font-size: var(--text-xs); + color: var(--ink-muted); + user-select: none; + padding: 2px 0; + } + details.detail summary:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; + } + details.detail[open] summary { + margin-bottom: var(--sp-2); + } .ok { color: var(--ink-muted); display: inline-flex; @@ -2772,7 +2820,7 @@ var ZnConnections = class extends i4 { const s4 = desk.syncDoc(this.doc.id); const canHost = sync?.at?.signedIn === true || sync?.devIdentity === true; if (!canHost) { - return this.renderSignInPrompt(s4 ? "This document is shared, but live sessions need your AT identity \u2014 sign in to reconnect it." : "Going live needs your AT identity, so people can find you."); + return this.renderSignInPrompt(sync?.at?.staleScope ? "Your last sign-in didn\u2019t include permission for live documents. Signing in again grants it \u2014 nothing on this machine is affected." : s4 ? "This document is shared, but live sessions need your AT identity \u2014 sign in to reconnect it." : "Going live needs your AT identity, so people can find you."); } if (!sync?.relayOk) return this.renderRelayDown(Boolean(s4)); if (!s4) { @@ -2933,19 +2981,29 @@ var ZnConnections = class extends i4 { renderRelayDown(shared) { const sync = desk.sync; const spaces = sync?.provider === "spaces"; - const relay = sync?.relay?.replace(/^wss?:\/\//, "").replace(/\/ws$/, ""); - const detail = [ - relay, - sync?.relayError - ].filter(Boolean).join(" \u2014 "); - const backendName = spaces ? "your PDS" : "the relay"; + const server = sync?.relay?.replace(/^wss?:\/\//, "").replace(/\/ws$/, ""); + const kind = sync?.errorKind; + const raw = sync?.relayError; + const explanation = !spaces ? shared ? "Live sharing is paused: Ziran can\u2019t reach the relay. Your edits stay on this machine and flow to the others when it reconnects." : "The relay is unreachable right now \u2014 Ziran keeps trying and will reconnect on its own. This document keeps working on this machine either way." : kind === "unsupported" ? `${server ?? "Your server"} doesn\u2019t support live documents: they need a server running the AT Proto Spaces protocol, which most don\u2019t yet.` : kind === "permission" ? `${server ?? "Your server"} refused the request: this sign-in doesn\u2019t carry permission for live documents. Signing out and back in grants it.` : `Ziran couldn\u2019t reach ${server ?? "your server"} just now. Your edits stay on this machine either way, and Ziran keeps trying.`; return b2` -

- ${shared ? `Live sharing is paused: Ziran can\u2019t reach ${backendName}. Your edits stay on this machine and flow to the others when it reconnects.` : `${spaces ? "Your PDS is" : "The relay is"} unreachable right now \u2014 Ziran keeps trying and will reconnect on its own. This document keeps working on this machine either way.`} -

- ${detail ? b2`

${detail}

` : A} +

${explanation}

+ ${kind === "unsupported" ? b2`

+ You can create an account + that supports it, then sign in with that handle. +

` : A} + ${raw ? b2` +
+ What the server said +

${raw}

+
+ + +
+
+ ` : A}
+ ${kind === "permission" ? b2`` : A}
`; diff --git a/tiles/built/richtext.tile b/tiles/built/richtext.tile index 56ebbdbf0d06fd79f311be20382b3a6d66f0fbd7..43090cee02a4c2c930f351051943f5b3f600de55 100644 GIT binary patch delta 1625 zcmZ3nLcD96_=b&)3Nro)S1#wI{hlwAFLzsH(N%Muq?${eB`QqO(q@ga3{9IaGIBGj z-*04M3>6}*n3*|4N?S*vxF9F9#3!@3#IqzdS4SZyGYyGXoSBymliQpvo6K6Tudm>j zU!stimz+~toLQD?rI471;3?!L7G*0GXXd2lmE=?^6qhIFmZX-Y7AaKb zmvSjUfxf;%No7H*LSAY`i9$)fLS~7MLUF!AqC#6v+&dXtYT#Z3OIsIu8b z*;JukK|x<%0TfUQNvRo$WtsV^#i_ZOMMe2Vx~Ub3 zxdl0?y2Ys_r3F@S`$6HNudm>mSCU!;iI-x9l8jV^%#zex1&xx7#1e%}NLVW5CT6D= zgMunGIUf`%Nu@bCsU?~^=w^i^Ch7a;r|8BiR{QMCpWkUEK=oZ9*1LR&)~9h{doByIml~>e0lRygA2t zgD@y31*I0}=ai+UD3pM*KxVN*eo;zhUSd%tSinjlr6|8ZAu+EKmUuxq0-VD@sUkVC zD76F{uKN0*lmQ9y{NxKRNdl|%ADv_Xi_Rq zELO-WEk-gbF*!N4pd>W~7HcI%3bqO*MS8`lCBY^6MX4#iiACAP8X9GpspWdbC5a`e zdc_EF1$+C+4}C?ad)2V=NoYbHsGy*imYGwMTBMnPM+c`HaXK?Xmf7(9Uf>VgJgfO$H19Ax3suKAu+k6G%+Wq5?nff zGK`f%aYkwiwy=Xc2vq!Jq$+@e5fpl`a08V=pzuWtw#>w0g@T;K%sg=1C?uvQX6C^H zNI@YpO+iBuRu1K*YbsR3?NLxj&d)0@ftES83Pc2(3?kS-Wdin~W3lE^P?&t6OkD^$ wQb5V0SaULCxbWlxE0)PjHA2nqDedkljP33zOzrL|%VH2oGKLBfR?N(tA+>p$Y%=R)4wYc$lA_Yh@hYJTlXaX$H_N*B2~XyYQJI_=s=K)* g<_=G@c6PgVHe ({ id: d.getAttribute('data-cmt') }) }], toDOM: (m) => ['span', { 'data-cmt': m.attrs.id, class: 'cmt' }, 0], }, + // Not inclusive: an inclusive mark silently swallows whatever you + // type next to it, so a suggestion grew forever and leaving + // suggestion mode changed nothing. The suggest pass marks exactly + // what landed instead (see suggestPlugin). sug_ins: { attrs: { id: {}, who: { default: '' } }, - inclusive: true, + inclusive: false, parseDOM: [{ tag: 'ins[data-sug]', getAttrs: (d) => ({ id: d.getAttribute('data-sug'), who: d.getAttribute('data-who') ?? '' }) }], toDOM: (m) => ['ins', { 'data-sug': m.attrs.id, 'data-who': m.attrs.who, class: 'sug-ins' }, 0], }, @@ -379,6 +383,15 @@ 'Mod-i': toggleMark(schema.marks.em), 'Backspace': (s, d, v) => (suggesting ? suggestDelete(s, d, -1) : false), 'Delete': (s, d, v) => (suggesting ? suggestDelete(s, d, 1) : false), + // List behaviour, straight from prosemirror-example-setup: + // Enter splits the item (that is what makes a second bullet), + // Tab/Mod-] nests, Shift-Tab/Mod-[ lifts back out. Each command + // returns false outside a list, so baseKeymap still applies. + 'Enter': splitListItem(schema.nodes.list_item), + 'Tab': sinkListItem(schema.nodes.list_item), + 'Shift-Tab': liftListItem(schema.nodes.list_item), + 'Mod-]': sinkListItem(schema.nodes.list_item), + 'Mod-[': liftListItem(schema.nodes.list_item), }), keymap(baseKeymap), dropCursor(), @@ -510,7 +523,13 @@ tr = tr.delete(s.from, s.to); } } - if (tr.docChanged || tr.steps.length) view.dispatch(tr); + // Resolved text is ordinary text: drop any suggestion mark the caret + // is still carrying, or the next keystroke re-styles what was just + // accepted. + tr = tr.setStoredMarks((view.state.storedMarks ?? view.state.selection.$from.marks()) + .filter((m) => m.type !== schema.marks.sug_ins && m.type !== schema.marks.sug_del)); + if (tr.docChanged || tr.steps.length || tr.storedMarks) view.dispatch(tr); + renderSide(); } /* ——— images ——— */ @@ -582,6 +601,14 @@ btn('Comment', 'Comment on selection', addComment, () => false), btn('Suggest', 'Suggestion mode: edits become proposals', () => { suggesting = !suggesting; + // Leaving the mode must actually leave it: shed any suggestion mark + // the caret is carrying so the next keystroke is plain text again. + if (!suggesting) { + const marks = (view.state.storedMarks ?? view.state.selection.$from.marks()) + .filter((m) => m.type !== schema.marks.sug_ins && m.type !== schema.marks.sug_del); + view.dispatch(view.state.tr.setStoredMarks(marks)); + } + renderStatus(); }, () => suggesting, 'suggest'), ]; for (const b of buttons) bar.append(b === 'sep' ? Object.assign(document.createElement('span'), { className: 'sep' }) : b); diff --git a/tools/spaces-spike.ts b/tools/spaces-spike.ts index ffc8968..525a15e 100644 --- a/tools/spaces-spike.ts +++ b/tools/spaces-spike.ts @@ -253,12 +253,12 @@ console.log('\nspace creation (memberListPolicy)'); const { uri: space } = await xrpc(alice.base, 'com.atproto.simplespace.createSpace', { headers: bearer(alice), body: { - type: 'space.ziran.workspace', + type: 'space.ziran.space', policy: { $type: 'com.atproto.simplespace.defs#memberListPolicy' }, appAccess: { $type: 'com.atproto.simplespace.defs#open' }, }, }); -ok('createSpace under legacy auth', space?.startsWith(`at://${alice.did}/space/space.ziran.workspace/`), space); +ok('createSpace under legacy auth', space?.startsWith(`at://${alice.did}/space/space.ziran.space/`), space); const got = await xrpc(alice.base, 'com.atproto.simplespace.getSpace', { params: { space }, headers: bearer(alice), diff --git a/website/Caddyfile b/website/Caddyfile index d1831fb..826b3fa 100644 --- a/website/Caddyfile +++ b/website/Caddyfile @@ -10,6 +10,9 @@ redir /i/* https://relay.ziran.space{uri} temporary root * /srv - try_files {path} /index.html + # {path}/index.html comes BEFORE the catch-all: without it a real + # subdirectory (/account/) falls through to the root page instead of + # serving its own index. + try_files {path} {path}/index.html /index.html file_server }