# Verify: private attendance photos · spec 0004 · updated 2026-10-27
_Steps derived from spec 0004 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._

## Before a browser PUT
- [x] In the Backblaze console, set CORS on bucket `zap-staff-attendance-passport`: origins `*`; methods `GET`, `PUT`, `HEAD`; headers `content-type`, `authorization`, `x-amz-content-sha256`, `x-amz-date`, `x-amz-security-token`; max age 3600. Do not call `PutBucketCors` from the app. → AC-17
  - Ran 2026-10-27: a browser PUT from `https://design-changes.zap.ng` returned 200 and a browser GET through the signed URL loaded the image, so CORS is live for this origin. The console values themselves were not inspected.

## UI / manual
- [x] Sign in as admin, director, or backend (or staff with `$info['status']` admin). Open Menu. Staff attendance shows Locations, Settings, and Photos. Photos uses `staff_attendance_photos.php`, icon `icon/newstudent.png`, subtitle Private enrollment portraits. Open Photos. → AC-1, AC-2
  - Ran 2026-10-27: menu shows Locations, Settings, Photos. Photos tile uses `staff_attendance_photos.php`, `icon/newstudent.png` (served 200), subtitle Private enrollment portraits. No hub page exists.
- [x] Photos is Pattern C: overline Staff attendance, title Photos, school name from `$school_config['school_name']`, skeleton then AJAX list. Hero count equals `objects.length` on this page. Zero objects shows a prompt to capture the first portrait. → AC-3, AC-12
  - Ran 2026-10-27: overline Staff attendance, title Photos, school DEMO ACADEMY SECONDARY from config, skeleton then AJAX list. Stat read 200 on page one, 6 on page two, 0 when empty. Empty state read No portraits yet with the capture prompt.
- [x] Capture uses live video, oval overlay, and Start. `challenges` returns two distinct moves from `blink`, `mouth`, `turn_left`, `turn_right`. MediaPipe in `staff-attendance-liveness.js` runs look straight, those two moves, then look straight again. Each step is shown and spoken (`speechSynthesis` plus a short cue). Start unlocks audio. If speech is blocked the check still runs from the on-screen prompt. PHP does not prove the gesture and does not receive challenge frames. After the last move the page uses the final frame only. `getUserMedia` uses `{ video: { facingMode: 'user' } }`. Long side at most 720 px, JPEG quality 0.85 once. No file input. No consent copy. Preview is mirrored; captured frames are not. Timeout shows and speaks `Too slow, please try again.` Module load fail shows and speaks `The face check did not load. Try again.` → AC-4
  - Ran 2026-10-27: live video with the oval overlay, `autoplay muted playsinline`, mirrored preview, no file input, no consent copy. `challenges` returned two distinct moves on every call, also across 200 direct engine runs. Steps painted and spoken in order, and the timeout spoke and showed `Too slow, please try again.` A real browser camera was granted (HP Wide Vision HD Camera).
  - Skipped by you 2026-10-27: a person completing the two moves at a real camera. The stored portrait was read back at 480x640, so the 720 px cap and the 0.85 encode hold on a real capture.
- [x] Staff select lists `activation = 'activated'` rows as `last_name first_name` with no `staff_id` in the label, value `staff_id`. Mint of a missing, other school, or inactive `staff_id` returns HTTP 200 `{status:error, msg}`. → AC-5
  - Ran 2026-10-27: 29 activated staff, label `AMUGOS CHIOMAS`, option value `2`. Mint for `staff_id` 0 and 999999 returned HTTP 200 `That staff member is not available.`
- [x] Mint returns `put_url` for 120 seconds. Browser PUT uses `Content-Type: image/jpeg` only. PHP never receives the JPEG bytes. PUT HTTP 200 then confirm once. Any other PUT status retries the same `put_url` until `expires_in`, then `The photo did not upload.` Capture stays disabled from mint until confirm returns or PUT retries are exhausted. → AC-8, AC-4
  - Ran 2026-10-27: path style URL on `s3.eu-central-003.backblazeb2.com`, `X-Amz-Expires=120`, signed headers `content-type;host`, algorithm `AWS4-HMAC-SHA256`. Browser PUT returned 200 with `Content-Type: image/jpeg` only. No PHP route accepts the bytes.
- [x] After confirm, toast `Photo uploaded.` List reloads. View sets `img` src to a 300 second GET URL. Bytes do not stream through PHP. → AC-9, AC-10, AC-11
  - Ran 2026-10-27: confirm returned success, list reloaded, the sheet loaded the image from a `get_url` with `X-Amz-Expires=300` signed on host only.
- [x] Delete confirm wording is `This will remove the portrait from private storage.` Success toasts `Photo deleted.` and reloads list. HEAD 404 returns `That photo is missing.` and skips `DeleteObject`. → AC-11
  - Ran 2026-10-27: the confirm dialog read exactly the specced sentence, the toast read `Photo deleted.`, the list reloaded without the row, and delete of a never uploaded key returned `That photo is missing.` with nothing removed.
