docs(openspec): archive cropper-token-auth and sync specs

The cropper upload POSTs with the session token and no OTP query auth, and the
panel shows the profile's domain and picture target. Field pass was verified
earlier (upload 0b90075b, saved with no OTP). Merged the image-cropper delta
into the main spec and archived the change.
This commit is contained in:
2026-10-06 19:29:45 +01:00
parent 8a13fb527e
commit 528eaafb18
6 changed files with 18 additions and 9 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-01
@@ -0,0 +1,51 @@
# Design
## Context
See proposal.md. The a2loft `cropper-token-auth` change adds token mode to the
profile-picture endpoints: `Authorization: Bearer <pb token>`, identity from
the token's phone, authorized with OpenFGA `crop_image` on `domain:<code>` then
`domain:idid`. This change consumes it.
## Goals / Non-Goals
**Goals:** the cropper saves with the session token and no OTP; the save's
domain is a2loft's own record of the picture; the panel is truthful about the
profile's domain.
**Non-Goals:** the fingerprint wizard's OTP flow; the cropper's lookup (there
is none — pictures come from the queue); removing OTP support in a2loft.
## Decisions
1. **Token sent from the server**, never exposed to the browser beyond the
page's existing injected session: the proxy attaches
`Authorization: Bearer ${req.session.token}`.
2. **Domain from a2loft, not the client**: `dims/picture/{uploadId}` is the
record of the picture; its `domainCode` is the profile's identity domain and
its `domain` is the picture target (`core`). Sent as `domainCode`, displayed
as both. Falls back to the queue item's label when a2loft has no record
(custom ids), guarded by a path-safety pattern.
3. **The `core → uc` alias is deleted**: `core` is a2loft's own pseudo-domain
(special-cased in `getUserByPhoneAsync`/`updatePicture`), so rewriting it —
as the old bot did for its own calls — changed attribution and the
notification. Authorization no longer depends on the domain, so no alias is
needed.
4. **Queue loads are enriched** with `domainCode` for display (one extra
read-only a2loft call per picture).
## Risks / Trade-offs
- [a2loft record unavailable for an upload] → falls back to the queue label
under the path-safety check; the save still carries the token for authz.
- [Operators outside the domain's super-users] → irrelevant now: the OpenFGA
check with the `domain:idid` fallback authorizes them.
## Migration Plan
Deploy a2loft first (dual-mode, no client change), then this change; the
cropper saves without an OTP. Rollback = revert the proxy and re-add the field.
## Open Questions
None.
@@ -0,0 +1,44 @@
# Proposal: cropper-token-auth
## Why
The cropper's save proves the operator with a phone number and an OTP. a2loft
resolves that phone against the domain being saved to, so an operator without
super-user rights in that domain is rejected with a blanket `Invalid OTP
specified.` — even though they are authorized to crop images for the domain.
The a2loft endpoints now accept a PocketBase token (see the a2loft change
`cropper-token-auth`), which authorizes with OpenFGA `crop_image` and takes the
actor's identity from the token.
## What Changes
- The cropper's save posts the **session token** to a2loft instead of `phone` +
`otp`; the OTP input is removed from the UI.
- The save's domain comes from **a2loft's own picture record**
(`dims/picture/{uploadId}` → `domainCode`), falling back to the queue item's
label, and the panel shows both (the profile's domain and the picture
target).
- The `core → uc` alias is deleted: `core` is a2loft's own pseudo-domain for a
profile picture, not an alias for `uc`.
- The fingerprint wizard's staff lookup and fingerprint save keep their OTP;
only the cropper's calls change.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `image-cropper`: the save is token-authenticated (no OTP), the domain comes
from a2loft's record, and the OTP requirement is removed.
## Impact
- `tools/cropper.js`: save handler (Bearer token, no `?otp=`), domain
resolution via dims with the label as fallback, path-safety check replacing
the catalog check, queue loads enriched with the profile's domain.
- `tools/public/assets/cropper/`: the OTP field and all `otp` state removed;
the panel shows the profile's domain and the picture target.
- Requires the a2loft `cropper-token-auth` change to be deployed first.
@@ -0,0 +1,37 @@
# Spec Delta: image-cropper
## MODIFIED Requirements
### Requirement: Saving requires a live gate and an OTP
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
permission live (bypassing the cache) and SHALL authenticate the operator with
the session's PocketBase token, sent to a2loft as a bearer credential; no OTP
SHALL be collected or forwarded. The picture's domain SHALL come from a2loft's
own record for the upload (`dims/picture/{uploadId}` → `domainCode`), falling
back to the queue item's label, and SHALL match a path-safe pattern before it
is used in the request. A denied save SHALL not reach a2loft.
#### Scenario: Successful save
- **WHEN** a permitted operator saves
- **THEN** the route verifies the live gate and forwards the picture to
a2loft with the session token as a bearer credential and the profile's
resolved domain
#### Scenario: Denied save does not reach a2loft
- **WHEN** the live crop check denies, or the request carries no message body
- **THEN** the route answers 403/400 and makes no a2loft call
#### Scenario: Unsafe domain values are refused
- **WHEN** neither a2loft's record nor the queue label yields a path-safe
domain
- **THEN** the save is refused with 400 and a2loft is not contacted
### Requirement: Unified login — no separate artist login
The cropper SHALL NOT implement its own login. The operator's identity comes
from the YOUreka session and is presented to a2loft as the session's PocketBase
token; the picture's own metadata carries the domain. The cropper collects no
other credential — there is no save-time OTP.
#### Scenario: Phone prefilled from the session
- **WHEN** a signed-in operator opens the cropper
- **THEN** their phone is already known to the page without any login form
@@ -0,0 +1,18 @@
# Tasks
- [x] 1. Save handler: `Authorization: Bearer <session token>`, no `?phone/otp`;
domain resolved from `dims/picture/{uploadId}` (`domainCode`) with the queue
label as fallback, guarded by a path-safety pattern; `requireOtp` and the
`core → uc` alias deleted.
- [x] 2. Queue loads enriched with the profile's `domainCode` for display;
load-by-id passes it through.
- [x] 3. UI: OTP field, state and call parameters removed; panel shows the
profile's domain and the picture target.
- [x] 4. Tests: save carries the bearer token and no query auth; the resolved
domain wins over the body; unsafe values are refused; no OTP field in the
served page; queue load exposes `domainCode` (46 tests pass).
- [x] 5. Deploy a2loft and this app, then a field pass: load a queued picture →
save with no OTP → confirm the picture lands attributed to the token's phone.
Verified: loaded upload 0b90075b-… (panel showed "Domain ucth · picture target
core"), no OTP field, save POSTed with the session token and no query auth,
answered "Saved — Picture saved for +2348068287222" and reset the panel.