diff --git a/openspec/changes/archive/2026-10-01-move-image-cropper/.openspec.yaml b/openspec/changes/archive/2026-10-01-move-image-cropper/.openspec.yaml new file mode 100644 index 0000000..1aca8b9 --- /dev/null +++ b/openspec/changes/archive/2026-10-01-move-image-cropper/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-30 diff --git a/openspec/changes/archive/2026-10-01-move-image-cropper/design.md b/openspec/changes/archive/2026-10-01-move-image-cropper/design.md new file mode 100644 index 0000000..5f9910c --- /dev/null +++ b/openspec/changes/archive/2026-10-01-move-image-cropper/design.md @@ -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. diff --git a/openspec/changes/archive/2026-10-01-move-image-cropper/proposal.md b/openspec/changes/archive/2026-10-01-move-image-cropper/proposal.md new file mode 100644 index 0000000..7522ea6 --- /dev/null +++ b/openspec/changes/archive/2026-10-01-move-image-cropper/proposal.md @@ -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`. diff --git a/openspec/changes/archive/2026-10-01-move-image-cropper/specs/image-cropper/spec.md b/openspec/changes/archive/2026-10-01-move-image-cropper/specs/image-cropper/spec.md new file mode 100644 index 0000000..acfb340 --- /dev/null +++ b/openspec/changes/archive/2026-10-01-move-image-cropper/specs/image-cropper/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-10-01-move-image-cropper/specs/tools-app/spec.md b/openspec/changes/archive/2026-10-01-move-image-cropper/specs/tools-app/spec.md new file mode 100644 index 0000000..df6b2f7 --- /dev/null +++ b/openspec/changes/archive/2026-10-01-move-image-cropper/specs/tools-app/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-10-01-move-image-cropper/tasks.md b/openspec/changes/archive/2026-10-01-move-image-cropper/tasks.md new file mode 100644 index 0000000..a2fb5e4 --- /dev/null +++ b/openspec/changes/archive/2026-10-01-move-image-cropper/tasks.md @@ -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 diff --git a/openspec/specs/image-cropper/spec.md b/openspec/specs/image-cropper/spec.md new file mode 100644 index 0000000..a764ed0 --- /dev/null +++ b/openspec/specs/image-cropper/spec.md @@ -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 diff --git a/openspec/specs/tools-app/spec.md b/openspec/specs/tools-app/spec.md index 1708309..17980ff 100644 --- a/openspec/specs/tools-app/spec.md +++ b/openspec/specs/tools-app/spec.md @@ -11,14 +11,12 @@ operator credentials directly to third parties. ### Requirement: Landing page links the YOUreka tools 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 -(`/fingerprint`). +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 - `https://smart-pxa.yrka.app` and a link to `/fingerprint` - + `/cropper` and a link to `/fingerprint` ### Requirement: YOUreka Auth login establishes a cookie session 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, @@ -121,9 +119,17 @@ session; the OTP SHALL NOT be accepted without one. ### 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. +`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 \ No newline at end of file