From 796094f61b245d0e93122ad23f01f0bf4e7f6aee Mon Sep 17 00:00:00 2001 From: "Foluso ADEBISI (DevBox Aux)" Date: Thu, 1 Oct 2026 12:37:25 +0100 Subject: [PATCH] =?UTF-8?q?feat(cropper):=20save=20with=20the=20session=20?= =?UTF-8?q?token=20=E2=80=94=20no=20OTP,=20domain=20from=20a2loft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: -> 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. --- .../changes/cropper-token-auth/.openspec.yaml | 2 + openspec/changes/cropper-token-auth/design.md | 51 +++++++++++++++ .../changes/cropper-token-auth/proposal.md | 44 +++++++++++++ .../specs/image-cropper/spec.md | 37 +++++++++++ openspec/changes/cropper-token-auth/tasks.md | 15 +++++ tools/cropper.js | 62 ++++++++++-------- tools/public/assets/cropper/app.js | 7 +- tools/public/assets/cropper/index.html | 14 ++-- tools/test/cropper.test.js | 64 +++++++++---------- 9 files changed, 229 insertions(+), 67 deletions(-) create mode 100644 openspec/changes/cropper-token-auth/.openspec.yaml create mode 100644 openspec/changes/cropper-token-auth/design.md create mode 100644 openspec/changes/cropper-token-auth/proposal.md create mode 100644 openspec/changes/cropper-token-auth/specs/image-cropper/spec.md create mode 100644 openspec/changes/cropper-token-auth/tasks.md diff --git a/openspec/changes/cropper-token-auth/.openspec.yaml b/openspec/changes/cropper-token-auth/.openspec.yaml new file mode 100644 index 0000000..480ad4d --- /dev/null +++ b/openspec/changes/cropper-token-auth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-01 diff --git a/openspec/changes/cropper-token-auth/design.md b/openspec/changes/cropper-token-auth/design.md new file mode 100644 index 0000000..45baf06 --- /dev/null +++ b/openspec/changes/cropper-token-auth/design.md @@ -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 `, identity from +the token's phone, authorized with OpenFGA `crop_image` on `domain:` 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. diff --git a/openspec/changes/cropper-token-auth/proposal.md b/openspec/changes/cropper-token-auth/proposal.md new file mode 100644 index 0000000..d6bf870 --- /dev/null +++ b/openspec/changes/cropper-token-auth/proposal.md @@ -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. diff --git a/openspec/changes/cropper-token-auth/specs/image-cropper/spec.md b/openspec/changes/cropper-token-auth/specs/image-cropper/spec.md new file mode 100644 index 0000000..5f13c4e --- /dev/null +++ b/openspec/changes/cropper-token-auth/specs/image-cropper/spec.md @@ -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 diff --git a/openspec/changes/cropper-token-auth/tasks.md b/openspec/changes/cropper-token-auth/tasks.md new file mode 100644 index 0000000..fb55bdd --- /dev/null +++ b/openspec/changes/cropper-token-auth/tasks.md @@ -0,0 +1,15 @@ +# Tasks + +- [x] 1. Save handler: `Authorization: Bearer `, 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. diff --git a/tools/cropper.js b/tools/cropper.js index 61d1cd2..7e9c9b7 100644 --- a/tools/cropper.js +++ b/tools/cropper.js @@ -13,17 +13,11 @@ const require = createRequire(import.meta.url); const QUEUE_NAME = 'id-images'; /** - * Queue domains that differ from their a2loft identity code. - * - * A queue item's `domain` comes from the Telegram intake: the operator tags a - * photo (e.g. `#core`) and the bot stores that label verbatim, then translated - * it before every a2loft call. a2loft has no `core` domain — `GET - * {a2loft}/identity/core/units` answers "Invalid domain code specified." while - * `uc` is valid — so the label must be mapped before the save is forwarded. - * (The proper fix is on the intake side: enqueue the a2loft code, or register - * `core` upstream; until then this keeps existing queue data working.) + * Domains are forwarded as the picture record states them. The queue's label + * (e.g. `core` from the Telegram intake) and the profile's identity domain + * (`domainCode`, e.g. `ucth`) are both legitimate values there: a2loft + * authorizes the actor with OpenFGA, not with the domain's uploader list. */ -const DOMAIN_ALIASES = { core: 'uc' }; const BUCKET = 'files'; const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg'; @@ -138,6 +132,26 @@ export function createCropperRouter(cfg) { const UPSTREAM_TIMEOUT_MS = 15000; const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`; + + /** + * The profile's identity domain as a2loft records it (`domainCode`), falling + * back to the queue item's own label. Used for the save and for display. + */ + const resolvePictureDomain = async (uploadId, fallback) => { + if (!uploadId) return fallback; + try { + const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(uploadId)}`, { + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + const body = await r.json().catch(() => null); + return (body && body.ok && body.data && body.data.domainCode) || fallback; + } catch { + return fallback; + } + }; + + /** Path-safety only: a2loft decides which codes exist or are authorized. */ + const SAFE_DOMAIN = /^[A-Za-z0-9_-]+$/; const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`; const getMessage = () => @@ -153,14 +167,6 @@ export function createCropperRouter(cfg) { ) ); - const requireOtp = (req, res) => { - if (!req.query.otp) { - res.status(400).json({ ok: false, message: 'OTP is required.' }); - return false; - } - return true; - }; - // ── pictures ──────────────────────────────────────────────────────────── router.get('/picture/next', async (req, res) => { let message; @@ -178,6 +184,7 @@ export function createCropperRouter(cfg) { } catch { return res.json({ ok: false, message: 'Queue item is malformed.' }); } + data.domainCode = await resolvePictureDomain(data.upload_id, null); const { messageId, popReceipt, dequeueCount } = message; res.json({ ok: true, @@ -193,14 +200,14 @@ export function createCropperRouter(cfg) { const body = await r.json().catch(() => null); const data = body && body.data; if (!r.ok || !data) return res.json({ ok: false, message: 'No upload with the specified id.' }); - const { upload_id, file_name, caption, domain, raw_file_id } = data; + const { upload_id, file_name, caption, domain, raw_file_id, domainCode } = data; return res.json({ ok: true, data: { message_id: 'custom', pop_receipt: 'custom', dequeue_count: 0, - message: { upload_id, file_name, caption, domain, raw_file_id, from_queue: false }, + message: { upload_id, file_name, caption, domain, domainCode, raw_file_id, from_queue: false }, }, }); } catch (error) { @@ -331,7 +338,6 @@ export function createCropperRouter(cfg) { // ── save (the one a2loft call that needs the operator OTP) ────────────── router.post('/profile/picture', async (req, res) => { if (!requireMessageBody(req, res)) return; - if (!requireOtp(req, res)) return; let live; try { live = await cropPermission({ @@ -360,18 +366,22 @@ export function createCropperRouter(cfg) { message: { upload_id, domain, agentDeviceHash } = {}, }, } = req.body; - const urlDomain = DOMAIN_ALIASES[domain] || domain; - if (!domainCodes.has(urlDomain)) { + // The profile's own domain comes from a2loft's record, not the client. + const urlDomain = await resolvePictureDomain(upload_id, domain); + if (!SAFE_DOMAIN.test(urlDomain || '')) { return res.status(400).json({ ok: false, message: 'Unknown domain.' }); } - const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp }); try { const r = await fetchImpl( - `${base}/${encodeURIComponent(urlDomain)}/profile/picture?${q.toString()}`, + `${base}/${encodeURIComponent(urlDomain)}/profile/picture`, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + // the session token proves the operator; a2loft authorizes with OpenFGA + authorization: `Bearer ${req.session.token}`, + }, body: JSON.stringify({ pictureDataUrl, phone, diff --git a/tools/public/assets/cropper/app.js b/tools/public/assets/cropper/app.js index 3b0f14a..da551b5 100644 --- a/tools/public/assets/cropper/app.js +++ b/tools/public/assets/cropper/app.js @@ -23,7 +23,6 @@ Vue.config.ignoredElements = [/^cds-/]; accepted: false, cleanedImage: null, requiresCleaning: '', - otp: '', reviewOptions: [ 'IMAGE: Not in the midline of the photo frame (aligned to the left or right)', 'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)', @@ -59,7 +58,8 @@ Vue.config.ignoredElements = [/^cds-/]; return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0; }, canSave: function () { - return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== ''; + // the session token authenticates the save; no OTP is involved + return !this.busy.save && this.requiresCleaning !== ''; }, otherOptionSelected: function () { return this.selectedReviewOptions.indexOf('Others not specified.') >= 0; @@ -124,7 +124,6 @@ Vue.config.ignoredElements = [/^cds-/]; this.selectedReviewOptions = []; this.otherReviewOption = ''; this.requiresCleaning = ''; - this.otp = ''; }, loadPicture: async function (mode) { @@ -268,7 +267,7 @@ Vue.config.ignoredElements = [/^cds-/]; this.busy.save = true; try { var { data } = await axios.post( - '/cropper/profile/picture?otp=' + encodeURIComponent(this.otp), + '/cropper/profile/picture', { pictureDataUrl: this.cleanedImage || this.croppaInstance.generateDataUrl('image/jpeg'), phone: this.message.message.caption, diff --git a/tools/public/assets/cropper/index.html b/tools/public/assets/cropper/index.html index 52b71e9..939bd21 100644 --- a/tools/public/assets/cropper/index.html +++ b/tools/public/assets/cropper/index.html @@ -69,7 +69,15 @@
Phone
{{ message.message.caption || '—' }}
-
Domain
{{ message.message.domain }}
+
+
Domain
+
+ {{ message.message.domainCode || message.message.domain }} + + · picture target {{ message.message.domain }} + +
+
Agent
{{ agent.name }} {{ agent.phone }}
Process
@@ -119,10 +127,6 @@
-
- - -
Crop and upload picture Saving… diff --git a/tools/test/cropper.test.js b/tools/test/cropper.test.js index ab10cca..15db192 100644 --- a/tools/test/cropper.test.js +++ b/tools/test/cropper.test.js @@ -56,19 +56,27 @@ async function startFakeA2loft() { let raw = ''; req.on('data', (c) => (raw += c)); req.on('end', () => { - calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null }); + calls.push({ + method: req.method, + url: req.url, + authorization: req.headers.authorization, + body: raw ? JSON.parse(raw) : null, + }); const json = (body) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); }; if (req.url.startsWith('/identity/dims/picture/')) { + // 'no-record' lets a test exercise the fallback to the queue's own label + if (req.url.includes('no-record')) return json({ ok: false, message: 'No upload.' }); return json({ ok: true, data: { upload_id: 'up-1', file_name: 'id-domains/ucth/raw-pictures/x/img.jpg', caption: '08068287222', - domain: 'ucth', + domain: 'core', + domainCode: 'ucth', raw_file_id: 'raw-1', }, }); @@ -241,6 +249,7 @@ test('crop gate: permitted operator loads the page; denied operator does not', a // notifications must use a component the bundle actually defines assert.match(html, /cds-inline-notification/); assert.ok(!/cds-toast-item/.test(html), 'cds-toast-item is not a registered element'); + assert.ok(!/otpSave|otpLookup/.test(html), 'the cropper has no OTP fields'); assert.match(html, /cds-side-nav-link href="\/cropper" aria-current="page"/); } finally { await ok.stop(); @@ -336,7 +345,7 @@ test('save verifies the gate live and proxies to a2loft with session identity', }, }, }; - const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, { + const res = await fetch(`${base}/cropper/profile/picture`, { method: 'POST', headers: { cookie: cookies, 'content-type': 'application/json' }, body: JSON.stringify(payload), @@ -348,9 +357,11 @@ test('save verifies the gate live and proxies to a2loft with session identity', const save = a2loft.calls.find((c) => c.url.includes('/profile/picture')); assert.ok(save, 'a2loft save contacted'); const u = new URL(save.url, 'http://x'); + // the profile's own domain comes from a2loft's record ('ucth'), not the + // queue label the browser echoed ('ucth' here) — and no OTP is involved assert.equal(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture'); - assert.equal(u.searchParams.get('phone'), '+2348068287222', 'session phone is the validating identity'); - assert.equal(u.searchParams.get('otp'), '778899'); + assert.equal(u.search, '', 'no phone/otp query parameters'); + assert.match(save.authorization, /^Bearer \S+/, 'the session token proves the operator'); assert.equal(save.body.phone, '08068287222', 'profile phone from the picture'); assert.equal(save.body.uploadId, 'up-1'); assert.ok(portal.state.verifyCalls > 0); @@ -359,7 +370,7 @@ test('save verifies the gate live and proxies to a2loft with session identity', } }); -test('save refuses without an OTP and without the crop permission', async () => { +test('save requires the live crop permission (no OTP involved)', async () => { const { base, a2loft, stop } = await boot({ cropAllowed: false }); try { const cookies = await login(base); @@ -372,14 +383,7 @@ test('save refuses without an OTP and without the crop permission', async () => message: { upload_id: 'up-1', domain: 'ucth', caption: '08068287222', file_url: null }, }, }; - const noOtp = await fetch(`${base}/cropper/profile/picture`, { - method: 'POST', - headers: { cookie: cookies, 'content-type': 'application/json' }, - body: JSON.stringify(payload), - }); - // permission denial dominates; 400 (missing OTP) or 403 both satisfy the spec - assert.ok([400, 403].includes(noOtp.status), `got ${noOtp.status}`); - const denied = await fetch(`${base}/cropper/profile/picture?otp=778899`, { + const denied = await fetch(`${base}/cropper/profile/picture`, { method: 'POST', headers: { cookie: cookies, 'content-type': 'application/json' }, body: JSON.stringify(payload), @@ -437,7 +441,7 @@ test('malformed request bodies are rejected, never hang the request', async () = const { base, a2loft, stop } = await boot(); try { const cookies = await login(base); - for (const path of ['/cropper/picture/review', '/cropper/dequeue', '/cropper/profile/picture?otp=1']) { + for (const path of ['/cropper/picture/review', '/cropper/dequeue', '/cropper/profile/picture']) { const res = await fetch(`${base}${path}`, { method: 'POST', headers: { cookie: cookies, 'content-type': 'application/json' }, @@ -467,11 +471,11 @@ test('a malformed queue item does not hang the request', async () => { } }); -test('save rejects an unknown domain without contacting a2loft', async () => { +test('save rejects an unsafe domain and contacts no one', async () => { const { base, a2loft, stop } = await boot(); try { const cookies = await login(base); - const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, { + const res = await fetch(`${base}/cropper/profile/picture`, { method: 'POST', headers: { cookie: cookies, 'content-type': 'application/json' }, body: JSON.stringify({ @@ -480,12 +484,14 @@ test('save rejects an unknown domain without contacting a2loft', async () => { message: { pop_receipt: 'custom', message_id: 'm-1', - message: { upload_id: 'up-1', domain: '../../admin', agentDeviceHash: 'a' }, + // a2loft has no record for this upload, so the queue's label is used + // and the path-safety check rejects it + message: { upload_id: 'no-record', domain: '../../admin', agentDeviceHash: 'a' }, }, }), }); assert.equal(res.status, 400); - assert.equal(a2loft.calls.length, 0, 'a2loft never contacted'); + assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0, 'no save attempted'); } finally { await stop(); } @@ -513,18 +519,11 @@ test('picture fetches reject object keys outside the id-domains prefix', async ( }); -test('a queue domain of core is saved against the uc code (legacy alias)', async () => { - const legacy = JSON.stringify({ - upload_id: 'up-core', - file_name: 'id-domains/uc/raw-pictures/x/img.jpg', - caption: '08068287222', - domain: 'core', - raw_file_id: 'raw-1', - }); - const { base, a2loft, stop } = await boot({ queue: fakeQueue(legacy) }); +test("the picture's own target is forwarded when a2loft has no record", async () => { + const { base, a2loft, stop } = await boot(); try { const cookies = await login(base); - const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, { + const res = await fetch(`${base}/cropper/profile/picture`, { method: 'POST', headers: { cookie: cookies, 'content-type': 'application/json' }, body: JSON.stringify({ @@ -532,16 +531,17 @@ test('a queue domain of core is saved against the uc code (legacy alias)', async phone: '08068287222', requiresCleaning: false, message: { - pop_receipt: 'pop-1', + pop_receipt: 'custom', message_id: 'm-1', - message: { upload_id: 'up-core', domain: 'core', agentDeviceHash: 'a' }, + message: { upload_id: 'no-record', domain: 'core', agentDeviceHash: 'a' }, }, }), }); const body = await res.json(); assert.equal(res.status, 200, JSON.stringify(body)); const save = a2loft.calls.find((c) => c.url.includes('/profile/picture')); - assert.equal(decodeURIComponent(new URL(save.url, 'http://x').pathname), '/identity/uc/profile/picture'); + // 'core' is a2loft's own pseudo-domain for a profile picture — no rewriting + assert.equal(decodeURIComponent(new URL(save.url, 'http://x').pathname), '/identity/core/profile/picture'); } finally { await stop(); }