This commit is contained in:
j3d1 2026-08-27 20:41:19 +02:00
parent 2bb6624d50
commit 7276750c66
15 changed files with 552 additions and 169 deletions

View file

@ -0,0 +1,77 @@
# API Endpoint Versioning Scope (Design in Progress)
Status: decision deferred. The `/api/` prefix has already been changed to `/api/v1/` for every
route under that prefix (inventory items, storage locations, search, friends, friend requests,
groups, group invites, id mapping, tags/properties/categories/availability policies/info,
files/item files/staged files, and account export/import/delete). Whether that blanket approach is
the right scope, or whether versioning should be narrower, is the open question below.
## Problem
A versioned URL prefix matters most where two independently-deployed servers need to agree on a
wire contract: federation. An endpoint that's only ever called by a server's own frontend against
its own backend is deployed in lockstep with that backend, so it has no independent version to
track. Applying a uniform prefix everywhere is simpler, but it also prefixes a large number of
routes that never cross a domain boundary, and skips one non-`/api/` route
(`/auth/user/<handle>/`, used to fetch a friend's profile/avatar) and the file/thumbnail serving
routes (`/media/...`), both of which do cross a domain boundary and arguably belong in the
versioned surface more than some of the routes currently in it.
One exception either way: the version/capability discovery route (`GET /api/version/`) has to stay
unprefixed no matter which option is chosen — it's how a caller finds out which versions a server
speaks in the first place, so it can't itself live behind a version segment.
## Options
**Option A: version every real endpoint uniformly.**
Every route under `/api/`, `/auth/`, `/admin/`, and `/media/` gets the `/api/v1/` prefix (except
`/api/version/`), regardless of whether it's ever called across a domain boundary. Simple to state
and apply; doesn't require maintaining a classification of which routes are federation-facing.
**Option B: version only routes that actually cross a domain boundary.**
Only routes a server's own frontend calls against a *different* server (directly, or that another
server calls against this one) get the `/api/v1/` prefix. Everything else keeps its existing,
unprefixed path. Smaller versioned surface, but requires keeping the classification below current
as routes change, and means a single URL path can't ever serve both a same-domain and
cross-domain purpose without the whole path being versioned.
## Current classification
Verified against actual call sites (which routes are dispatched against a friend's/foreign
server rather than the caller's own home server), not just against which routes are technically
reachable by a non-local caller — several routes accept a broader signature-based authentication
than they actually need, and one route family enforces "local caller only" itself even though its
authentication layer would allow a remote identity through.
**Crosses a domain boundary today:**
- `POST /friendrequests/` (also called against the recipient's own server, not just the sender's)
- `GET/POST /groupinvites/` (invite delivery is posted to the invitee's own server)
- `POST /group_invites/accept/` (posted to the group's own home server)
- `GET /groups/<handle>/`, `DELETE /groups/<handle>/members/<id>/`, `POST /groups/<handle>/invites/`
- `* /inventory_items/<handle>/...`, `* /storage_locations/<handle>/...` (group-owned or
friend-owned items/locations)
- `GET /search/` (fans out to every known server)
- `GET /resolve_short_id/<kind>/<owner_id>/<local_id>/`
- `GET /auth/user/<handle>/` (friend profile/avatar lookup — not currently under `/api/`)
- `GET /media/...`, `GET /thumbnail/<size>/...` (image bytes fetched from the owning server —
not currently under `/api/`)
**Never observed leaving the caller's own home server:**
- `GET/POST /friends/`, `DELETE /friends/<id>/`, `DELETE /friendrequests/<id>/`
- `GET /groups/` (the collection route, as opposed to `/groups/<handle>/`)
- `DELETE /groupinvites/<id>/`, `POST /groupinvites/<id>/accept/`, `GET /groupmemberships/`
- `GET /idmap/` (distinct from `/resolve_short_id/...`, which does cross domains)
- `* /workflows/...`
- `GET /tags/`, `GET /properties/`, `GET /categories/`, `GET /availability_policies/`, `GET /info/`,
`GET /domains/` (also not currently called from the frontend at all)
- `POST /import/`, `GET /export/`, `DELETE /account_data/`, `DELETE /account/` — these accept the
same broad authentication as the federation-facing routes, but reject any non-local caller
themselves
- `GET /files/`, `* /item_files/...`, `* /staged_files/...`
- `GET/PATCH /auth/user/`, `POST /auth/register/`, `POST /auth/token/`, `GET /auth/preferences/`,
`GET/PUT /auth/self/preferences/`, `DELETE /auth/self/preferences/<key>/`, `* /auth/users/...`
(admin management)
- everything under `/admin/` (domain/category/property/tag administration)
**Exception regardless of which option is chosen:**
- `GET /api/version/`

View file

@ -50,16 +50,20 @@ separate "discover groups you're not in" browsing for MVP — you land in a grou
it, the same way you become friends with someone by request/accept, not by browsing a directory of
all users.
Known limitation: this list only ever queries the member's own home backend, so it only shows
groups actually hosted there (groups you created, or joined on your own domain). Membership itself
works regardless of which backend hosts the group — a remote member can still be invited, accept,
and fully edit/delete the group's items (see "Owning items as a group" below) — but a group hosted
on someone else's backend won't show up in your own "My Groups" list, because unlike friendship
(which both sides record), group membership is only ever recorded on the group's own home backend,
and there's no index anywhere of "which other backends has this identity been added to." Making a
remote membership discoverable would need a small personal pointer index (written by the client at
join time) plus a handle-based group lookup on the group's own backend; deferred as a fast-follow
alongside group-friending.
Listing groups (the table itself) only ever queries the member's own home backend — it's this
backend's own view of "groups I host you in" — but every row links to the same group detail page
regardless of where the group is actually hosted. Mirroring friendship (which both sides record),
the member's own home backend also remembers the bare fact of a remote membership, as a pointer
alongside the group's own home backend's real membership record, and merges the two into one table
keyed by handle (a group present in both keeps its home-hosted row, which already carries a member
count).
A group's full detail resolves from its handle the same way a friend's foreign items already do:
the client resolves the target group's own domain from its handle and talks to that backend
directly, rather than assuming home. `GroupDetail.vue` needs no "is this group local or foreign"
branch anywhere, including for creating a new item/storage location — the owner selector binds
directly to the group's handle, and a group reference is addressed by handle everywhere it appears,
never by a bare internal id.
### Group detail page

View file

@ -129,7 +129,7 @@ long-lived, network-interposing piece of code — exactly the kind of thing a su
or an XSS-planted `registration.update()` would target. If the page's message handler blindly signs
whatever URL the request names, a compromised SW stops being "something that can read images this
identity can already see" and becomes "something that can get a validly-signed request for *any*
endpoint" — e.g. `POST /api/inventory/items/5/delete` or `POST /api/friends/accept` — and then just
endpoint" — e.g. `POST /api/v1/inventory/items/5/delete` or `POST /api/v1/friends/accept` — and then just
replay it directly against the real backend. That's a full account-takeover primitive smuggled in
through what was supposed to be an image-caching optimization, and it's strictly worse than not
having the bridge at all.

View file

@ -1,8 +1,11 @@
# Item Handles & Physical Labels (Design in Progress)
Status: not implemented. This document collects the problem, goals, and open design questions for
giving inventory items stable identifiers and physical (scannable) labels. Nothing here is
settled.
Status: partially implemented. This document collects the problem, goals, and open design
questions for giving inventory items stable identifiers and physical (scannable) labels. The core
handle/URL shape question below is settled, generalized to cover any owned kind, not just items —
see [User-Qualified ID](../handles-and-shortids.md#user-qualified-id) and
[Item URL](../glossary.md#item-url). The remaining open questions (unguessability, label retirement
on deletion, route reconciliation, lending/borrowing) are not.
## Problem
@ -59,11 +62,16 @@ unguessability question below).
Two distinct formats are needed, because "an item handle" is used in two different situations:
- *A compact handle, for use where context already makes clear it's a Toolshed item.* Inside the
app, in exports, in logs, anywhere the reader already knows they're looking at Toolshed data,
the handle doesn't need to spell that out or be openable on its own. This can be as short as
`user@domain.tld:id`, the item's owner handle with `:id` appended, mirroring how a tag/category
handle already appends `:name` after its origin (see federation.md's Unique Handles section).
No new delimiter concept, just the same pattern applied to items.
app, in exports, in logs, anywhere the reader already knows they're looking at Toolshed data, the
handle doesn't need to spell that out or be openable on its own. An item's compact handle is the
item-kind instance of the general [User-Qualified
ID](../handles-and-shortids.md#user-qualified-id) scheme: the item's owner handle, the `i` kind
letter, and the local id, e.g. `alice@example.com:i42`. The same scheme covers any owned kind (a
storage location, for instance); item handles are just the motivating case. An alternative in the
same compact-handle family pairs the owner's domain with a [short
id](../handles-and-shortids.md#short-ids) instead, e.g. `domain.tld:~DyU` (see
[Domain-Qualified Short ID](../handles-and-shortids.md#domain-qualified-short-id)) — shorter, at
the cost of needing the app's short id decoder to make sense of it at all.
- *A self-contained URL, for use with no context at all.* A physical label, a link shared outside
the app, has to work without the reader already knowing what it is or which server it belongs