feat(cropper): save with the session token — no OTP, domain from a2loft
Consumes the a2loft cropper-token-auth change: the save now sends the
session's PocketBase token as a bearer credential and no phone/otp. The
backend authorizes with OpenFGA crop_image (domain:<code> -> domain:idid),
so an operator no longer needs super-user rights in the profile's domain —
which is what made saves fail with a blanket 'Invalid OTP specified.'.
- the save's domain comes from a2loft's own record of the picture
(dims/picture/{uploadId} -> domainCode), falling back to the queue label,
guarded by a path-safety pattern instead of the catalog check
- the 'core' -> 'uc' alias is deleted: 'core' is a2loft's own pseudo-domain
for a profile picture (special-cased there), so rewriting it changed the
attribution and the ID-card notification
- queue loads are enriched with the profile's domainCode; the panel shows
the profile's domain and, when it differs, the picture target
- the cropper's OTP field, state and parameters are gone; the fingerprint
wizard is untouched (its own OTP flow on its own endpoints)
- tests updated: bearer + no query auth, resolved domain wins, unsafe values
refused, no OTP field served (46 pass)
Requires the a2loft change deployed first.
This commit is contained in:
@@ -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,15 @@
|
||||
# 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).
|
||||
- [ ] 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.
|
||||
Reference in New Issue
Block a user