Files
pad/web/src/lib/api
xarmian f1c9457790 feat(web): 403-driven cache purge for localIndex (TASK-1360) (#507)
* feat(web): 403-driven cache purge for localIndex (TASK-1360)

Per DOC-1342 design decision #3: the local cache is "what you could
see last time you synced." When the server returns 403 mid-session,
the offending entry is purged so the next read doesn't surface
stale-by-permission rows.

## API client (web/src/lib/api/client.ts)

- New `AccessRevokedScope` type + `setAccessRevokedHandler` registration
  hook. Keeps client.ts free of any store import (no circular dep).
- `request()` on 403 parses the URL with `parseAccessRevokedScope`
  (handles item endpoints `/workspaces/{ws}/items/{idOrSlug}` and
  collection-items endpoints `/workspaces/{ws}/collections/{coll}/items`)
  and invokes the handler. Handler failures are caught + logged; the
  403 still propagates as a `PadApiError`.

## localIndex

- New `removeByCollection(ws, collSlug)` — bulk-remove every row in
  a collection. Used when a collection-scoped 403 means the whole
  grant is revoked.
- New `findByIdOrSlug(ws, idOrSlug)` — id-first then slug-scan
  lookup so an item-scoped 403 with a slug URL can resolve to the
  in-RAM id (the SvelteMap is keyed by id).

## App bootstrap (+layout.svelte)

- Registers the handler once at top level: item → `remove` after
  `findByIdOrSlug` resolves; collection → `removeByCollection`.
  Both purges write through to IDB via the existing `persistRemovals`
  path so reloads don't resurrect the stale row.

401 already triggers a /login redirect; this lands as the 403
counterpart. Permission-revocation that doesn't trigger a 403 (e.g.
visibility loss with no fetched row) is explicitly punted per
DOC-1342 #3 — best-effort cache, not authoritative for permissions.

Parent: PLAN-1343. See DOC-1342 design decision #3.

* fix(web): only purge on GET 403s, not write-method 403s (Codex round 1)

[P1] notifyAccessRevoked fired on every 403 — including POST /items
(create), PATCH /items/{id} (update), DELETE /items/{id} (archive),
and the grants/share-link write endpoints. A read-only user who
correctly fails to create or modify an item would have their entire
collection purged from localIndex.

Gate the purge on read methods (GET / HEAD). Write-method 403s mean
"you can read but not write" — the cached rows are still legitimately
visible. Read-method 403s are the canonical "visibility revoked"
signal.

Parent: PLAN-1343.

* fix(web): purge entire workspace on 403, not per-item/collection (round 2)

[P1] Codex caught two related issues:

  1. Pad's server returns 403 from the workspace-access middleware
     (`permission_denied`, `not a member of this workspace`), and
     item-level visibility misses return 404. A 403 on
     /workspaces/{ws}/items/{slug} therefore means workspace access
     is gone — purging only `slug` leaves the rest of the
     workspace's cache stale.

  2. The item URL parser matched `/items/{slug}/grants`,
     `/items/{slug}/share-links`, etc. — owner-only subroutes that
     can 403 after a role downgrade while the item itself is still
     readable. Purging the item on those was incorrect.

Both fixed by collapsing the scope to "the entire workspace":
AccessRevokedScope is now `{ kind: 'workspace'; workspace }`; the
parser just extracts the workspace slug from any `/workspaces/{ws}/...`
path; the handler in +layout.svelte calls `localIndex.reset(ws)`.
The per-item `findByIdOrSlug` and `removeByCollection` helpers added
in round 0 stay in localIndex for future per-item revocation paths
(e.g. server-side `unauthorized` SSE events), but the 403 path no
longer uses them.

Parent: PLAN-1343.

* fix(web): scope 403 purge to read-model endpoints only (Codex round 3)

[P1] Codex caught that workspace-scoped 403s aren't all
workspace-access-revoked signals. Grant-only guests legitimately get
403 on /workspaces/{ws}/members, /workspaces/{ws}/storage/usage,
etc., while their item read access is fine. The previous
"any workspace-scoped 403 → reset" handler would wipe the local index
on every such 403, leaving guest views stuck loading.

Restrict parseAccessRevokedScope to the explicit read-model endpoints
the local-first store actually consumes:

  GET /workspaces/{ws}/items
  GET /workspaces/{ws}/items/{idOrSlug}
  GET /workspaces/{ws}/items-index
  GET /workspaces/{ws}/items-changes
  GET /workspaces/{ws}/collections/{coll}/items

A 403 on any of these means the cache is stale-by-permission. A 403
on anything else stays opaque to the local index.

Parent: PLAN-1343.
2026-05-11 21:15:43 -04:00
..