docs(openspec): move-image-cropper change — specs synced, change archived

image-cropper capability added; tools-app landing + config requirements
updated (internal /cropper link, credentials-via-env carve-out).
This commit is contained in:
2026-10-01 01:13:52 +01:00
parent d689434047
commit 58b8b9c862
8 changed files with 389 additions and 6 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-30
@@ -0,0 +1,79 @@
# Design
## Context
`id-image-bot` is three things: a Telegram intake bot (handlers disabled in
code), a picture-review pipeline (Azure queue `id-images`, Minio `files`
bucket, nodered review/process endpoints), and the operator web tool ("Smart
Photo Editor": Vue 2 + vue-croppa, phone/OTP login against a2loft). Only the
web tool moves; the bot and its deployment stay. The a2loft save route
(`POST /:domainCode/profile/picture`) accepts only `?phone=&otp=` — no key
variant — so the operator OTP survives, but only at save time. See proposal.md
for motivation.
## Goals / Non-Goals
**Goals:**
- One login story: YOUreka session everywhere; the cropper adds only a
save-time OTP (a2loft's requirement).
- Uniform `crop_image` authorization over every proxied cropper endpoint,
cheap for the portal (read cache, live at save).
- Faithful behavior: same queue/review/nodered flows and review-reason list.
**Non-Goals:**
- Moving the Telegram bot; decommissioning `spxa.yrka.app`.
- Changing the review/reject workflow or the review-reason catalog.
- Replacing toastr/swal UX patterns wholesale.
## Decisions
1. **Namespaced router, `tools/cropper.js` with DI.** `createCropperRouter({
minio, queue, fetch, requireApi, cropGate, a2loftBase, ... })` — the same
injectable pattern as the app factory, so tests fake minio/queue/upstreams.
Mounted at `/cropper` in server.js; the UI uses relative request paths.
2. **Unified login.** The bot app's phone+OTP middleware validated every
request; here the YOUreka session replaces it, and the one a2loft call that
needs an OTP (picture save) takes an operator-entered OTP forwarded with the
session phone and the picture's domain. Phone prefill from `/auth/session`.
3. **`crop_image` gate with read cache.** `permit.js` grows `cropPermission(
phone, {force})` beside the domain checker: same verifyOnce plumbing,
`relation: 'crop_image'`, object `domain:idid`, no fallback; results cached
per phone for 5 minutes, save forces live. Router-level middleware covers
every cropper route uniformly.
4. **Credentials via env, URLs hardcoded.** `MINIO_ACCESS_KEY`,
`MINIO_SECRET_KEY`, `AZURE_STORAGE_CONNECTION_STRING`,
`NODERED_HTTP_USERNAME`, `NODERED_HTTP_PASSWORD`, `CARVEKIT_API_KEY` — same
names the bot app used, so an existing `.env` is drop-in. Clients are built
only when the env is present; without it the router answers 503 and the
rest of the app is unaffected.
5. **Restyle to the tools design language.** Same stylesheet and panel
structure as `/fingerprint` (navbar, step chips, badges, buttons); toastr
stays for toasts. No behavior change beyond namespacing and auth.
6. **Native `FormData`** (Node 22) replaces the `form-data` dependency for the
cleanup proxy.
## Risks / Trade-offs
- [a2loft `validateOtp` may not recognize the YOUreka session phone format] →
the fingerprint walkthrough already saw a2loft accept the session phone on
lookups; the field pass confirms on the save path before cutover.
- [Cached crop gate lets a revoked operator act for ≤5 minutes] → reads only;
the save verifies live, and a2loft's OTP check remains the hard gate.
- [Review/process endpoints depend on nodered availability] → unchanged from
the bot app; failures surface as route errors, not crashes.
- [Two auth domains (mnet/ucth) disappear from the login] → the save validates
against the picture's domain; the field pass exercises a real item.
## Migration Plan
1. Land the cropper in `tools/` while `id-image-bot` keeps running unchanged.
2. Field pass: login → gate → load a real queued item (or by ID) → crop →
clean → save with OTP; confirm the picture lands in a2loft.
3. Repoint the landing card (done in the same change); leave `spxa.yrka.app`
running until the team confirms the new home.
4. Rollback: the old deployment still serves the tool.
## Open Questions
None — auth unification, secret handling and scope were confirmed with the
operator.
@@ -0,0 +1,55 @@
# Proposal: move-image-cropper
## Why
The tools landing page sends operators to an external deployment for identity
photo cropping (`spxa.yrka.app` — the proposal's original text said
`smart-pxa.yrka.app`, which was wrong), with a separate phone/OTP login and no
shared session. Moving the cropper into the tools app unifies auth, deployment
and the UI, and puts its externally-proxied endpoints (background cleanup,
picture storage) behind the same authorization as the rest of the tools.
## What Changes
- **BREAKING**: Add the Image Cropper ("Smart Photo Editor") to the tools app
at `/cropper` — page + namespaced API router (`/cropper/picture/*`,
`/cropper/profile/picture`, `/cropper/dequeue`, `/cropper/api/*`) — replacing
the landing page's external link.
- **Unified login**: the cropper's phone/OTP "Artist Login" is removed. Identity
comes from the YOUreka session; a single OTP is entered only when saving to
a2loft (the only call a2loft requires it for).
- **Crop permission gate**: page load, picture loading, background cleanup and
saving each verify `crop_image` on `domain:idid` (subsystem `idid`) via the
portal OpenFGA endpoint — fail closed; reads may serve a 5-minute cached
result, saving always checks live.
- **Restyle**: the cropper UI adopts the tools design language (navbar, cards,
badges) instead of the old grey standalone page.
- **Credentials via environment**: Minio, Azure storage, nodered and Carvekit
credentials come from `process.env` (documented in `.env.example`) — the sole
exception to the hardcoded-configuration rule; URLs stay hardcoded.
- The Telegram intake bot stays in `id-image-bot` untouched; the old cropper
deployment remains until this one is field-proven.
## Capabilities
### New Capabilities
- `image-cropper`: the `/cropper` photo review/crop tool — permission gate,
picture loading, background cleanup, save to a2loft with OTP.
### Modified Capabilities
- `tools-app`: landing page links the cropper internally (`/cropper`), and the
hardcoded-configuration requirement gains a credentials-via-environment
carve-out.
## Impact
- New code: `tools/cropper.js` (DI router), `tools/public/assets/cropper/`,
permit.js generalization (`crop_image` check with cache).
- New dependencies: `minio`, `azure-storage`. Removed dependency risk: none
(`form-data` replaced by native `FormData`).
- New env vars: Minio keys, Azure storage connection string, nodered
credentials, Carvekit key (`.env.example` documents all).
- External systems unchanged: Azure queue `id-images`, Minio `files` bucket,
`nodered.a2loft.net`, `removebg.itcass.net`, `apis.a2loft.net`.
@@ -0,0 +1,95 @@
# Spec Delta: image-cropper
## Purpose
The Image Cropper at `/cropper`: operators review, crop, clean and save
identity photos submitted for YOUreka ID domain profiles, with every proxied
action authorized by the crop_image relation.
## ADDED Requirements
### Requirement: Crop permission gate
Access to the cropper SHALL require the `crop_image` relation on `domain:idid`
(subsystem `idid`) for the signed-in operator's phone, verified against the
portal OpenFGA endpoint and failing closed on denial or error. Read paths
(page load, picture loading, background cleanup, dequeue) MAY serve a cached
result for up to 5 minutes; saving SHALL always verify live.
#### Scenario: Permitted operator opens the cropper
- **WHEN** an operator with the crop_image relation requests `/cropper`
- **THEN** the editor page loads
#### Scenario: Non-permitted operator is kept out
- **WHEN** an operator without the crop_image relation requests the cropper
page
- **THEN** the page renders a not-permitted explanation with no editor UI
#### Scenario: Cached gate on reads
- **WHEN** a read action occurs within 5 minutes of the operator's last
permission check
- **THEN** the cached result is used without calling the portal
### Requirement: Picture loading is gated and namespaced
The cropper SHALL load review pictures only through its namespaced routes
(`/cropper/picture/next`, `/cropper/picture/load/:id`,
`/cropper/picture/:file_name`) behind the session and crop gates. A denied
route SHALL return 403 without contacting the queue, storage, or review
services.
#### Scenario: Next queued picture
- **WHEN** the operator requests the next picture
- **THEN** the route proxies the Azure queue and returns the queue message
#### Scenario: Denied picture load does not reach services
- **WHEN** a request without the crop_image relation loads a picture route
- **THEN** the route answers 403 and makes no upstream calls
### Requirement: Background cleanup is gated
The background-cleanup proxy (`/cropper/api/removebg`) SHALL require the
session and crop gate, forwarding the cropped image to the cleanup service
only for permitted operators.
#### Scenario: Denied cleanup does not reach the cleanup service
- **WHEN** a request without the crop_image relation calls the cleanup proxy
- **THEN** it answers 403 without contacting the cleanup service
#### Scenario: Cleanup proxies the cleaned image
- **WHEN** a permitted operator submits a cropped image for cleanup
- **THEN** the route forwards it to the cleanup service and returns the
cleaned image
### 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 require an OTP from the
operator, forwarding the session phone as the validating identity together
with the OTP and the picture's domain to a2loft. A denied or OTP-less save
SHALL not reach a2loft.
#### Scenario: Successful save
- **WHEN** a permitted operator saves with an OTP
- **THEN** the route verifies the live gate and forwards the picture to
a2loft with the session phone, OTP, and picture domain
#### Scenario: Denied save does not reach a2loft
- **WHEN** the live crop check denies, or the OTP is missing
- **THEN** the route answers 403/400 and makes no a2loft call
### Requirement: Unified login — no separate artist login
The cropper SHALL NOT implement its own login. The operator's identity comes
from the YOUreka session (phone prefilled), the picture's metadata carries the
domain, and the only credential the operator supplies is the 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
### Requirement: Cropper credentials come from the environment
The cropper's service credentials (Minio keys, Azure storage connection
string, nodered credentials, cleanup-service key) SHALL be read from
environment variables. When they are absent, the cropper routes SHALL answer
503 without attempting upstream calls; the application itself SHALL still
boot.
#### Scenario: Unconfigured deployment degrades gracefully
- **WHEN** the app starts without the cropper credentials
- **THEN** the other tools work and cropper routes answer 503
@@ -0,0 +1,30 @@
# Spec Delta: tools-app
## MODIFIED Requirements
### Requirement: Landing page links the YOUreka tools
The app SHALL serve a landing page at `/` containing links to the Image
Cropper (`/cropper`) and to the Fingerprint Agent page (`/fingerprint`).
#### Scenario: Landing page renders both tool links
- **WHEN** a user opens the app root URL
- **THEN** the page shows a link to the Image Cropper pointing at
`/cropper` and a link to `/fingerprint`
### Requirement: Configuration is hardcoded
The app SHALL hardcode all configuration in source: port `4173`, portal
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
`https://apis.a2loft.net`, and agent `http://127.0.0.1:9000`. There SHALL be
no runtime configuration via environment variables or config files, with one
exception: the Image Cropper's service credentials (Minio keys, Azure storage
connection string, nodered credentials, cleanup-service key) SHALL come from
environment variables and SHALL NOT be stored in source.
#### Scenario: No configuration required to run
- **WHEN** the app is started with no environment variables or config files
- **THEN** it serves on port 4173 with the hardcoded external endpoints
#### Scenario: Cropper credentials from the environment
- **WHEN** the cropper credentials are supplied as environment variables
- **THEN** the cropper routes use them without any change to the hardcoded
URL/port configuration
@@ -0,0 +1,21 @@
# Tasks
## 1. Permission plumbing
- [x] 1.1 Generalize `permit.js`: single-object `checkPermission` for an arbitrary relation, plus `cropPermission(phone, {force})` with the 5-minute per-phone cache; verify with `node --test` and stubbed fetch covering allow, deny, cache-hit (no portal call), expiry, and live-at-save
## 2. Cropper router
- [x] 2.1 Add `tools/cropper.js` — `createCropperRouter` with DI (minio, azure queue, fetch, nodered/removebg/a2loft config): router-level session + crop gate, picture next/load-by-id/image routes, review + review-agent routes, dequeue, removebg proxy (native FormData), save proxy (live gate + OTP + session phone + picture domain), baseUrl; 503 when credentials are absent; verify with `node --test` and fake clients/upstreams covering gate denials (403, no upstream calls), cache behavior, save proxying, and the unconfigured 503 path
## 3. UI
- [x] 3.1 Port the Smart Photo Editor to `tools/public/assets/cropper/` restyled to the tools design language (navbar, panels, badges): relative API paths, phone prefilled from `/auth/session`, no login step, OTP field at save, review-reason list and crop/clean flow preserved; landing card links `/cropper` (replacing the wrong external URL); verify in the browser that the page renders, gates apply, and the flow works against fakes
## 4. Wiring and docs
- [x] 4.1 Mount the router in `server.js` (env-gated client construction), add `.env.example` with the credential names, rebuild the Docker image, and document the cropper + env vars in the README; verify the app boots with no env (other tools fine, cropper 503) and with a fake env in Docker
## 5. Field verification
- [x] 5.1 Playwright field pass with a real item: YOUreka login → gate check → load queued picture (or by ID) → crop → clean → review → save with OTP; confirm the picture lands in a2loft and a wrong-OTP save fails cleanly
+95
View File
@@ -0,0 +1,95 @@
# image-cropper Specification
## Purpose
The Image Cropper at `/cropper`: operators review, crop, clean and save
identity photos submitted for YOUreka ID domain profiles, with every proxied
action authorized by the crop_image relation.
## Requirements
### Requirement: Crop permission gate
Access to the cropper SHALL require the `crop_image` relation on `domain:idid`
(subsystem `idid`) for the signed-in operator's phone, verified against the
portal OpenFGA endpoint and failing closed on denial or error. Read paths
(page load, picture loading, background cleanup, dequeue) MAY serve a cached
result for up to 5 minutes; saving SHALL always verify live.
#### Scenario: Permitted operator opens the cropper
- **WHEN** an operator with the crop_image relation requests `/cropper`
- **THEN** the editor page loads
#### Scenario: Non-permitted operator is kept out
- **WHEN** an operator without the crop_image relation requests the cropper
page
- **THEN** the page renders a not-permitted explanation with no editor UI
#### Scenario: Cached gate on reads
- **WHEN** a read action occurs within 5 minutes of the operator's last
permission check
- **THEN** the cached result is used without calling the portal
### Requirement: Picture loading is gated and namespaced
The cropper SHALL load review pictures only through its namespaced routes
(`/cropper/picture/next`, `/cropper/picture/load/:id`,
`/cropper/picture/:file_name`) behind the session and crop gates. A denied
route SHALL return 403 without contacting the queue, storage, or review
services.
#### Scenario: Next queued picture
- **WHEN** the operator requests the next picture
- **THEN** the route proxies the Azure queue and returns the queue message
#### Scenario: Denied picture load does not reach services
- **WHEN** a request without the crop_image relation loads a picture route
- **THEN** the route answers 403 and makes no upstream calls
### Requirement: Background cleanup is gated
The background-cleanup proxy (`/cropper/api/removebg`) SHALL require the
session and crop gate, forwarding the cropped image to the cleanup service
only for permitted operators.
#### Scenario: Denied cleanup does not reach the cleanup service
- **WHEN** a request without the crop_image relation calls the cleanup proxy
- **THEN** it answers 403 without contacting the cleanup service
#### Scenario: Cleanup proxies the cleaned image
- **WHEN** a permitted operator submits a cropped image for cleanup
- **THEN** the route forwards it to the cleanup service and returns the
cleaned image
### 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 require an OTP from the
operator, forwarding the session phone as the validating identity together
with the OTP and the picture's domain to a2loft. A denied or OTP-less save
SHALL not reach a2loft.
#### Scenario: Successful save
- **WHEN** a permitted operator saves with an OTP
- **THEN** the route verifies the live gate and forwards the picture to
a2loft with the session phone, OTP, and picture domain
#### Scenario: Denied save does not reach a2loft
- **WHEN** the live crop check denies, or the OTP is missing
- **THEN** the route answers 403/400 and makes no a2loft call
### Requirement: Unified login — no separate artist login
The cropper SHALL NOT implement its own login. The operator's identity comes
from the YOUreka session (phone prefilled), the picture's metadata carries the
domain, and the only credential the operator supplies is the 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
### Requirement: Cropper credentials come from the environment
The cropper's service credentials (Minio keys, Azure storage connection
string, nodered credentials, cleanup-service key) SHALL be read from
environment variables. When they are absent, the cropper routes SHALL answer
503 without attempting upstream calls; the application itself SHALL still
boot.
#### Scenario: Unconfigured deployment degrades gracefully
- **WHEN** the app starts without the cropper credentials
- **THEN** the other tools work and cropper routes answer 503
+12 -6
View File
@@ -11,14 +11,12 @@ operator credentials directly to third parties.
### Requirement: Landing page links the YOUreka tools ### Requirement: Landing page links the YOUreka tools
The app SHALL serve a landing page at `/` containing links to the Image The app SHALL serve a landing page at `/` containing links to the Image
Cropper (`https://smart-pxa.yrka.app`) and to the Fingerprint Agent page Cropper (`/cropper`) and to the Fingerprint Agent page (`/fingerprint`).
(`/fingerprint`).
#### Scenario: Landing page renders both tool links #### Scenario: Landing page renders both tool links
- **WHEN** a user opens the app root URL - **WHEN** a user opens the app root URL
- **THEN** the page shows a link to the Image Cropper pointing at - **THEN** the page shows a link to the Image Cropper pointing at
`https://smart-pxa.yrka.app` and a link to `/fingerprint` `/cropper` and a link to `/fingerprint`
### Requirement: YOUreka Auth login establishes a cookie session ### Requirement: YOUreka Auth login establishes a cookie session
The app SHALL authenticate operators through the YOUreka portal web-auth The app SHALL authenticate operators through the YOUreka portal web-auth
flow: create a web auth session at the portal with a local callback URL, flow: create a web auth session at the portal with a local callback URL,
@@ -121,9 +119,17 @@ session; the OTP SHALL NOT be accepted without one.
### Requirement: Configuration is hardcoded ### Requirement: Configuration is hardcoded
The app SHALL hardcode all configuration in source: port `4173`, portal The app SHALL hardcode all configuration in source: port `4173`, portal
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft `https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
`https://apis.a2loft.net`, and agent `http://127.0.0.1:9000`. There SHALL `https://apis.a2loft.net`, and agent `http://127.0.0.1:9000`. There SHALL be
be no runtime configuration via environment variables or config files. no runtime configuration via environment variables or config files, with one
exception: the Image Cropper's service credentials (Minio keys, Azure storage
connection string, nodered credentials, cleanup-service key) SHALL come from
environment variables and SHALL NOT be stored in source.
#### Scenario: No configuration required to run #### Scenario: No configuration required to run
- **WHEN** the app is started with no environment variables or config files - **WHEN** the app is started with no environment variables or config files
- **THEN** it serves on port 4173 with the hardcoded external endpoints - **THEN** it serves on port 4173 with the hardcoded external endpoints
#### Scenario: Cropper credentials from the environment
- **WHEN** the cropper credentials are supplied as environment variables
- **THEN** the cropper routes use them without any change to the hardcoded
URL/port configuration