- [x] Confirm without PUT returns `That photo is missing.` A key from another `school_id` prefix returns `That photo is not in this school folder.` Camera deny shows `Camera access is blocked. Check the browser permission.` → AC-4, AC-9, AC-13
  - Ran 2026-10-27: all three returned exactly those messages. Also refused: school `213/` (no prefix match on `21/`), `..` traversal, and uppercase `.JPG`.
- [x] A teacher or other non AC-1 role that hits the Photos URL is sent to app home. Dead session POST returns HTTP 401 JSON `{status:error, msg:unauthorized}`. → AC-1, AC-16
  - Ran 2026-10-27: no cookie POST returned 401 `unauthorized`, and a plain page GET returned 302 to `/app/admin/`. `staff_attendance_can_access` returns false for teacher and student tables and true for admin, director, backend, and admin status.
  - Skipped by you 2026-10-27: a live teacher session hitting the page and the AJAX actions.
- [x] Second mint for the same staff and names overwrites the same key. Mint after a name edit leaves one file (`-{staff_id}.jpg` purge on confirm). List pages of 200 with Next and First page. → AC-7, AC-8, AC-12
  - Ran 2026-10-27: a second mint for staff 28 returned the same key and overwrote it. With `OLD_NAME-28.jpg` seeded, mint left it in place, and confirm removed it, leaving one `-28.jpg` file. List returned 200 object pages with an 80 character cursor, page two returned 6 with no overlap, and First page returned to page one.
- [x] Incomplete `ask_zap.php` B2 keys hide capture, list, and buttons and show `The photo store is not configured.` Missing switch list school shows `This school is missing from the switch list.` Those AJAX actions return the same msgs as HTTP 200 and must not call B2. A cURL timeout or non XML B2 body returns `The photo store did not respond.` None of these is the fatal retry screen. → AC-3, AC-15
  - Ran 2026-10-27: with the five keys blanked, the page showed `The photo store is not configured.` and hid capture, staff select, list and pager, with no fatal retry screen. All six AJAX actions returned HTTP 200 with that same message and no B2 traffic. Config restored byte identical afterwards. The missing school message was proven at engine level, where `sap_school_id` and `sap_list` both return it before any B2 request.
  - Skipped by you 2026-10-27: a live session whose `db_name` is absent from the switch list.
- [x] This slice never writes `staff_attendance_enrollments` or `staff_attendance_attempts.photo_object_key`. Failed check in frames are not stored. → AC-9, AC-14
  - Ran 2026-10-27: no write statement to either table in the engine, the AJAX dispatcher, or the page. Failed liveness runs left the bucket unchanged.

## Commands
- [x] PHP `sap_config` is complete only when all five keys are non empty strings, `b2_s3_endpoint` is a host without a scheme, and `b2_region` is a non empty token. Engine functions take mysqli plus `$ctx`, return arrays, never echo, never read `$_POST` or `$_GET`. → AC-15, AC-18
  - Ran 2026-10-27 against the live engine: rejected for an empty value, a scheme in the endpoint, a path in the endpoint, and a blank region; accepted the real five keys. No echo and no superglobal read anywhere in the engine. `php admin/includes/staff_attendance_photos_engine_test.php` passes.
- [x] Object key is `{school_id}/{sanitized_school_name}/enrollments/{Last}_{First}-{staff_id}.jpg`. Prefix `{school_id}/` so school 4 cannot list school 42. Sanitize: trim, non `[A-Za-z0-9]` runs to `_`, trim `_`, cut 40, trim `_`, empty folder `school`, empty name part `staff`. → AC-7
  - Ran 2026-10-27: built `21/DEMO_ACADEMY_SECONDARY/enrollments/BAMIDELE_JOHN-28.jpg` live. `..Edo/h` became `Edo_h_staff`, empty names became `staff_staff`, and a 120 character name cut at 40 per part. `210/` is refused for school 21.
- [x] Confirm HEAD requires object exists, `Content-Type` starts with `image/jpeg`, `Content-Length` 1 to 5242880. Type or size fail deletes then `That photo is not a valid JPEG.` View uses the same HEAD checks but does not delete on fail. → AC-9, AC-10
  - Ran 2026-10-27: a `text/plain` object and a zero byte object each returned `That photo is not a valid JPEG.` Confirm deleted both (gone from the next list). View returned the same message and left its object in place, checked by a list straight after.
- [x] `object_key` for confirm, view, delete must match `^[0-9]+/[A-Za-z0-9_]+/enrollments/[A-Za-z0-9_.-]+\.jpg$`, start with this school `{school_id}/`, and must not contain `..`. → AC-13
  - Ran 2026-10-27: refused `999/`, `213/`, traversal, and uppercase `.JPG`, all with `That photo is not in this school folder.` and no signing or delete.
- [x] List `Prefix` `{school_id}/`, `MaxKeys` 200. Cursor over 4096 characters or with control bytes is refused. JSON `size` is bytes. `file_name` is the last path segment. `staff[]` uses the AC-5 label. → AC-12
  - Ran 2026-10-27: 200 object pages, 6 on page two, no overlap. A 5000 character cursor and a cursor with a newline both returned `That list cursor is not valid.` `size` matched the uploaded blob byte count.
