This commit is contained in:
j3d1 2026-08-20 05:04:30 +02:00
parent c345372382
commit 395a9b156a
9 changed files with 1434 additions and 0 deletions

View file

@ -0,0 +1,113 @@
# Groups MVP (Design in Progress)
Status: not implemented. [groups.md](groups.md) works out the underlying model (what a group is,
how "acting as a group" is authenticated across federation). This document doesn't re-derive any
of that; it takes that design as given and asks a narrower question: what is the smallest set of
screens and actions that makes groups usable, reusing the UI patterns Toolshed already has for
friends and inventory rather than inventing new ones.
## Goals
- Ship something a user can actually click through: create a group, add/remove members, and have
the group own items, end to end.
- Reuse existing screens and interaction patterns wherever the shape already matches, instead of
designing new ones (see below).
- Leave every deferred piece as an explicit non-goal rather than a silent gap, so it's clear what
MVP does and doesn't cover.
## Non-goals (for now)
- **Group-to-group friending, and letting outside users friend a group.** groups.md's model
supports both, but the only audience for a group's items in MVP is the group's own members;
"private" already covers that (see "Availability policy for group items" below). Extending
visibility to non-members via the group's own friends list is real, valuable, and deferred as a
fast-follow, not designed here.
- **Transferring an existing personally-owned item's ownership to a group.** MVP only supports
creating a *new* item directly owned by a group. Moving an already-existing item across owners is
a separate piece of work (ownership transfer isn't something Toolshed supports for user-to-user
either today).
- **Deleting a group.** MVP has no explicit "delete this group" action. A group's lifecycle is just
"created, membership changes over time"; the orphaning guard (a group can't be left with zero
members, see groups.md's Known Gaps) means a group that's no longer wanted just sits unused rather
than needing a teardown flow.
- **Any governance, roles, or per-item permissions.** Same non-goal as groups.md: every member has
equal, full privileges over the group and everything it owns.
## User-facing features
### Creating a group
A new "Groups" section, entry point styled like Inventory's "Add" button. The form is just a name;
the handle (`#name@yourdomain`) is derived from it the same way a username becomes part of a user's
handle. The creator becomes the group's first member automatically — there's no empty-group state
to design for.
### My Groups list
A "Groups" nav entry/page, modeled directly on `Friends.vue`: a table of groups the current user is
a member of (name/handle, member count), each row linking into that group's detail page. No
separate "discover groups you're not in" browsing for MVP — you land in a group by being added to
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.
### Group detail page
One page per group, with two sections, each reusing an existing pattern wholesale:
- **Members** — the add/remove-row pattern from `Friends.vue`'s friend list: an inline "add member"
field (enter a handle, `user@domain`), and a remove (trash icon) action per row. Any member can
add or remove any other member — flat privilege, no confirmation step beyond the orphaning guard
(removing the group's last member is blocked, with an error explaining why, rather than silently
emptying the group).
- **Group inventory** — the exact table/grid pattern from `Inventory.vue` (Name, Availability
Policy, Amount, Edit/Delete), scoped to items owned by this group, with the same "Add" button
leading into the existing item-creation form (see below).
A "Leave group" action removes the current user from Members; if they're the last member, it's
blocked by the same orphaning guard.
### Owning items as a group
The existing item create/edit form (`InventoryNew.vue` / `InventoryEdit.vue`) gets one new field:
an "Owner" selector, defaulting to "Myself," with the groups you belong to as the other options.
Every other field on that form (tags, properties, availability policy, storage location, quantity)
is unchanged, and a group-owned item behaves exactly like a personally-owned one everywhere else in
the app (edit, delete, detail view) — any member can edit or delete it, the same way the owner can
today.
### Where group-owned items show up
The main "Inventory" page stays scoped to items you personally own, unchanged from today. A group's
items are visible and managed in exactly one place: that group's detail page. This keeps "my
inventory" meaning one thing (what I personally own) and avoids merging two different item lists
with different edit semantics into one view for MVP.
### Availability policy for group items
The item form's existing Availability Policy field (private/share/lend/rent/sell) is unchanged and
applies to group-owned items the same way it does today. Since group-friending is out of scope for
MVP (see Non-goals), "share/lend/rent/sell" have no wider audience to expand to yet, only "private"
is fully meaningful right now — a private group item is visible to and editable by every group
member, which is already the core value the Problem statement in groups.md is after (a shared
workshop's members all having a say over shared equipment). The field stays as-is rather than being
trimmed down to just "private," so nothing needs to change on it once group-friending ships.
## Open scoping call
Deferring group-friending is the one judgment call in this document worth flagging explicitly:
it means an MVP group can't yet share an item with anyone outside its own membership, which is a
real limitation, not just a simplification. It was scoped out because it pulls in a second piece of
UI (a group's own Friends tab, and "who can accept a friend request on the group's behalf") that
isn't needed for the core "shared ownership among members" use case to work end to end. Worth
confirming this is the right line before building against it.

View file

@ -0,0 +1,233 @@
# Groups (Design in Progress)
Status: not implemented. This document collects the problem, goals, and open design questions for
adding groups to Toolshed. Nothing here is settled; it's a starting point for discussion.
## What a group is
A group exists to model shared ownership of items, the way a club, workshop, or company owns
equipment collectively rather than any one person owning it. A group has members, and all members
have equal privileges to edit the items the group owns; there's no owner-vs-member distinction
within a group, membership itself is the privilege.
This is a sharper (and more consequential) definition than "a label you can share things with":
it means a group needs to be able to *own* things, not just receive shared access to them the way
a friend does. That has implications worked out below.
## Problem
The only relationship Toolshed currently models between users is pairwise friendship (see
[federation.md](../federation.md)), and every item has exactly one owning user. That's enough for
"I trust this one specific person" and "I personally own this thing," but it breaks down for
anything collectively owned:
- A shared workshop, tool library, or team can't own equipment as a unit. Today it has to belong
to one specific person's account, which is a poor fit and doesn't reflect who actually has a say
over it.
- There's no way for several equally-privileged people to edit the same item; edit rights today
are entirely tied to the single `owner` field.
- Adding or removing a member of an informal group currently means renegotiating friendships and
re-sharing individually; there's no shared object whose membership can just be edited once.
## Goals
- Let a set of users collectively own items, with every member holding equal edit rights over
those items.
- Let membership be managed in one place instead of via N pairwise arrangements.
- A request from a client to any backend may not depend on any other backend being online at the
same time — not to construct the request, and not to verify it. Concretely: the receiving server
must be able to verify the request using only the request itself plus keys it has already cached
from prior trust (friend-accept), with the group's own authoritative backend and the requesting
member's home server both unneeded and unreachable-safe at verification time; and the client must
be able to send the request using only what it already has cached, with the group's authoritative
backend unneeded and unreachable-safe at send time too. This is the same property plain user
requests already have (see federation.md's Cryptography section); group requests must not regress
it on either side.
- Fit into the existing handle system (see federation.md's "Unique Handles" section): a group
should be nameable and referenceable using the same handle shape a user is.
- Stay optional and additive. Pairwise friendship and single-user ownership should keep working
exactly as they do now for people who never touch groups.
## Non-goals (for now)
- Group governance beyond flat, equal membership (voting, roles, hierarchies). Equal privileges
for all members is the whole model for now; anything more layers on top later if needed.
- Fine-grained per-item permissions within a group (e.g. "this member can edit but not delete").
Membership is the only privilege level.
## Open design questions
### Does a group need its own keypair?
No. A group's day-to-day existence is a membership roster maintained by whichever backend is
authoritative for the group's handle (the same "authoritative backend" idea a user's domain already
implies, see federation.md's Servers subsection). A request made "as the group" is an ordinary
request, signed with an actual member's own personal private key, together with a claim of which
group it's acting on behalf of (`acting_as`), signed as part of the same payload as the rest of the
request.
A receiving server must be able to authenticate such a request using only what arrives in the
request plus keys it already holds; no other server, including the group's own authoritative
backend, needs to be reachable at verification time. This is accomplished with **membership
certificates**: on request, a group's authoritative backend issues a current member a small
signed statement of the shape "handle X, public key P, is a member of group #G, valid from T1 until
T2," signed with the group's own private key. Issuance is on-demand (the member asks, rather than
the backend pushing renewals on a schedule), but it is its own separate action, decoupled from
sending any particular group request: a member fetches and refreshes this certificate from the
group's backend whenever they happen to be online, caches it locally, and later attaches whichever
certificate they currently hold to a request they make "as the group." Sending that request never
itself triggers a live fetch from the group's backend — if the cached certificate has expired and
the group's backend isn't reachable right then, the request simply can't be sent as the group yet;
the client doesn't fall back to contacting the group's backend synchronously to get one.
A receiving server checks two signatures, using keys it already has cached, with no outgoing call
to anyone: the member's signature over the request, using the public key embedded in the
certificate itself, and the certificate's own signature, using the group's public key, learned and
cached exactly the way any friend's key is, at the point the group was friended. If both check out
and the certificate hasn't expired, the request is authorized.
The certificate is what lets a receiving server trust a specific member's public key at all, for
members it has never individually friended: that trust is vouched for by the group's already-cached
key, rather than requiring a separate key-exchange with every member of every group a user happens
to be friends with. Equal privileges for all members falls out of this directly, since any member's
own key plus a valid certificate is sufficient proof.
Removing a member takes effect once their existing certificate expires, not the moment the
backend's roster is edited; certificate lifetime is the parameter that governs how quickly a
removal actually takes hold (see "How long should a membership certificate be valid for?" below,
and Security below).
### Are group handles different from user handles, or is a group just a special kind of user?
The handle should look almost exactly like a user handle, just prefixed with `#`: `#groupname@domain`
instead of `groupname@domain`. It's resolved the same way and referenceable in the same places (e.g.
as an item's owner, or as a friend-list entry), so the reuse of the existing federation model is
unaffected. The prefix exists only to keep group and user handles from occupying the same namespace on
a domain: without it, "is `groupname@domain` a user or a group" would depend on which one happened to
register the name first, and the two could never be told apart just by looking at the handle. With the
prefix, a domain can have both a `climbing@domain` user and a `#climbing@domain` group with no
collision and no ambiguity about which is which, and any code path that resolves a handle can dispatch
on the actor kind (user vs. group) from the handle's own shape, before it even needs to ask a server.
But underneath, a group isn't really "a special kind of user," it's backed by a fundamentally
different kind of identity: a membership list instead of a single keypair (see above). A user
handle answers "is this request authentically from this one identity," a group handle answers "is
this request from someone currently entitled to act for this collective identity." Those are
different questions even though the answer to both ends up being "yes, forward the request." Groups
and users are probably best thought of as two kinds of actor that share a handle format and most of
the surrounding plumbing (ownership, friendship, availability policy), rather than one being a
special case of the other.
### Should a group be able to grant read access to non-members ("group friends")?
Yes, this should reuse the same mechanism a user's own sharing already uses. Since a group is an
actor with a handle, it can have its own friends the same way a user does, and a group's owned
items can go through the same availability-policy check (private vs. visible to the group's
friends) that an individual's items already do. Nothing new needs to be invented here, it's the
existing friendship and availability-policy machinery applied to a second kind of actor.
### Should groups be able to befriend other groups?
Yes, for the same reason: if a group is an actor with a handle and a friends list, there's no
reason the other side of that friendship has to be a user specifically. Two clubs befriending each
other so each can see the other's shared equipment is the same mechanism as two users befriending
each other, just with both sides being groups instead of one or zero.
### Should anyone be able to share directly with a specific group, instead of with "my friends" generally?
This is the one piece that isn't just reuse of what exists today. Right now, an item's
availability policy is all-or-nothing across *all* of the owner's friends, there's no way to share
with a subset of friends, individually or as a named group. Letting an item be shared with one
specific group (a user's own item, shared with a club they belong to, say) instead of with every
friend equally would be a genuine generalization of the current sharing model, not something that
falls out of adding groups as an actor.
Worth noting: this capability would be just as useful for individual users wanting to share with a
subset of their friends, without a group being involved at all. It might make more sense to design
"share with a specific target (user, group, or named subset)" as its own piece of work, rather than
building it as a groups-only feature.
### How long should a membership certificate be valid for?
This is the tuning knob the certificate design introduces, and it's a real trade-off rather than a
detail to defer. A short validity window (say, hours) keeps the staleness window after a removal
small, but means a member who's offline for longer than that can't act as the group at all until
they reconnect and refresh. A long window (days or weeks) is more forgiving of intermittent
connectivity but leaves a removed member's old certificate usable for longer. Whatever default is
picked, a member should be able to fetch a fresh certificate well before the old one expires while
still online, so the common case isn't "offline for exactly the wrong amount of time."
## Interaction with availability policy
Items currently have an availability policy (private / share / lend / rent / sell) that's a
property of the item, not a list of who it applies to, "friends" is implicit and applies equally to
all of them. A group-owned item works the same way, just with the group's own friends as the
implicit audience instead of an individual's. Targeted sharing (the question above) would extend
this, not replace it.
## Known gaps in the design
None of these block the design, but they're real gaps that need an explicit answer before
implementation.
### Ambiguity
- **What exactly is signed.** The `acting_as` claim and the certificate's identifying fields must be
signed as part of the same payload the member's key signs, not as free-standing, unsigned data
alongside it. If they aren't inside the signed bytes, `acting_as` can be swapped after signing,
turning a personal request into a group one or vice versa, or one group's request into another's.
- **Who can change membership, and how.** Equal privileges to edit items doesn't by itself say
whether that equality extends to *requesting or renewing certificates for others, or editing the
authoritative backend's issuance list itself*. Flat and unilateral (any member can add or remove
any member) is the simplest reading of "no owner-vs-member distinction," but it's a materially
different trust model from "equal edit rights over items" and deserves its own explicit decision.
### Security
- **Confused deputy on `acting_as`.** Trust in an `acting_as` claim reduces to trust in the group's
authoritative backend's issuance decisions: a malicious or compromised backend can sign a
certificate for a handle that was never really a member, and every receiving server that trusts
the group's key will accept it. The backend's issuance discipline, and the security of its own
private key, is a single point of failure for the group as a whole.
- **Membership staleness window.** There is a window after a member is removed during which their
existing certificate keeps working: exactly the certificate's remaining validity period. This is a
strictly worse revocation story than individual friendship, where trust is keyed to a public key
learned once with no expiry, but the window is a bounded, chosen parameter (see "How long should a
membership certificate be valid for?" above) rather than open-ended.
- **Blast radius of a single compromised member key.** Because membership is flat and unilaterally
editable by any member, a compromised personal key doesn't just expose that person's own items,
as with an ordinary account compromise, it exposes edit rights over everything the group owns for
as long as that member's certificate remains valid, and can be used to obtain a certificate for an
attacker-controlled handle as a permanent member before anyone notices. This risk is inherent to
"equal privileges, no roles" as a model, worth flagging even though richer governance is a
non-goal for now.
- **Self-lockout / orphaning.** Nothing in the design stops a group's last member from leaving (or
removing everyone else) from the backend's issuance list, which would leave group-owned items with
no one able to obtain a valid certificate for that owner at all once existing certificates expire.
The backend should guard against removing the last member, but that guard doesn't address a
member unilaterally removing every *other* member, which the flat model otherwise permits.
- **Audit trail depends on discipline.** Since any member's signature plus a valid certificate
satisfies authorization, "the group edited this item" is never sufficient for an audit trail; the
actual signer's handle (from the certificate's embedded public key) must always be logged
alongside the group claim, or member-level accountability is lost entirely.
### Maintainability
- **Two actor kinds sharing one code path.** `Group` should have the same shape as `User` for the
things that matter (a `.friends` set, a `.handle`), so ownership/friending/availability-policy
code can stay actor-agnostic. That reuse only holds if future code is disciplined about not
special-casing `User` in ways that assume a single, non-expiring keypair (e.g. "cache the owner's
public key forever, no expiry check needed") — a shortcut that would silently break the moment
the owner turns out to be a group, where the *acting member's* key is only good until its
certificate expires.
- **Certificate issuance and refresh is a client responsibility.** A member's client needs to
refresh its certificate before it expires, handle in-flight group actions failing closed if it
didn't (the same as any expired-credential error), and surface refresh failures to the user
rather than as a confusing rejected request.
- **Expanded federation test surface.** Every existing federation test implicitly assumes the
request's signer and its authorized actor are the same handle. `acting_as` plus an embedded
membership certificate means the whole request-verification path needs testing for the
signer-vs-actor split and the certificate's own signature and expiry checks, including
cross-domain cases (group hosted on one domain, member's key registered on another, item owned by
the group sitting on a third) and expiry-boundary cases (certificate expires mid-flight, is
refreshed concurrently with a request, etc).

View file

@ -0,0 +1,166 @@
# Authenticated image caching (Design in Progress)
Status: not implemented. This document proposes a fix for a real performance gap: every
authenticated image in the app is refetched, re-verified, and re-decoded from scratch on every page
load, even though the backend already sends headers built for exactly the opposite.
## Problem
Images are served from `GET /media/<hash_path>` and `GET /media/<size>/<hash_path>/`
(`backend/files/media_urls.py`), both gated behind `SignatureAuthentication`
(`backend/authentication/signature_auth.py`): the client signs the full request URL with an Ed25519
key and sends `Authorization: Signature <user>@<domain>:<sig>`. There's no cookie and no
URL-embedded token — auth lives entirely in a request header that a browser has no way to attach to
a plain `<img src="...">`. So `AuthenticatedImage.vue` does it by hand: `fetch()` with the header,
`.blob()`, `URL.createObjectURL()`, assign that to `src` (`federation.js`'s `getRaw`,
`fileCache.js`). `fileCache.js` is a module-level `Map` — it dedupes concurrent requests and holds
decoded blobs for the life of the page, but it's memory-only. Reload the page (or just navigate
between the SPA's route-based chunks in a way that re-mounts things) and it's gone; every image the
user has already looked at gets fetched, signature-verified, and blob-decoded all over again.
Meanwhile the backend response already carries `ETag`, `Cache-Control: max-age=31536000, private,
immutable`, and a 365-day `Expires` (`_cache_headers`, `media_urls.py`) — because `src` is a
SHA-256 hash-addressed path, the same URL can only ever mean the same bytes, forever. Those headers
are correct and unused: nothing durable in the client ever consults them. This design closes that
gap using the browser's own Cache Storage API, without touching the backend.
## Goals
- Make a previously-viewed image load instantly on the next page load / browser restart, not just
within the current tab's JS session.
- Do it without weakening the authorization model: a signature is still required and verified
server-side for the *first* fetch of a given file by a given identity. Caching must not let one
identity's cached bytes leak to a different identity sharing the same browser.
- Reuse the backend's existing headers rather than inventing a parallel freshness scheme — content
is immutable, so a cache hit needs zero revalidation, ever.
- No backend changes. This is purely a client-side storage question.
## Non-goals (for now)
- **Revoking already-cached bytes when access changes** (e.g. an unfriend). The backend's own
1-year `Cache-Control` already accepts that risk today for anything an HTTP-compliant cache might
hold; a persistent client cache extends the shelf life of that same accepted risk, it doesn't
introduce a new one. Not solving revocation here.
- **Prefetching / warming the cache ahead of navigation.** Real optimization, separate piece of
work; this document is about not throwing away work already done.
- **A Service Worker that reinstates plain `<img src>`.** Sketched below as a follow-up because it's
the "real" fix for the root cause (no way to attach a header to an `<img>` request), but it's a
bigger lift (SW lifecycle, an extra message-passing bridge for signing) than the storage win alone
needs. Scoped out of this pass.
## Design: persist `fileCache` with the Cache Storage API
`window.caches` (the `CacheStorage` interface) is available to any page context, not just inside a
Service Worker — `caches.open(name)` gives a store of real `Request`/`Response` pairs that survives
reloads and browser restarts, backed by the browser's own disk quota. That's the missing tier;
nothing else about `fileCache.js`'s existing shape needs to change.
**Two tiers, not one:**
- **L1 — in-memory `Map<key, objectURL>`** (what exists today). Kept as-is: within a single page
session, components just want the already-created object URL back without re-touching storage at
all. Same LRU/budget logic (`MAX_BYTES`), unchanged.
- **L2 — `CacheStorage`**, consulted on an L1 miss, before falling back to the network. Holds raw
`Response` objects (not blobs), keyed by the same request used for the authenticated fetch.
Revised `get(key, fetcher)` flow:
1. L1 hit → return the object URL, as today.
2. L1 miss → check `cache.match(request)`. Hit → `.blob()` the cached response, create the object
URL, populate L1, done. **No conditional GET, no revalidation** — the response is `immutable`,
so if it's in the cache it's still correct by construction.
3. L2 miss → run the existing authenticated `getRaw()` fetch. On success, `cache.put(request,
response.clone())` before consuming the body, then proceed as today (`.blob()`, object URL,
populate L1).
**Namespacing by identity, not one global cache.** `Cache-Control: private` on the response is the
backend telling shared caches to stay out — correct, since access is per-requester
(`_accessible_files`'s friends-or-self check). A single browser-wide `CacheStorage` bucket keyed
only by URL would quietly turn into exactly the shared cache that header is warning off, *if* this
browser ever holds more than one local identity (switching accounts, a shared machine). Concretely:
open the cache as `images-${username}@${domain}` (derived from the active `state.keypair`, the same
identity that produces the signature) rather than a single `"images"` name. Same-identity re-fetches
get the full cache benefit; a different identity in the same browser starts with an empty bucket and
goes through the normal authenticated-fetch-then-verify path, same as it does today. `invalidate()`
and `clear()` already exist on `FileCache` but nothing calls them — wire `clear()` to also
`caches.delete(currentNamespace)` and call it on logout/identity-switch, which is the natural,
already-there hook for this.
**Storage budget.** L2 doesn't need its own hard byte cap the way L1 does — `CacheStorage` is
subject to the browser's own storage-pressure eviction, which is the right backstop for "durable but
not sacred" data like this. Optionally call `navigator.storage.persist()` once at startup to ask the
browser to exempt the origin from casual eviction under pressure; harmless to skip if declined.
**Net effect:** a returning user's already-seen images (inventory thumbnails, profile pictures,
friends' shared items) render from disk with zero network round-trips and zero re-verification,
using exactly the durability guarantee (`immutable`, hash-addressed) the backend already asserts.
First-time images are unaffected — same authenticated fetch as today, just now also written to L2 on
the way through.
## Follow-up worth flagging: a Service Worker to restore plain `<img>`
The deeper cost isn't just the network round-trip — it's that every image, cached or not, is forced
through manual `fetch → blob → createObjectURL`, so the browser's native image pipeline (off-main
thread decode, `loading="lazy"`, `fetchpriority`, responsive `srcset`) is unavailable, and object
URLs have to be manually revoked (`fileCache.js` already does this correctly, but every new call
site is a chance to leak one). The reason the app can't use plain `<img src>` at all is that nothing
can attach the `Authorization: Signature` header to a browser-initiated image request.
A Service Worker can, because its `fetch` handler intercepts requests — including image loads —
before they leave the page, and can substitute its own request in place of the original:
- On a `fetch` event where `event.request.destination === 'image'` and the URL matches `/media/`,
check the (identity-namespaced) `CacheStorage` first; hit → respond straight from cache, no
network at all.
- Miss → the SW doesn't have the signing key (it lives in page memory / `localStorage`, neither
reachable from a SW), so it asks the one controlled client (`self.clients.get(event.clientId)`
the specific tab that issued the request, not "any open tab") for a signature over this exact URL
via `postMessage`/`MessageChannel` — an in-process round trip, not a network call — attaches the
returned header, performs the real fetch, stores the result in `CacheStorage`, and responds with
it.
- Once this exists, `AuthenticatedImage.vue` can go back to `<img :src="mediaUrl" loading="lazy"
decoding="async">` directly; the SW is what makes that legal despite the custom auth scheme.
### Scoping the signing bridge: a compromised SW must not become a "sign anything" oracle
The message bridge above is the one new capability this design adds that doesn't exist today: a
channel through which something can ask the page to sign a URL on its behalf. A Service Worker is a
long-lived, network-interposing piece of code — exactly the kind of thing a supply-chain compromise
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
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.
The fix has to live on the page side of the channel, since the SW is the presumed-compromised
component in this threat model and can't be trusted to police itself. Treat the message handler as a
dedicated, narrow function — not a thin wrapper around the app's general-purpose signer
(`createSignAuth` in `federation.js`, which is used for arbitrary API calls elsewhere in the app) —
that:
- **Ignores any method the request claims and always signs as `GET`.** The bridge never accepts a
body/`data` field from the SW at all, which closes off the entire class of mutating requests
(`POST`/`PUT`/`PATCH`) regardless of what path is named.
- **Validates the path against a strict allowlist grammar before signing anything**, rather than a
loose "starts with `/media/`" check. `src` values are hash-addressed —
`/media/<hex>/<hex>/<64-hex-char-sha256>.<ext>` for originals, with an optional `/<32|64|256>/`
size prefix for thumbnails. Because the variable part is constrained to `[0-9a-f]`, a regex over
that exact shape is effectively a closed grammar: `.` and `/` (the characters path traversal or
extra-segment tricks would need) simply aren't in the hex alphabet, so there's no meaningfully
malformed input that still matches. Anything that doesn't match — a different endpoint, an
encoded traversal attempt, an extra query string — is refused, silently or with a logged warning,
never signed.
- Optionally also checks the URL's host against the identity's home domain or its current friend
servers (belt-and-suspenders — a signature is bound to the exact signed URL string, so it can't be
replayed against a different host than the one named in it, but this catches a compromised SW
fishing for signatures against a host that happens to also trust this key for unrelated reasons).
With this in place, the worst a fully compromised SW can do is obtain signed `GET`s for images the
current identity is already authorized to fetch — the same blast radius as "can read the
already-authorized image cache" — not an oracle for arbitrary authenticated mutation.
Deferred because it adds real surface area (SW registration/update lifecycle, this scoped
message-passing bridge, first-load-before-SW-is-active edge cases) beyond what the storage change
alone needs. Worth doing as a second pass once the simpler win above is in and paying off.

View file

@ -0,0 +1,131 @@
# 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.
## Problem
As described in federation.md's "Unique Handles" section, an item today is identified only by a
local id scoped to its owner, it isn't given an explicit, portable handle the way a tag, property,
or category is. That's fine as long as the only thing ever addressing an item is the owning user's
own signed API traffic. It stops being fine the moment something *outside* that loop needs to
refer to the item:
- A friend who borrowed a physical tool has no way to look it up other than finding it in the
owner's shared inventory list by eye.
There's nothing you could put on a sticker.
- If items ever need to be referenced from outside their owner's own requests (a group's shared
view, a lending record, a printed label), there's currently no stable identifier to reference
that's meaningful outside the owner's own account.
- A local database id isn't something we'd want to expose or rely on externally: it's an
implementation detail of one backend's storage, not a handle with the same guarantees
(uniqueness, meaning, longevity) the rest of the federation model gives every other kind of
entity.
Put simply: every other kind of thing in Toolshed (users, tags, properties, categories) has a
handle that means something outside of one database. Items don't, and physical labeling is the
clearest case where that gap actually matters.
## Goals
- Give an item a handle that's meaningful and resolvable outside its owner's own account, without
requiring items to become shared/reusable entities the way tags are (an item is still owned by
exactly one person; see the "Items" subsection of federation.md for why that keeps things
simple).
- Support a physical label (QR code, barcode, or similar) that can be printed and stuck on a real
object, such that scanning it gets you to the right item on the right backend.
- Make the label survive the normal life of a physical object: it gets lent out, comes back,
maybe changes which storage location it lives in, all without needing a new label printed.
## Non-goals (for now)
- Turning items into shareable/reusable entities across owners (that's what tags/categories are
for; see [tags.md](tags.md)). An item handle identifies *this specific person's specific thing*,
not a class of thing.
- Solving inventory tracking/auditing (check-in/check-out logs) as a whole system; that can build
on top of a stable item handle once one exists, but isn't the same problem.
## Open design questions
**What does the handle look like?**
The natural extension of the existing scheme is owner handle + local id, that's enough to be
globally unique (no two users share a handle, and ids are already unique within one user's
inventory) without inventing a new namespace. Worth deciding whether the id should be the existing
internal database id (simple, but leaks a little implementation detail and a rough count of
someone's inventory) or a separate opaque id generated for exactly this purpose (see the
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.
- *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
to, so it needs to open directly to the right frontend, resolve the right backend, and land on
the right item. That means it has to encode the same information (owner handle + item id) as a
full URL, e.g. `https://toolshed.webdomain.tld/i/alice@example.com/42`, note the owner's handle
can be embedded in a path segment as-is (`@` doesn't need escaping in a URL path), which keeps it
one segment shorter than splitting the handle back into `domain/user`, and means the handle is
visible unmodified inside the link rather than reassembled from separate parts. If the owner is
ever a group rather than a user (see groups.md), its handle carries a leading `#`, which does need
the `+`-for-`#` substitution described in [handles-and-shortids.md](../handles-and-shortids.md)'s
Handle syntax section before it can sit in a path segment, e.g.
`https://toolshed.webdomain.tld/i/+climbing@example.com/42`. The frontend host
in this URL (`toolshed.webdomain.tld`) doesn't have to be, and generally won't be, the backend
authoritative for `example.com`, any frontend can resolve any handle (see federation.md's Servers
subsection), so this is just whichever frontend happens to be handling the link, not part of the
item's identity.
Both formats should stay as short as the encoded information allows, this matters most for the URL
form, since it's the one that ends up in a QR code or printed label where physical size is a real
constraint (see the labels goal above).
**How does this fit with the frontend's existing routes?**
There's already a `/inventory/shared/:user/:id` route (`InventoryDetailForeign`), but today
`:user` is just a bare username with no domain, i.e. it only works for a friend on the viewer's own
domain, and the view itself doesn't yet do anything domain-aware with that param. A resolvable
global handle needs the full `user@domain.tld` and a lookup step this route doesn't have yet. Two
ways to reconcile that: extend the existing route to take a full handle in the `:user` segment
(`/inventory/shared/alice@example.com/42`, no new route shape needed, just a richer meaning for the
param it already has), or treat the short `/i/...` URL as a dedicated, minimal entry point whose
only job is to resolve a handle and then hand off into whatever the richer in-app view ends up
being. The two aren't mutually exclusive: the short form is what needs to be small enough to print,
the in-app route doesn't have the same constraint and can stay more descriptive.
**What does scanning a label actually do?**
Probably: the label encodes a URL or handle-like string; scanning it opens the frontend, which
resolves the owner's domain the same way it resolves any other handle (see federation.md), and
lands on that item. This reuses the discovery mechanism that already exists for logging in as a
handle, rather than inventing a second one.
**Does resolving a label require authorization?**
An item's availability policy already controls who can see it (owner-only if private, friends if
shared, etc.). A label should presumably respect the same policy rather than being a backdoor that
makes a private item visible to literally anyone who finds the physical object and scans its code.
That means resolving a label isn't a free public lookup, it goes through the same friend/signature
checks as everything else, which has UX implications (an anonymous finder of a lost tool can't
necessarily see who it belongs to).
**Does the label need to be opaque/unguessable?**
If item ids are small sequential integers, a label built from a guessable id lets anyone enumerate
a user's items by scanning or guessing nearby numbers, even if each individual lookup is
authorization-checked. Probably wants some amount of unguessability even before authorization is
considered, as a defense-in-depth measure.
**What survives item changes?**
Storage location, availability policy, name, and description can all change over the life of an
object without it becoming a "different" item. The label should point at the handle, not at any of
that mutable data, so none of those changes require a new label. The one thing that probably *does*
need a decision is deletion: does a handle ever get reused, or is it retired for good once an item
is deleted (retiring seems safer, avoids an old label resolving to an unrelated new item later)?
**Relationship to lending/borrowing.**
A scannable label is the obvious hook for a future "mark as borrowed / returned" flow. Not solving
that now, but the handle scheme chosen here should be able to carry that later without a redesign,
i.e. it should be able to identify the item independent of who currently physically has it.

View file

@ -0,0 +1,252 @@
# Tags, Properties & Categories (Design in Progress)
Status: partially implemented (the handle scheme and strict resolution described in federation.md
exist today); this document is about the rough edges and open questions in that design, not a
proposal to build it from scratch.
## Problem
Tags, properties, and categories are handles of the form `origin#type:name` (see federation.md's
"Unique Handles" section), and a reference to a handle a server doesn't recognize is left
unresolved rather than guessed at or merged. That strictness is deliberate and avoids silent data
corruption, but it pushes a real cost onto users and creates some open problems:
- **Discoverability.** There's no way today to search or browse "what origins/taxonomies already
exist" before creating a new tag. In practice this likely means people invent their own local
tags for things a shared, canonical origin already covers, simply because they didn't know it
existed.
- **Fragmentation.** Because uniqueness is scoped per origin, nothing stops semantically identical
concepts from existing under different names or different origins at once (`drill` vs.
`power-drill`, one server's `origin:mytools` vs. another's `origin:community-tools`). Nothing
reconciles these; they just coexist.
- **No aliasing/synonyms.** If a user starts out with a local tag and later wants to adopt a shared
origin's equivalent, there's no supported way to merge or alias the two; existing items keep
pointing at the old handle.
- **Trust and quality of shared origins.** An origin is "whatever the classification is considered
to have come from," which could be an imported reference dataset. Nothing in the current design
addresses who maintains such a dataset, how it's kept correct, or what happens when it changes
(a category gets renamed or split upstream, e.g.).
- **Localization.** A tag/category name is a single string. There's no notion of the same concept
having a different display name per language while still resolving to the same handle.
- **Property values aren't typed.** A property's value on an item is a plain string. A property
definition can carry a unit (`unit_symbol`/`unit_name`), but nothing declares that its values are
actually numbers meant to be read in that unit, versus, say, free text that happens to mention a
unit. This is already a problem for one server in isolation (see the worked example below), and
federation doesn't create it, it just means it now has to be solved consistently across origins
instead of once.
- **Handle collisions.** `origin` is a free-text string with nothing enforcing that it's actually
unique to one definition. Fragmentation (above) is two different strings for the same thing;
this is the more dangerous mirror image, the same handle string ending up attached to two
different definitions, which is exactly the scenario the strict-resolution design was supposed
to make impossible (see the second worked example below).
### Worked example: filtering by a numeric range across origins
Say two independently-run servers both end up with a property called "voltage," each under its own
origin: `git:base#property:voltage` and `some-other-origin#property:voltage`. A user who's friends
with people on both wants to filter their combined view of shared items by, say, `0 < voltage <
4`. Two separate problems stack up:
1. **Are these the same property?** Per the fragmentation problem above, the two handles are, and
stay, unrelated as far as the system is concerned, same name, different origin, no connection.
A range filter built against one handle simply won't match items tagged with the other, even
though a person looking at both would probably call them "the same thing."
2. **Even if they were recognized as the same thing, are the values comparable?** A range filter
needs actual numbers in a known unit. If one server's items store `"3.7"` and the other's store
`"3700"` (volts vs. millivolts), a numeric comparison across the two silently produces nonsense
unless the unit is known and converted. If either side stores the value as loosely-formatted
text (`"3.7V"`, `"~3.7"`) rather than a bare number, it may not be reliably parseable as a
number at all.
So a cross-origin range filter needs both an aliasing/equivalence answer (are `voltage` and
`voltage` the same concept) and a units/typing answer (are their values actually numbers, in units
that convert cleanly into each other), and the first being solved doesn't imply the second is.
### Worked example: the same handle meaning two different things
Say `git:ee2` names a specific reference dataset that started life in one shared git history, and
two servers each imported it, at different times, from what has since become two diverging
branches (or forks) of that history. Both servers now have a property whose handle is the exact
same string, `git:ee2#property:charging_voltage`, but whose actual definition, say, unit, or
dimensions has since diverged between the two branches. Neither server did anything wrong; each
one faithfully imported "`git:ee2`" as it existed at the time.
This is a materially worse problem than the fragmentation/voltage example above. Fragmentation is
a missed opportunity, two things that should be linked aren't, and the failure is visible (the
filter just doesn't match as much as a person would expect). A handle collision is silent: nothing
about the two servers exchanging data suggests anything is wrong, both sides say
`git:ee2#property:charging_voltage`, so anything that trusts equal-handle-means-equal-definition
(exactly what the strict-resolution design promises, and exactly what a filter, an alias, or a
plain item-detail display would rely on) can silently combine or display incompatible values as if
they were the same thing. This is the specific failure mode the whole handle design exists to
prevent, so a scheme where it can still happen is a real gap, not just an inconvenience.
The root cause is that `origin` is a free-text label describing where something came from, not an
identifier that's actually bound to a specific, fixed piece of content. A name like `git:ee2` reads
as if it points to something immutable, but nothing about the origin field enforces that, `ee2`
could easily be a branch or tag name rather than a specific commit, i.e. a pointer that can keep
moving, and two importers pinned to it at different times without ever taking on a different
handle to show for it.
This isn't hypothetical, it's exactly how the reference data included in this repository already
works. The files in `backend/shared_data/` (`base.json`, `ee.json`, etc.) are origin datasets:
`configure.py`'s import step sets `origin = "git:" + filename` for everything a file defines
(`configure.py:119`), and a file can declare a fixed dependency on another one by name, e.g.
`ee.json`'s `"depends": ["git:base"]`. The intent is clearly that these files are immutable once
committed, and that a `depends` entry is a pin to a specific, settled parent, not a moving target,
but nothing in the code enforces that today, it's a convention people are expected to follow.
There's already a piece of the machinery needed to enforce it, though: import already computes a
sha256 of each file's raw content and stores it (`configure.py:125`, saved onto
`ImportedIdentifierSets.hash`, which is `unique=True` alongside `name`, `hostadmin/models.py:15`).
It's just not used for the thing it would be useful for, the "already imported, skipping" check
(`configure.py:129-130`) matches on `name` alone, it never recomputes the hash of the file being
imported and compares it against the hash already on record for that name. So even on a single
server, an edited `ee.json` re-imported under its old filename wouldn't be noticed as a change,
let alone flagged as a conflict, and the hash never leaves that server's own bookkeeping to be
compared against what a friend server has on record for the same name.
## Goals
- Make it easy to find and reuse an existing origin/handle before inventing a new local one, to
reduce fragmentation without weakening the strict-resolution guarantee that already exists.
- Give users a path to move a locally-invented tag onto a shared origin later, without losing or
having to manually re-tag their existing items.
- Keep the core guarantee intact: a handle always means one specific, traceable thing, nothing
should be implicitly merged or reinterpreted across origins.
- Make that guarantee actually hold, not just assumed: two servers that both use a given handle
should either really mean the same definition, or have some way to find out they don't, rather
than the collision staying silent.
## Non-goals (for now)
- Building a moderation/governance system for shared origins. Worth thinking about, but a bigger
problem than this document is trying to scope.
- Free-text/fuzzy tag matching in search. Search UX can layer on top of resolved handles without
changing what a handle means.
## Open design ideas
**A small set of well-known, shipped origins.**
Toolshed could ship with one or a few canonical origins covering common tool/inventory categories
out of the box, so that a fresh server already has a sensible baseline vocabulary to reuse instead
of every server reinventing "drill," "screwdriver," "power tools," etc. independently. Doesn't
solve fragmentation for everything, but raises the floor.
**Explicit aliasing rather than merging.**
Rather than trying to detect and merge "equivalent" tags automatically (risky, exactly the kind of
implicit behavior the strict-resolution design intentionally avoids), a tag could carry an explicit,
user-initiated "supersedes"/"alias of" pointer to another handle. Items already tagged with the old
handle could then be offered a one-time, explicit re-tag rather than a silent change of meaning.
For properties specifically, an alias needs to claim more than "these mean the same thing," it
needs to claim the values are comparable, which means recording a unit conversion (possibly just
"identical unit, factor 1") alongside the alias, not just a bare pointer. An alias with no stated
conversion should probably be treated as "same concept, values not (yet) comparable," a range
filter has no business guessing a conversion on its own.
**Filters default to per-handle, and only widen on an explicit alias.**
Following directly from the strict-resolution philosophy in federation.md: a range filter should
only ever combine two distinct property handles into one filterable facet because of an explicit
alias (see above) that also states the values are comparable, never because their names or units
happen to match. Absent that, two same-named properties from different origins should just show up
as two separate filters, visibly distinct, rather than the UI silently guessing they're the same
and producing a filter result that mixes incomparable values.
**Typed property values.**
Giving a property definition a declared value type (number, text, boolean, ...) in addition to its
existing unit metadata would let both the frontend and the alias/conversion mechanism above know
whether "range filter" even applies to a given property, and would close the gap where a value
happens to look numeric but isn't guaranteed to parse as one. This is useful even without
federation in the picture, cross-origin comparison just makes the gap load-bearing instead of
cosmetic.
**Origin metadata/versioning.**
If an origin represents an imported dataset, giving it its own version or changelog would let a
server know when the upstream taxonomy it imported has moved on, and decide explicitly whether to
re-import, rather than silently drifting from what other servers using the "same" origin now have.
This only helps if everyone's still on one shared timeline, though, it doesn't by itself address
diverging forks/branches ending up with the same name (see below).
**Pin origins to immutable content, not movable names.**
The `git:ee2` collision happens because the origin string names something mutable (a branch/tag)
rather than something fixed. If an origin string were derived from the content itself, e.g. a hash
of the definition, or a specific immutable commit rather than a branch, two independent imports
could never end up with the same string unless the content was actually identical at that point,
collisions would become structurally impossible rather than just unlikely. This is a bigger change
than the versioning idea above: it's not tracking change over time, it's making the identifier
itself incapable of silently referring to different things.
A concrete version of this: use the git blob hash of the file a property/tag/category was defined
in as (part of) its origin. If the reference dataset already lives in a git repo, this is free,
git's already computed it, and it's independently checkable, any server holding or able to fetch
the same repo can recompute the hash from the content and confirm for itself, rather than trusting
a label. This is a real improvement over free-text `origin` strings, but it isn't a free lunch:
- *Granularity.* A blob hash identifies a whole file, not a single property. If a file defines
several properties together, editing any one of them changes every other property's "identity"
in the same file too, even though nothing about them changed. Either definitions need to be
one-per-file for the hash to mean what's intended, or the hash needs to cover just the relevant
entry rather than the literal git blob.
- *It converts every edit into a fork.* Since any change, including a typo fix, changes the hash,
a routine upstream correction mechanically fragments what's still the same property into two
handles. That's consistent with "never silently reinterpret a handle," but it means the aliasing
mechanism above stops being a nice-to-have and becomes the primary upgrade path, every legitimate
edit needs an explicit "supersedes" link, or old items are stranded on a stale, now-orphaned hash.
- *A hash alone is an identity, not a location or a label.* It proves two things are the same (or
aren't), but doesn't say where to fetch the content from if you don't already have it, and isn't
human-readable. Pairing it with a location (which repo) and a mnemonic (which release/name it
corresponds to) alongside the hash keeps the discoverability goal intact instead of trading it
away for collision-proofing.
**Detect collisions on contact, as a backstop.**
Even with better-behaved identifiers going forward, existing data and human-typed origin strings
mean collisions can't be ruled out entirely. Whenever two servers interact over a handle they both
claim to know (e.g. as part of resolving an alias, or federated search), comparing a fingerprint of
the full definition, not just the handle string, would let a mismatch surface as an explicit
conflict to resolve, rather than being silently trusted. This is the same instinct as the "explicit
alias must state whether things are comparable" idea above, applied in the opposite direction, here
the handles already match and the system needs to actively check whether that trust is warranted.
The needed ingredient already exists locally and just isn't being used this way: the sha256 hash
already computed and stored per import (see above) is exactly the kind of definition fingerprint
this needs. Two changes would make it actually do the job: first, comparing it on every import
(including a "re-import" of a name already on record), not just recording it once, so a locally
edited file gets caught before it's ever presented to anyone else, and second, exchanging it as
part of whatever federated interaction references a shared-origin handle, so two servers can
compare hashes for the same name and find out they've diverged instead of assuming they haven't.
**What this means for the handle actually on the wire.**
Putting the above together, the everyday handle shouldn't change shape at all. It stays
`origin#type:name`, e.g. `git:ee2#property:charging_voltage`, exactly as it is today. The reason is
redundancy: this string is what appears on every single reference (every item's tag list, every
property assignment), potentially many times per item across many items, while a hash only ever
needs to be known once per origin. Carrying a full hash on every occurrence would repeat the same
value over and over for no benefit beyond what knowing it once already provides.
Instead, the hash stays where it already lives, attached to the origin as a whole (extending
`ImportedIdentifierSets`, see above), and gets exchanged at the points where two servers actually
need to agree on one, e.g. the first time a friend's item references an origin a server doesn't
already have a hash on record for. First contact just records it, same as resolving any unfamiliar
handle today; a later mismatch against what's on record is the collision, and that's the point
where it needs to become visible rather than silently trusted.
Only once a collision has actually been found does the wire format need to say more than
`origin#type:name`, because at that point there genuinely are two different things sharing a name
and something has to distinguish them for a person sorting it out. A short, abbreviated hash
appended to the origin, the same idea git itself relies on for short commit hashes, keeps that
escape hatch usable: `git:ee2~0f3a9c1e#property:charging_voltage` versus
`git:ee2~7bc82a04#property:charging_voltage`. This longer form is exception-path plumbing for
resolving an already-detected conflict, not something that changes the size or shape of handles in
the common case.
**Search across known origins.**
Before creating a new tag/category, a creation flow could search across origins the local server
already knows about (its own, plus any it's imported) and surface likely existing matches. This is
a UX/workflow fix rather than a change to the handle model itself, it doesn't need to touch
resolution semantics at all.
**Display name vs. handle.**
Separating "the name that appears in the handle" (stable, part of the identity) from "the label
shown to a user" (translatable, cosmetic) would allow localization without affecting resolution or
uniqueness, since resolution would stay keyed on the handle, not the display string.