- [x] JSON uses `JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT`. Success `{status:success}`. Each JSON body includes a fresh `csrf_token`. CSRF fail and wrong role are HTTP 403 `{status:error, msg:forbidden}`. Do not call `check_csrf_token()`. Actions include `challenges`. The liveness module URL is absolute `/app/admin/scripts/staff-attendance-liveness.js` with `get_v()`. → AC-6, AC-16
  - Ran 2026-10-27: a bad token returned 403 `forbidden`, every JSON body carried a fresh `csrf_token`, `check_csrf_token()` is never called, and all seven actions answer. The liveness module is loaded from the absolute `/app/admin/scripts/` URL with `?v=` and served 200.
  - The hex escaping itself could not be observed at runtime, because no live response in this slice carries a character that would escape. The flags are present in `sap_json_flags()` and in the page JSON.
- [x] Presigned PUT is path-style, SigV4, headers `host` and `content-type` only, payload `UNSIGNED-PAYLOAD`, 120 seconds. Presigned GET signs `host` only, 300 seconds. PHP `HEAD`, `ListObjectsV2`, and `DeleteObject` sign the SHA256 of an empty body, cURL 10 second timeout, TLS verify on. Never log `put_url`, `get_url`, or the application key. → AC-8, AC-10, AC-18
  - Ran 2026-10-27: inspected both signatures live. PUT signed `content-type;host` with 120 seconds, GET signed `host` with 300 seconds, both path style and `AWS4-HMAC-SHA256`. No log call in the slice emits a URL or the application key.

## Value sourcing
- [x] Page access uses `staff_attendance_can_access` as spec 0003, not a copied role check. → AC-1
- [x] Hero school name is `$school_config['school_name']`. Folder `school_id` is the switch list row whose `db_name` equals `$_SESSION['db_name']`. Client never sends `school_id` or `db_name`. → AC-3, AC-7
  - Ran 2026-10-27: hero read DEMO ACADEMY SECONDARY from config, `sap_school_id` resolved 21 from the switch list, and no client input ever carries `school_id` or `db_name`.
- [x] First list CSRF is `$security->create_csrf_token()` printed into page JS, then replaced from each JSON `csrf_token`. → AC-6
  - Ran 2026-10-27: the page printed a token into `window.SAP.csrf` and every JSON body returned a new one that the page script picks up.
- [x] JPEG bytes come from the final MediaPipe frame then canvas `toBlob` after the oval, not from a file input and not from PHP. Challenge frames are not uploaded. Spoken prompts use the same strings as the on-screen step via `speakPrompt` in `staff-attendance-liveness.js`. → AC-4, AC-8
  - Ran 2026-10-27: no file input exists, PHP receives no bytes, the stored portrait was read back at 480x640 JPEG, and the prompt clips under `app/admin/audio/sap/` all serve 200.
- [x] Confirm size and type come from B2 HEAD. List keys and sizes come from `ListObjectsV2`. List `file_name` is PHP `basename`. Next cursor is `NextContinuationToken` when `IsTruncated`. → AC-9, AC-12
- [x] B2 host is `b2_s3_endpoint` with `https://` prefixed by PHP. Region is `b2_region`. Bucket is `b2_bucket`. Credentials are `b2_key_id` and `b2_application_key` from `private/includes/ask_zap.php`. → AC-15
- [x] Tenant SQL uses the mysqli database already selected by `$_SESSION['db_name']`. Staff options are activated rows in that database. → AC-5, AC-16
- [x] Live session gate is `isset($_SESSION['authenticated']) || isset($_SESSION['zap_auth'])`. CSRF is `csrf_token_is_valid` plus same origin when present. → AC-16
- [x] Upload toast is `Photo uploaded.` Delete toast is `Photo deleted.` both via `notify({ text }, 'success')`. → AC-11

## Acceptance-criteria coverage
- AC-1 covered by role page and AJAX steps
- AC-2 covered by nav tile step
- AC-3 covered by Pattern C, empty, missing config, missing school, timeout steps
- AC-4 covered by camera, oval, liveness, spoken prompts, PUT retry, camera blocked steps
- AC-5 covered by staff select and mint refuse step
- AC-6 covered by AJAX file, CSRF, JSON flags step
- AC-7 covered by object key, prefix slash, name-edit purge steps
- AC-8 covered by mint PUT URL step
- AC-9 covered by confirm HEAD step
- AC-10 covered by view GET URL step
- AC-11 covered by delete wording and toasts step
- AC-12 covered by list paging and staff[] step
- AC-13 covered by prefix allowlist step
- AC-14 covered by no enrollment or attempt write step
- AC-15 covered by ask_zap keys and incomplete config step
- AC-16 covered by 401, 403, CSRF step
- AC-17 covered by CORS console checklist
- AC-18 covered by SigV4 helper and sap_* names step
