From 1ce33fea565d3e80da65e92951462b5e3652f053 Mon Sep 17 00:00:00 2001 From: Alfa Team Date: Sun, 28 Jun 2026 11:33:10 -0400 Subject: [PATCH 1/3] Fix DEV auth user key authority --- ...user-key-db-authority_branch_validation.md | 23 + ...ey-db-authority_manual_validation_notes.md | 14 + ...5-dev-auth-user-key-db-authority_report.md | 61 + ...-key-db-authority_requirement_checklist.md | 18 + ...key-db-authority_validation_lane_report.md | 18 + ...user-key-db-authority_validation_report.md | 24 + dev/reports/codex_changed_files.txt | 120 +- dev/reports/codex_review.diff | 5173 +++-------------- ...upabaseDevCreatorIdentitySeedSync.test.mjs | 188 +- .../SupabaseProviderContractStub.test.mjs | 138 +- src/dev-runtime/server/local-api-router.mjs | 24 +- ...upabase-dev-creator-identity-seed-sync.mjs | 155 +- 12 files changed, 1511 insertions(+), 4445 deletions(-) create mode 100644 dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md create mode 100644 dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md create mode 100644 dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md create mode 100644 dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md create mode 100644 dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md create mode 100644 dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md new file mode 100644 index 000000000..c807ea892 --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md @@ -0,0 +1,23 @@ +# Branch Validation: PR_26179_OWNER_035-dev-auth-user-key-db-authority + +## Status + +PASS + +## Branch + +- Current branch: `PR_26179_OWNER_035-dev-auth-user-key-db-authority` +- Source branch: `main` +- Worktree at report time: modified files only for this PR plus generated report artifacts + +## Validation Commands + +- PASS `node --check src/dev-runtime/server/local-api-router.mjs` +- PASS `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` +- PASS `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +- PASS `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +- PASS `git diff --check` +- PASS `npm run validate:canonical-structure` + diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md new file mode 100644 index 000000000..005ca851f --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md @@ -0,0 +1,14 @@ +# Manual Validation Notes: PR_26179_OWNER_035-dev-auth-user-key-db-authority + +Manual browser validation was not run because this PR changes server-side auth/session resolution and DEV identity sync behavior. The targeted API-level tests cover the affected user-visible outcomes: + +- A valid Supabase Auth id linked to `users.authProviderUserId` signs in and returns the database-owned `users.key`. +- A matching email with stale `users.authProviderUserId` does not sign in and returns the existing Creator-safe identity setup message. +- The browser response does not expose raw Auth ids or database user keys on the failure path. + +Recommended manual follow-up in DEV after merge: + +- Run the approved DEV identity sync. +- Sign in as `qbytes.dq@gmail.com`. +- Confirm the session resolves to the existing database `users.key` row whose `authProviderUserId` equals the Supabase Auth user id. + diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md new file mode 100644 index 000000000..94dfcbd73 --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md @@ -0,0 +1,61 @@ +# PR_26179_OWNER_035-dev-auth-user-key-db-authority + +## Summary + +This PR makes DEV account session resolution treat the database `users` row as authoritative. + +Runtime sign-in now resolves a session only when the Supabase Auth user id matches `users.authProviderUserId`. Matching by email alone is no longer accepted for login/session resolution. Email remains valid only for the DEV identity sync step that locates the existing database `users` row before writing the real Supabase Auth id back to `users.authProviderUserId`. + +## Diagnostic Findings + +### Where `users.key` currently comes from + +- DEV seed setup defines static user keys in `src/dev-runtime/seed/seed-db-keys.mjs`. +- Current database seed DML also contains DEV account rows under `dev/build/database/dml/account.sql`. +- Before this PR, `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` imported those seed keys and used them as the canonical keys during DEV Supabase identity sync. +- After this PR, DEV sync locates existing `users` rows by email and reads `users.key` from the database row. The sync no longer imports `SEED_DB_KEYS`. + +### Where `authProviderUserId` is populated + +- Account creation provisioning still writes `authProviderUserId` in `src/dev-runtime/server/local-api-router.mjs` through `provisionSupabaseIdentityForAuthPayload()`. +- DEV identity sync now reads `auth.users.id` from Supabase Auth, reads `users.key` from the database row found by email, and sends that database-owned key plus the real Auth id through `databaseProvider.initializeIdentity()`. + +### Seed constant use + +- Seed constants remain valid for DEV seed setup, seed fixtures, and existing test/demo data. +- They are no longer used by the DEV identity sync helper as the authoritative app user key source. +- Runtime sign-in/session resolution does not hardcode DavidQ or other user keys. It reads identity tables and resolves `matchingUser.key` after matching Supabase Auth id to `users.authProviderUserId`. + +## Exact Fix + +- Removed email fallback from Supabase sign-in session resolution. +- Added a Creator-safe setup failure when Auth succeeds but the matching email row has not been linked to the Auth id. +- Added a narrow server-side Postgres client injection hook for tests, with default runtime behavior unchanged. +- Refactored DEV creator identity sync so canonical identity definitions are email-based and database `users.key` is read from existing rows. +- Changed role assignment and cleanup repair logic to derive canonical user keys from database rows. +- Added regression tests proving: + - Sign-in uses the database-owned `users.key` matched by `authProviderUserId`. + - Sign-in does not create a session from matching email when `authProviderUserId` is stale. + - DEV sync preserves non-seed database user keys. + - DEV sync fails if an expected database `users` row is missing. + +## Files Changed + +- `src/dev-runtime/server/local-api-router.mjs` +- `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` +- `dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +- `dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` + +## Validation + +PASS: + +- `node --check src/dev-runtime/server/local-api-router.mjs` +- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` +- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +- `git diff --check` +- `npm run validate:canonical-structure` + diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md new file mode 100644 index 000000000..180e988c1 --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md @@ -0,0 +1,18 @@ +# Requirement Checklist: PR_26179_OWNER_035-dev-auth-user-key-db-authority + +- PASS Do not use hardcoded user keys as runtime identity source. +- PASS Use email only to locate the existing DEV `users` row during DEV identity sync. +- PASS Read `users.key` from the database row during DEV sync. +- PASS Read `auth.users.id` from Supabase Auth during DEV sync. +- PASS Write `auth.users.id` into `users.authProviderUserId`. +- PASS Login/session resolution matches Supabase Auth id to `users.authProviderUserId`. +- PASS Do not create browser-owned auth. +- PASS Do not create fake login. +- PASS Do not add password tables. +- PASS Do not use `displayName` as an identity key. +- PASS Do not hardcode DavidQ/user keys in runtime login resolution. +- PASS Report where `users.key` currently comes from. +- PASS Report where `authProviderUserId` is populated. +- PASS Report whether seed constants are setup-only or runtime identity authority. +- PASS Report exact fix. + diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md new file mode 100644 index 000000000..634164f94 --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md @@ -0,0 +1,18 @@ +# Validation Lane Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority + +## Lane + +Targeted auth and DEV identity sync validation. + +## Commands + +- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` + +## Coverage + +- DEV identity sync reads existing database `users.key` values by email. +- DEV identity sync writes real Supabase Auth ids to `users.authProviderUserId`. +- Session resolution accepts only `auth.users.id` matched to `users.authProviderUserId`. +- Email-only session resolution is rejected with the existing Creator-safe setup message. + diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md new file mode 100644 index 000000000..5d5f107cc --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md @@ -0,0 +1,24 @@ +# Validation Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority + +## Result + +PASS + +## Targeted Validation + +- `node --check src/dev-runtime/server/local-api-router.mjs`: PASS +- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs`: PASS +- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS +- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS +- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS, 2 tests +- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS, 2 tests + +## Repository Guardrails + +- `git diff --check`: PASS +- `npm run validate:canonical-structure`: PASS + +## Notes + +The targeted tests use a server-side Postgres client fixture to exercise the Local API route without opening a real database connection. Default runtime behavior remains unchanged because the injected client defaults to `null`. + diff --git a/dev/reports/codex_changed_files.txt b/dev/reports/codex_changed_files.txt index 9883c823c..a1f9ea8e3 100644 --- a/dev/reports/codex_changed_files.txt +++ b/dev/reports/codex_changed_files.txt @@ -1,98 +1,26 @@ -# Alfa Objects Stack EOD Changed Files +# git status --short +M dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs + M dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs + M src/dev-runtime/server/local-api-router.mjs + M src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs +?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md -Base before Objects stack: 13ed907456492612adce750a434b52058301bbf5 -Objects stack merge head: 54b4c4a894e8e6ddf75d8dabdda0cc0f87f92a51 -Current branch during report: main -main...origin/main during report: 0 0 +# git ls-files --others --exclude-standard +dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md -# Merged PRs -- PR_26179_ALFA_011-objects-manager-mvp: a2da07a84adea589a22b05ed8985bdcf7d5806db Merge PR_26179_ALFA_011 Objects Manager MVP -- PR_26179_ALFA_012-objects-properties-mvp: 76ef2bcfff8a8fb0a3949901dc32a090593a4480 Merge PR_26179_ALFA_012 Object Properties MVP -- PR_26179_ALFA_013-objects-asset-links: 22464cc322f6d8c2721124dd1ca30d13f0adc8cf Merge PR_26179_ALFA_013 Objects Asset Links -- PR_26179_ALFA_014-objects-journey-readiness: 54b4c4a894e8e6ddf75d8dabdda0cc0f87f92a51 Merge PR_26179_ALFA_014 Objects Journey Readiness - -# git status --short before report refresh -M dev/reports/codex_review.diff - -# git diff --name-status 13ed907456492612adce750a434b52058301bbf5..54b4c4a894e8e6ddf75d8dabdda0cc0f87f92a51 -M assets/toolbox/game-journey/js/index.js -M assets/toolbox/objects/js/index.js -A dev/reports/PR_26179_ALFA_011-objects-manager-mvp_branch-validation.md -A dev/reports/PR_26179_ALFA_011-objects-manager-mvp_manual-validation-notes.md -A dev/reports/PR_26179_ALFA_011-objects-manager-mvp_report.md -A dev/reports/PR_26179_ALFA_011-objects-manager-mvp_requirement-checklist.md -A dev/reports/PR_26179_ALFA_011-objects-manager-mvp_validation-lane.md -A dev/reports/PR_26179_ALFA_011-objects-manager-mvp_validation-report.md -A dev/reports/PR_26179_ALFA_012-objects-properties-mvp_branch_validation.md -A dev/reports/PR_26179_ALFA_012-objects-properties-mvp_manual_validation_notes.md -A dev/reports/PR_26179_ALFA_012-objects-properties-mvp_report.md -A dev/reports/PR_26179_ALFA_012-objects-properties-mvp_requirement_checklist.md -A dev/reports/PR_26179_ALFA_012-objects-properties-mvp_validation_lane.md -A dev/reports/PR_26179_ALFA_012-objects-properties-mvp_validation_report.md -A dev/reports/PR_26179_ALFA_013-objects-asset-links_branch_validation.md -A dev/reports/PR_26179_ALFA_013-objects-asset-links_manual_validation_notes.md -A dev/reports/PR_26179_ALFA_013-objects-asset-links_report.md -A dev/reports/PR_26179_ALFA_013-objects-asset-links_requirement_checklist.md -A dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_lane.md -A dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_report.md -A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md -A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_eod_report.md -A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md -A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md -A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md -A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md -A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md -A dev/reports/PR_26179_ALFA_objects-stack_product-owner-review-notes.md -M dev/reports/codex_changed_files.txt -M dev/reports/codex_review.diff -M dev/reports/coverage_changed_js_guardrail.txt -M dev/reports/playwright_v8_coverage_report.txt -A dev/tests/dev-runtime/ObjectsApiService.test.mjs -M dev/tests/playwright/tools/GameJourneyTool.spec.mjs -M dev/tests/playwright/tools/ObjectsTool.spec.mjs -M src/dev-runtime/server/local-api-router.mjs -M src/dev-runtime/toolbox-api/alfa-tool-services.mjs -M toolbox/game-journey/index.html -M toolbox/objects/index.html - -# git diff --stat 13ed907456492612adce750a434b52058301bbf5..54b4c4a894e8e6ddf75d8dabdda0cc0f87f92a51 -assets/toolbox/game-journey/js/index.js | 285 +++++- - assets/toolbox/objects/js/index.js | 598 ++++++++++++- - ...FA_011-objects-manager-mvp_branch-validation.md | 10 + - ...-objects-manager-mvp_manual-validation-notes.md | 19 + - ...PR_26179_ALFA_011-objects-manager-mvp_report.md | 39 + - ...11-objects-manager-mvp_requirement-checklist.md | 16 + - ...ALFA_011-objects-manager-mvp_validation-lane.md | 17 + - ...FA_011-objects-manager-mvp_validation-report.md | 19 + - ...012-objects-properties-mvp_branch_validation.md | 26 + - ...jects-properties-mvp_manual_validation_notes.md | 22 + - ...26179_ALFA_012-objects-properties-mvp_report.md | 40 + - ...objects-properties-mvp_requirement_checklist.md | 23 + - ...A_012-objects-properties-mvp_validation_lane.md | 23 + - ...012-objects-properties-mvp_validation_report.md | 18 + - ...FA_013-objects-asset-links_branch_validation.md | 27 + - ...-objects-asset-links_manual_validation_notes.md | 24 + - ...PR_26179_ALFA_013-objects-asset-links_report.md | 41 + - ...13-objects-asset-links_requirement_checklist.md | 15 + - ...ALFA_013-objects-asset-links_validation_lane.md | 23 + - ...FA_013-objects-asset-links_validation_report.md | 16 + - ...-objects-journey-readiness_branch_validation.md | 24 + - ...LFA_014-objects-journey-readiness_eod_report.md | 27 + - ...ts-journey-readiness_manual_validation_notes.md | 14 + - ...79_ALFA_014-objects-journey-readiness_report.md | 34 + - ...ects-journey-readiness_requirement_checklist.md | 17 + - ...14-objects-journey-readiness_validation_lane.md | 20 + - ...-objects-journey-readiness_validation_report.md | 19 + - ...LFA_objects-stack_product-owner-review-notes.md | 87 ++ - dev/reports/codex_changed_files.txt | 55 +- - dev/reports/codex_review.diff | 988 ++++++++++++++++++++- - dev/reports/coverage_changed_js_guardrail.txt | 4 +- - dev/reports/playwright_v8_coverage_report.txt | 31 +- - dev/tests/dev-runtime/ObjectsApiService.test.mjs | 224 +++++ - .../playwright/tools/GameJourneyTool.spec.mjs | 94 ++ - dev/tests/playwright/tools/ObjectsTool.spec.mjs | 211 ++++- - src/dev-runtime/server/local-api-router.mjs | 14 +- - src/dev-runtime/toolbox-api/alfa-tool-services.mjs | 417 +++++++++ - toolbox/game-journey/index.html | 21 + - toolbox/objects/index.html | 53 ++ - 39 files changed, 3601 insertions(+), 54 deletions(-) +# git diff --stat +.../SupabaseDevCreatorIdentitySeedSync.test.mjs | 188 ++++++++++++++------- + .../SupabaseProviderContractStub.test.mjs | 138 ++++++++++++++- + src/dev-runtime/server/local-api-router.mjs | 24 ++- + .../supabase-dev-creator-identity-seed-sync.mjs | 155 +++++++++++------ + 4 files changed, 391 insertions(+), 114 deletions(-) \ No newline at end of file diff --git a/dev/reports/codex_review.diff b/dev/reports/codex_review.diff index d9c691cd4..979b1be18 100644 --- a/dev/reports/codex_review.diff +++ b/dev/reports/codex_review.diff @@ -1,4306 +1,1009 @@ -diff --git a/assets/toolbox/game-journey/js/index.js b/assets/toolbox/game-journey/js/index.js -index 085df839c..c2c4f1d05 100644 ---- a/assets/toolbox/game-journey/js/index.js -+++ b/assets/toolbox/game-journey/js/index.js -@@ -11,8 +11,10 @@ import { - getActiveToolRegistry, - getToolRegistryApiDiagnostic, - } from "../../../../toolbox/tool-registry-api-client.js"; -+import { createServerRepositoryClient } from "../../../../src/api/server-api-client.js"; - - const repository = createGameJourneyApiRepository(); -+const objectsRepository = createServerRepositoryClient("objects"); - const registryDiagnostic = getToolRegistryApiDiagnostic(); - const registryTools = getActiveToolRegistry(); - const params = new URLSearchParams(window.location.search); -@@ -51,6 +53,9 @@ const diagnostics = document.querySelector("[data-journey-diagnostics]"); - const searchInput = document.querySelector("[data-journey-search-input]"); - const searchStatus = document.querySelector("[data-journey-search-status]"); - const completionMetrics = document.querySelector("[data-journey-completion-metrics]"); -+const objectsReadinessTable = document.querySelector("[data-journey-objects-readiness]"); -+const objectsReadinessStatus = document.querySelector("[data-journey-objects-readiness-status]"); -+const objectsReviewChecklist = document.querySelector("[data-journey-objects-review-checklist]"); - const recommendedTargets = document.querySelector("[data-journey-recommended-targets]"); - const recommendedTargetStatus = document.querySelector("[data-journey-target-status]"); - const SUMMARY_TABLE_COLUMN_COUNT = 13; -@@ -88,6 +93,54 @@ let routeForcesNoActiveGame = false; - const recommendedTargetValues = new Map( - GAME_JOURNEY_RECOMMENDED_TARGETS.map((target) => [target.key, target.suggestedCount]), - ); -+const OBJECTS_COMPLETION_BUCKET_KEY = "006-objects"; -+const OBJECTS_STAGE_CRITERIA = Object.freeze([ -+ Object.freeze({ -+ key: "saved-objects", -+ label: "At least one object is saved for the current game.", -+ }), -+ Object.freeze({ -+ key: "named-typed", -+ label: "Every saved object has a name, type, and starting state.", -+ }), -+ Object.freeze({ -+ key: "interactive-object", -+ label: "At least one object represents something the player can use, collect, avoid, or reach.", -+ }), -+ Object.freeze({ -+ key: "render-links", -+ label: "Sprite-rendered objects have a sprite asset reference.", -+ }), -+ Object.freeze({ -+ key: "owner-review-details", -+ label: "Object Details include Product Owner review context such as description, tags, or defaults.", -+ }), -+]); -+const OBJECTS_REVIEW_CHECKLIST = Object.freeze([ -+ "Confirm the object list matches the current Game Hub game.", -+ "Confirm each object name and type is understandable to a Creator.", -+ "Confirm sprite, audio, and message references are resolved or intentionally empty.", -+ "Confirm at least one meaningful player-facing object exists.", -+ "Confirm Objects validation is clean before expanding later gameplay work.", -+]); -+const INTERACTIVE_OBJECT_ROLES = Object.freeze([ -+ "collectible", -+ "enemy", -+ "goal", -+ "hazard", -+ "hero", -+ "projectile", -+]); -+const INTERACTIVE_OBJECT_TRAITS = Object.freeze([ -+ "collectible", -+ "goal", -+ "hazard", -+ "movable", -+ "playerControlled", -+ "scores", -+]); -+let objectsReadinessSnapshot = null; -+let objectsReadinessDiagnostic = ""; - - function currentRecommendedTargets() { - const result = repository.listRecommendedTargets(); -@@ -135,6 +188,10 @@ function routedNotes(filterId) { - return routeForcesNoActiveGame ? [] : repository.listNotes(filterId); - } - -+function normalizeText(value) { -+ return String(value || "").trim(); -+} -+ - function createElement(tagName, options = {}) { - const element = document.createElement(tagName); - if (options.className) { -@@ -146,6 +203,120 @@ function createElement(tagName, options = {}) { - return element; - } - -+function objectDetails(object = {}) { -+ return object.details && typeof object.details === "object" ? object.details : {}; -+} -+ -+function objectHasNameTypeAndState(object = {}) { -+ return Boolean(normalizeText(object.name) && normalizeText(object.role || object.type) && normalizeText(object.state)); -+} -+ -+function normalizedObjectRole(object = {}) { -+ return normalizeText(object.role || object.type).toLowerCase(); -+} -+ -+function normalizedObjectTraits(object = {}) { -+ return Array.isArray(object.traits) -+ ? object.traits.map(normalizeText).filter(Boolean) -+ : []; -+} -+ -+function objectIsInteractive(object = {}) { -+ const role = normalizedObjectRole(object); -+ if (INTERACTIVE_OBJECT_ROLES.includes(role)) { -+ return true; -+ } -+ const traits = normalizedObjectTraits(object); -+ return traits.some((trait) => INTERACTIVE_OBJECT_TRAITS.includes(trait)); -+} -+ -+function objectSpriteReference(object = {}) { -+ const details = objectDetails(object); -+ return normalizeText(object.render?.assetKey || details.spriteReference); -+} -+ -+function objectHasRenderReference(object = {}) { -+ return normalizeText(object.render?.type) !== "Sprite" || Boolean(objectSpriteReference(object)); -+} -+ -+function objectHasOwnerReviewDetails(object = {}) { -+ const details = objectDetails(object); -+ return Boolean( -+ normalizeText(details.description) || -+ normalizeText(details.defaultValues) || -+ normalizeText(details.spriteReference) || -+ normalizeText(details.audioReference) || -+ normalizeText(details.messageReference) || -+ (Array.isArray(details.tags) && details.tags.length > 0) -+ ); -+} -+ -+function evaluateObjectsStageReadiness(objects = []) { -+ const savedObjects = Array.isArray(objects) ? objects : []; -+ const hasObjects = savedObjects.length > 0; -+ const criteriaByKey = { -+ "saved-objects": hasObjects, -+ "named-typed": hasObjects && savedObjects.every(objectHasNameTypeAndState), -+ "interactive-object": savedObjects.some(objectIsInteractive), -+ "render-links": hasObjects && savedObjects.every(objectHasRenderReference), -+ "owner-review-details": savedObjects.some(objectHasOwnerReviewDetails), -+ }; -+ const criteria = OBJECTS_STAGE_CRITERIA.map((criterion) => ({ -+ ...criterion, -+ complete: Boolean(criteriaByKey[criterion.key]), -+ })); -+ const completedCriteria = criteria.filter((criterion) => criterion.complete).length; -+ return { -+ available: true, -+ completedCriteria, -+ criteria, -+ meaningful: completedCriteria >= 3, -+ objectCount: savedObjects.length, -+ ready: completedCriteria === criteria.length, -+ totalCriteria: criteria.length, -+ }; -+} -+ -+function unavailableObjectsStageReadiness(message) { -+ return { -+ available: false, -+ completedCriteria: 0, -+ criteria: OBJECTS_STAGE_CRITERIA.map((criterion) => ({ -+ ...criterion, -+ complete: false, -+ })), -+ meaningful: false, -+ objectCount: 0, -+ ready: false, -+ totalCriteria: OBJECTS_STAGE_CRITERIA.length, -+ unavailableMessage: message, -+ }; -+} -+ -+function objectRepositoryErrorMessage(result) { -+ if (result?.error || objectsRepository.__apiDiagnostic?.()) { -+ return "Objects readiness is temporarily unavailable. Continue building while the API refreshes."; -+ } -+ return "Objects readiness is temporarily unavailable. Continue building while the API refreshes."; -+} -+ -+function refreshObjectsReadinessSnapshot() { -+ const activeGame = routedActiveGame(); -+ if (!activeGame) { -+ objectsReadinessDiagnostic = "Open a game in Game Hub before checking Objects readiness."; -+ objectsReadinessSnapshot = unavailableObjectsStageReadiness(objectsReadinessDiagnostic); -+ return; -+ } -+ const result = objectsRepository.listObjects(activeGame.id || activeGame.key); -+ if (Array.isArray(result)) { -+ objectsReadinessDiagnostic = ""; -+ objectsReadinessSnapshot = evaluateObjectsStageReadiness(result); -+ return; -+ } -+ objectsReadinessDiagnostic = objectRepositoryErrorMessage(result); -+ objectsReadinessSnapshot = unavailableObjectsStageReadiness(objectsReadinessDiagnostic); -+} -+ - function formatDate(value) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { -@@ -952,6 +1123,52 @@ function formatCompletionFocusStatus(metric) { - return metric.active ? "Active focus" : "Planning context"; - } - -+function metricPercentComplete(completedCount, plannedCount) { -+ if (!plannedCount) { -+ return 0; -+ } -+ return Math.round((completedCount / plannedCount) * 100); -+} -+ -+function recalculateCompletionSnapshot(snapshot, records) { -+ const plannedCount = records.reduce((total, metric) => total + metric.plannedCount, 0); -+ const completedCount = records.reduce((total, metric) => total + metric.completedCount, 0); -+ const activeCount = records.filter((metric) => metric.active).length; -+ return { -+ ...snapshot, -+ activeCount, -+ completedCount, -+ inactiveCount: records.length - activeCount, -+ percentComplete: metricPercentComplete(completedCount, plannedCount), -+ plannedCount, -+ records, -+ }; -+} -+ -+function completionSnapshotWithObjectsReadiness(snapshot) { -+ if (!snapshot?.records || !objectsReadinessSnapshot?.available || objectsReadinessSnapshot.completedCriteria <= 0) { -+ return snapshot; -+ } -+ const records = snapshot.records.map((metric) => { -+ if (metric.bucketKey !== OBJECTS_COMPLETION_BUCKET_KEY) { -+ return metric; -+ } -+ const plannedCount = metric.plannedCount || objectsReadinessSnapshot.totalCriteria; -+ const completedCount = Math.min( -+ plannedCount, -+ Math.max(metric.completedCount, objectsReadinessSnapshot.completedCriteria), -+ ); -+ return { -+ ...metric, -+ completedCount, -+ percentComplete: metricPercentComplete(completedCount, plannedCount), -+ plannedCount, -+ status: metric.active ? "active" : metric.status, -+ }; -+ }); -+ return recalculateCompletionSnapshot(snapshot, records); -+} -+ - function renderCompletionMetrics() { - if (!completionMetrics) { - return; -@@ -967,7 +1184,8 @@ function renderCompletionMetrics() { - return; - } - -- const records = completionMetricsSnapshot?.records || []; -+ const snapshot = completionSnapshotWithObjectsReadiness(completionMetricsSnapshot); -+ const records = snapshot?.records || []; - if (!records.length) { - completionMetrics.append(createElement("p", { text: "No Journey progress targets are set yet. Add suggested targets to start tracking progress." })); - return; -@@ -977,16 +1195,16 @@ function renderCompletionMetrics() { - dashboard.dataset.journeyProgressDashboard = ""; - const summary = createElement("p", { - className: "status", -- text: `Journey progress: ${completionMetricsSnapshot.completedCount} of ${completionMetricsSnapshot.plannedCount} planned items done (${completionMetricsSnapshot.percentComplete}%). ${completionMetricsSnapshot.activeCount} sections are active focus areas; ${completionMetricsSnapshot.inactiveCount} are planning context.`, -+ text: `Journey progress: ${snapshot.completedCount} of ${snapshot.plannedCount} planned items done (${snapshot.percentComplete}%). ${snapshot.activeCount} sections are active focus areas; ${snapshot.inactiveCount} are planning context.`, - }); - const overallHeading = createElement("h3", { text: "Overall Progress" }); - const overallGrid = createElement("div", { className: "grid cols-2" }); - overallGrid.dataset.journeyOverallProgress = ""; - [ -- ["Overall", `${completionMetricsSnapshot.percentComplete}%`], -- ["Done", String(completionMetricsSnapshot.completedCount)], -- ["Planned", String(completionMetricsSnapshot.plannedCount)], -- ["Active Focus", String(completionMetricsSnapshot.activeCount)], -+ ["Overall", `${snapshot.percentComplete}%`], -+ ["Done", String(snapshot.completedCount)], -+ ["Planned", String(snapshot.plannedCount)], -+ ["Active Focus", String(snapshot.activeCount)], - ].forEach(([label, value]) => { - const stat = createElement("article", { className: "mini-stat mini-stat--inline" }); - stat.append( -@@ -1062,7 +1280,7 @@ function renderCompletionMetrics() { - const insightHeading = createElement("h3", { text: "What To Do Next" }); - const insightList = createElement("ul"); - insightList.dataset.journeyCompletionInsights = ""; -- completionInsightMessages(completionMetricsSnapshot, records, mostComplete, leastComplete).forEach((message) => { -+ completionInsightMessages(snapshot, records, mostComplete, leastComplete).forEach((message) => { - insightList.append(createElement("li", { text: message })); - }); - -@@ -1082,6 +1300,47 @@ function renderCompletionMetrics() { - completionMetrics.append(dashboard); - } - -+function objectsReadinessStatusText(snapshot) { -+ if (!snapshot?.available) { -+ return snapshot?.unavailableMessage || "Objects readiness is temporarily unavailable."; -+ } -+ if (snapshot.objectCount === 0) { -+ return "Objects readiness: no saved objects for this game yet."; -+ } -+ if (snapshot.ready) { -+ return `Objects readiness: ${snapshot.completedCriteria} of ${snapshot.totalCriteria} criteria complete. Product Owner review can start.`; -+ } -+ if (snapshot.meaningful) { -+ return `Objects readiness: ${snapshot.completedCriteria} of ${snapshot.totalCriteria} criteria complete. Journey progress now reflects meaningful Objects work.`; -+ } -+ return `Objects readiness: ${snapshot.completedCriteria} of ${snapshot.totalCriteria} criteria complete. Continue Objects before treating this stage as ready.`; -+} -+ -+function renderObjectsReadiness() { -+ const snapshot = objectsReadinessSnapshot || unavailableObjectsStageReadiness("Objects readiness is waiting for API data."); -+ if (objectsReadinessStatus) { -+ objectsReadinessStatus.textContent = objectsReadinessStatusText(snapshot); -+ } -+ if (objectsReadinessTable) { -+ objectsReadinessTable.replaceChildren(); -+ snapshot.criteria.forEach((criterion) => { -+ const row = createElement("tr"); -+ row.dataset.journeyObjectsCriterion = criterion.key; -+ row.append( -+ createElement("td", { text: criterion.label }), -+ createElement("td", { text: criterion.complete ? "PASS" : "Needs work" }), -+ ); -+ objectsReadinessTable.append(row); -+ }); -+ } -+ if (objectsReviewChecklist) { -+ objectsReviewChecklist.replaceChildren(); -+ OBJECTS_REVIEW_CHECKLIST.forEach((item) => { -+ objectsReviewChecklist.append(createElement("li", { text: item })); -+ }); -+ } -+} -+ - function normalizeTargetCount(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { -@@ -1474,6 +1733,7 @@ function render() { - renderStatScope(selectedStatsNote, notes); - renderStats(statCounts); - renderCompletionMetrics(); -+ renderObjectsReadiness(); - renderRecommendedTargets(); - renderSuggestedTools(displayNote); - renderRecentActivity(); -@@ -1788,6 +2048,16 @@ recommendedTargets?.addEventListener("input", (event) => { - } - }); - -+window.addEventListener("focus", () => { -+ refreshObjectsReadinessSnapshot(); -+ render(); -+}); -+ -+window.addEventListener("pageshow", () => { -+ refreshObjectsReadinessSnapshot(); -+ render(); -+}); -+ - addNoteButton?.addEventListener("click", () => { - if (redirectGuestWriteAction(noteStatus)) { - return; -@@ -1821,4 +2091,5 @@ addTypeButton?.addEventListener("click", () => { - renderStatusOptions(); - refreshCompletionMetricsSnapshot(); - applyInitialGameRoute(); -+refreshObjectsReadinessSnapshot(); - render(); -diff --git a/assets/toolbox/objects/js/index.js b/assets/toolbox/objects/js/index.js -index 0f332df58..96644bf92 100644 ---- a/assets/toolbox/objects/js/index.js -+++ b/assets/toolbox/objects/js/index.js -@@ -1,3 +1,4 @@ -+import { getSessionCurrent } from "../../../../src/api/session-api-client.js"; - import { - createServerRepositoryClient, - readServerToolConstants, -@@ -10,6 +11,7 @@ import { - validateObjectDefinition, - } from "../../../../src/engine/object-model/index.js"; - import { createAssetToolApiRepository } from "../../../js/shared/assets-api-client.js"; -+import { listMessages } from "../../../../toolbox/messages/messages-api-client.js"; - - const constants = readServerToolConstants("objects"); - -@@ -50,11 +52,32 @@ let objectsRepository = createObjectsToolApiRepository(); - let draftedObjects = []; - let editingRow = null; - let storageIssue = null; -+let selectedObjectKey = ""; -+let detailDraftBaseline = null; -+let messageRecords = null; -+let messageLoadIssue = null; - - const elements = { - addRow: document.querySelector("[data-objects-add-row]"), - assetStatus: document.querySelector("[data-objects-asset-status]"), -+ assetLinks: document.querySelector("[data-objects-asset-links]"), - count: document.querySelector("[data-objects-count]"), -+ detailActive: document.querySelector("[data-objects-detail-active]"), -+ detailAudio: document.querySelector("[data-objects-detail-audio]"), -+ detailCancel: document.querySelector("[data-objects-detail-cancel]"), -+ detailDefaults: document.querySelector("[data-objects-detail-defaults]"), -+ detailDescription: document.querySelector("[data-objects-detail-description]"), -+ detailEmpty: document.querySelector("[data-objects-details-empty]"), -+ detailForm: document.querySelector("[data-objects-details-form]"), -+ detailMessage: document.querySelector("[data-objects-detail-message]"), -+ detailName: document.querySelector("[data-objects-detail-name]"), -+ detailSave: document.querySelector("[data-objects-detail-save]"), -+ detailSelected: document.querySelector("[data-objects-detail-selected]"), -+ detailSprite: document.querySelector("[data-objects-detail-sprite]"), -+ detailTags: document.querySelector("[data-objects-detail-tags]"), -+ detailType: document.querySelector("[data-objects-detail-type]"), -+ detailUnsaved: document.querySelector("[data-objects-detail-unsaved]"), -+ detailVisible: document.querySelector("[data-objects-detail-visible]"), - editSprite: document.querySelector("[data-objects-edit-sprite]"), - list: document.querySelector("[data-objects-list]"), - log: document.querySelector("[data-objects-log]"), -@@ -104,12 +127,67 @@ function normalizeRenderConfig(source = {}) { - }); - } - -+function normalizeBoolean(value, fallback) { -+ if (value === true || value === "true" || value === "1") { -+ return true; -+ } -+ if (value === false || value === "false" || value === "0") { -+ return false; -+ } -+ return fallback; -+} -+ -+function parseTags(value) { -+ const values = Array.isArray(value) -+ ? value -+ : normalizeText(value).split(","); -+ const tags = values -+ .map(normalizeText) -+ .filter(Boolean); -+ return [...new Set(tags)]; -+} -+ -+function formatTags(tags) { -+ return parseTags(tags).join(", "); -+} -+ -+function normalizeObjectDetails(source = {}) { -+ const details = source.details && typeof source.details === "object" ? source.details : {}; -+ return Object.freeze({ -+ active: normalizeBoolean(details.active ?? source.active, true), -+ audioReference: normalizeText(details.audioReference ?? source.audioReference), -+ defaultValues: normalizeText(details.defaultValues ?? source.defaultValues), -+ description: normalizeText(details.description ?? source.description), -+ messageReference: normalizeText(details.messageReference ?? source.messageReference), -+ spriteReference: normalizeText(details.spriteReference ?? source.spriteReference) || normalizeText(source.render?.assetKey), -+ tags: Object.freeze(parseTags(details.tags ?? source.tags)), -+ visible: normalizeBoolean(details.visible ?? source.visible, true), -+ }); -+} -+ - function setText(element, value) { - if (element) { - element.textContent = value; - } - } - -+function currentSession() { -+ try { -+ return getSessionCurrent(); -+ } catch { -+ return { authenticated: false }; -+ } -+} -+ -+function redirectGuestWriteAction() { -+ if (currentSession()?.authenticated === true) { -+ return false; -+ } -+ setText(elements.log, "Sign in before saving Objects."); -+ window.location.href = new URL("/account/sign-in.html", window.location.href).href; -+ return true; -+} -+ - function listItem(text) { - const item = document.createElement("li"); - item.textContent = text; -@@ -250,6 +328,7 @@ function cloneObject(source = {}) { - - return { - ...object, -+ details: normalizeObjectDetails(source), - traits: traitsForObject(source, object), - }; - } -@@ -286,8 +365,13 @@ function issueLabel(issue) { - return "Object Definition"; - } - -+function engineDefinitionForValidation(object = {}) { -+ const { details, ...definition } = object; -+ return definition; -+} -+ - function objectDefinitionFindings(object, labelPrefix) { -- return validateObjectDefinition(object).issues -+ return validateObjectDefinition(engineDefinitionForValidation(object)).issues - .filter((issue) => !(issue.path.endsWith(".type") && !object.role)) - .map((issue) => ({ - action: issue.path.endsWith(".type") -@@ -397,6 +481,297 @@ function renderValidation(findings) { - elements.validationOverlay.hidden = false; - } - -+function detailFieldElements() { -+ return [ -+ elements.detailActive, -+ elements.detailAudio, -+ elements.detailDefaults, -+ elements.detailDescription, -+ elements.detailMessage, -+ elements.detailName, -+ elements.detailSprite, -+ elements.detailTags, -+ elements.detailType, -+ elements.detailVisible, -+ ].filter(Boolean); -+} -+ -+function detailsForObject(object = {}) { -+ return normalizeObjectDetails({ -+ ...object, -+ spriteReference: object.details?.spriteReference || object.render?.assetKey, -+ }); -+} -+ -+function detailSnapshotForObject(object = {}) { -+ const details = detailsForObject(object); -+ return { -+ active: details.active, -+ audioReference: details.audioReference, -+ defaultValues: details.defaultValues, -+ description: details.description, -+ messageReference: details.messageReference, -+ name: normalizeText(object.name), -+ spriteReference: details.spriteReference, -+ tags: [...details.tags], -+ type: normalizeText(object.role), -+ visible: details.visible, -+ }; -+} -+ -+function detailSnapshotFromForm() { -+ return { -+ active: elements.detailActive?.checked === true, -+ audioReference: normalizeText(elements.detailAudio?.value), -+ defaultValues: normalizeText(elements.detailDefaults?.value), -+ description: normalizeText(elements.detailDescription?.value), -+ messageReference: normalizeText(elements.detailMessage?.value), -+ name: normalizeText(elements.detailName?.value), -+ spriteReference: normalizeText(elements.detailSprite?.value), -+ tags: parseTags(elements.detailTags?.value), -+ type: normalizeText(elements.detailType?.value), -+ visible: elements.detailVisible?.checked === true, -+ }; -+} -+ -+function detailSnapshotKey(snapshot) { -+ return JSON.stringify({ -+ ...snapshot, -+ tags: [...(snapshot.tags || [])].sort(), -+ }); -+} -+ -+function selectedObject() { -+ if (!draftedObjects.length) { -+ selectedObjectKey = ""; -+ return null; -+ } -+ const selected = draftedObjects.find((object) => objectId(object) === selectedObjectKey); -+ if (selected) { -+ return selected; -+ } -+ selectedObjectKey = objectId(draftedObjects[0]); -+ return draftedObjects[0]; -+} -+ -+function hasUnsavedDetailChanges() { -+ if (!detailDraftBaseline || !selectedObject()) { -+ return false; -+ } -+ return detailSnapshotKey(detailSnapshotFromForm()) !== detailSnapshotKey(detailDraftBaseline); -+} -+ -+function renderDetailDirtyState() { -+ const object = selectedObject(); -+ const dirty = hasUnsavedDetailChanges(); -+ setText( -+ elements.detailUnsaved, -+ object -+ ? dirty -+ ? "Unsaved changes: yes" -+ : "Unsaved changes: none" -+ : "Unsaved changes: none" -+ ); -+ if (elements.detailSave) { -+ elements.detailSave.disabled = !object || !dirty; -+ } -+ if (elements.detailCancel) { -+ elements.detailCancel.disabled = !object || !dirty; -+ } -+} -+ -+function populateDetailTypeOptions(selectedType = "") { -+ if (!elements.detailType) { -+ return; -+ } -+ elements.detailType.replaceChildren(optionElement("", "Select type")); -+ typeOptions().forEach((option) => { -+ elements.detailType.append(optionElement(option.value, option.label)); -+ }); -+ elements.detailType.value = selectedType; -+} -+ -+function populateDetailForm(object) { -+ const snapshot = detailSnapshotForObject(object); -+ if (elements.detailName) { -+ elements.detailName.value = snapshot.name; -+ } -+ if (elements.detailDescription) { -+ elements.detailDescription.value = snapshot.description; -+ } -+ populateDetailTypeOptions(snapshot.type); -+ if (elements.detailTags) { -+ elements.detailTags.value = formatTags(snapshot.tags); -+ } -+ if (elements.detailActive) { -+ elements.detailActive.checked = snapshot.active; -+ } -+ if (elements.detailVisible) { -+ elements.detailVisible.checked = snapshot.visible; -+ } -+ if (elements.detailSprite) { -+ elements.detailSprite.value = snapshot.spriteReference; -+ } -+ if (elements.detailAudio) { -+ elements.detailAudio.value = snapshot.audioReference; -+ } -+ if (elements.detailMessage) { -+ elements.detailMessage.value = snapshot.messageReference; -+ } -+ if (elements.detailDefaults) { -+ elements.detailDefaults.value = snapshot.defaultValues; -+ } -+ detailDraftBaseline = snapshot; -+} -+ -+function renderDetailsPanel({ preserveDirty = false } = {}) { -+ const object = selectedObject(); -+ if (elements.detailEmpty) { -+ elements.detailEmpty.hidden = Boolean(object); -+ } -+ if (elements.detailForm) { -+ elements.detailForm.hidden = !object; -+ } -+ if (!object) { -+ detailDraftBaseline = null; -+ renderDetailDirtyState(); -+ return; -+ } -+ setText(elements.detailSelected, object.name || "Unnamed object"); -+ if (preserveDirty && hasUnsavedDetailChanges()) { -+ renderDetailDirtyState(); -+ return; -+ } -+ populateDetailForm(object); -+ renderDetailDirtyState(); -+} -+ -+function objectDetailFindings(snapshot, originalId = "") { -+ const findings = []; -+ if (!snapshot.name) { -+ findings.push({ -+ action: "Enter a name before saving Object Details.", -+ label: "Name", -+ }); -+ } -+ if (!snapshot.type) { -+ findings.push({ -+ action: "Choose a type before saving Object Details.", -+ label: "Type", -+ }); -+ } -+ const nextId = objectKeyFromText(snapshot.name); -+ if (nextId && draftedObjects.some((object) => objectId(object) === nextId && objectId(object) !== originalId)) { -+ findings.push({ -+ action: "Use a unique name before saving Object Details.", -+ label: "Duplicate Object", -+ }); -+ } -+ if (snapshot.spriteReference && !linkedSpriteAsset(snapshot.spriteReference)) { -+ findings.push({ -+ action: "Use an existing sprite asset reference, or clear the Sprite reference before saving Object Details.", -+ label: "Sprite reference", -+ }); -+ } -+ if (snapshot.audioReference && !linkedAudioAsset(snapshot.audioReference)) { -+ findings.push({ -+ action: "Use an existing audio asset reference, or clear the Audio reference before saving Object Details.", -+ label: "Audio reference", -+ }); -+ } -+ if (snapshot.messageReference && !linkedMessageRecord(snapshot.messageReference)) { -+ findings.push({ -+ action: messageLoadIssue -+ ? "Messages could not be checked. Reload Objects after the API is available, or clear the Message reference before saving Object Details." -+ : "Use an existing message reference, or clear the Message reference before saving Object Details.", -+ label: "Message reference", -+ }); -+ } -+ return findings; -+} -+ -+function objectFromDetailSnapshot(object, snapshot) { -+ const template = templateForType(snapshot.type); -+ const render = snapshot.spriteReference -+ ? { -+ assetKey: snapshot.spriteReference, -+ previewPath: normalizeText(object.render?.previewPath), -+ type: "Sprite", -+ } -+ : { type: "None" }; -+ return cloneObject({ -+ ...object, -+ details: { -+ active: snapshot.active, -+ audioReference: snapshot.audioReference, -+ defaultValues: snapshot.defaultValues, -+ description: snapshot.description, -+ messageReference: snapshot.messageReference, -+ spriteReference: snapshot.spriteReference, -+ tags: snapshot.tags, -+ visible: snapshot.visible, -+ }, -+ id: objectKeyFromText(snapshot.name), -+ name: snapshot.name, -+ render, -+ role: snapshot.type, -+ state: snapshot.active ? "Active" : "Disabled", -+ traits: template ? [...template.capabilities] : object.traits, -+ }); -+} -+ -+function selectDetails(objectKey) { -+ if (editingRow) { -+ setText(elements.log, "Details selection blocked: save or cancel the active row first."); -+ return; -+ } -+ if (hasUnsavedDetailChanges()) { -+ setText(elements.log, "Save or cancel Object Details before selecting another object."); -+ return; -+ } -+ selectedObjectKey = objectKey; -+ renderDetailsPanel(); -+ const object = selectedObject(); -+ setText(elements.log, object ? `Reviewing Object Details for ${object.name}.` : "No object selected."); -+} -+ -+function saveDetails() { -+ const object = selectedObject(); -+ if (!object) { -+ setText(elements.log, "Add an object before saving Object Details."); -+ return; -+ } -+ const originalId = objectId(object); -+ const snapshot = detailSnapshotFromForm(); -+ const findings = objectDetailFindings(snapshot, originalId); -+ if (findings.length > 0) { -+ renderValidation(findings); -+ setText(elements.log, `Object Details blocked: ${findings.length} validation action${findings.length === 1 ? "" : "s"}.`); -+ renderDetailDirtyState(); -+ return; -+ } -+ const nextObject = objectFromDetailSnapshot(object, snapshot); -+ const savedObjects = persistDraftedObjects(draftedObjects.map((savedObject) => ( -+ objectId(savedObject) === originalId ? nextObject : savedObject -+ ))); -+ if (!savedObjects) { -+ return; -+ } -+ draftedObjects = savedObjects; -+ selectedObjectKey = objectId(nextObject); -+ detailDraftBaseline = null; -+ setText(elements.log, `Saved Object Details for ${nextObject.name}.`); -+ render(); -+} -+ -+function cancelDetails() { -+ if (!selectedObject()) { -+ return; -+ } -+ renderDetailsPanel(); -+ setText(elements.log, "Canceled Object Details changes."); -+} -+ - function capabilityLabel(traitId) { - const trait = getObjectModelTrait(traitId); - return CAPABILITY_LABELS[traitId] || trait?.label || traitId; -@@ -509,12 +884,97 @@ function listAssetRecords() { - return Array.isArray(tables?.asset_library_items) ? tables.asset_library_items : []; - } - -+function assetReferenceValues(asset = {}) { -+ return [ -+ asset.id, -+ asset.key, -+ asset.name, -+ asset.fileName, -+ asset.originalName, -+ asset.reference, -+ asset.path, -+ asset.storedPath, -+ asset.storageObjectKey, -+ asset.targetFilePath, -+ ].map(normalizeText).filter(Boolean); -+} -+ -+function assetHasReference(asset, reference) { -+ const key = normalizeText(reference); -+ return Boolean(key) && assetReferenceValues(asset).some((value) => value === key); -+} -+ -+function assetRoleText(asset = {}) { -+ return [ -+ asset.assetRole, -+ asset.assetRoleLabel, -+ asset.assetType, -+ asset.mimeType, -+ asset.role, -+ asset.type, -+ asset.usage, -+ ].map(normalizeText).join(" ").toLowerCase(); -+} -+ -+function isSpriteAsset(asset = {}) { -+ const text = assetRoleText(asset); -+ return text.includes("sprite") || text.includes("image") || text.includes("png") || text.includes("jpeg") || text.includes("webp"); -+} -+ -+function isAudioAsset(asset = {}) { -+ const text = assetRoleText(asset); -+ return text.includes("audio") || text.includes("sound") || text.includes("music") || text.includes("voice") || text.includes("wav") || text.includes("mpeg") || text.includes("ogg"); -+} -+ -+function linkedAssetByReference(reference, predicate) { -+ const key = normalizeText(reference); -+ if (!key) { -+ return null; -+ } -+ return listAssetRecords().find((asset) => assetHasReference(asset, key) && predicate(asset)) || null; -+} -+ - function linkedSpriteAsset(assetKey) { -- const key = normalizeText(assetKey); -+ return linkedAssetByReference(assetKey, isSpriteAsset); -+} -+ -+function linkedAudioAsset(reference) { -+ return linkedAssetByReference(reference, isAudioAsset); -+} -+ -+function listMessageRecords() { -+ if (messageRecords !== null) { -+ return messageRecords; -+ } -+ try { -+ const result = listMessages(); -+ messageRecords = Array.isArray(result?.messages) ? result.messages : []; -+ messageLoadIssue = null; -+ } catch { -+ messageRecords = []; -+ messageLoadIssue = { -+ action: "Messages could not be checked. Reload Objects after the API is available.", -+ label: "Message references", -+ }; -+ } -+ return messageRecords; -+} -+ -+function messageReferenceValues(message = {}) { -+ return [ -+ message.key, -+ message.id, -+ message.name, -+ message.slug, -+ ].map(normalizeText).filter(Boolean); -+} -+ -+function linkedMessageRecord(reference) { -+ const key = normalizeText(reference); - if (!key) { - return null; - } -- return listAssetRecords().find((asset) => asset.id === key) || null; -+ return listMessageRecords().find((message) => messageReferenceValues(message).some((value) => value === key)) || null; - } - - function assetDisplayText(asset, fallbackKey = "") { -@@ -585,6 +1045,9 @@ function readPersistedObjects() { - } - - function persistDraftedObjects(nextObjects) { -+ if (redirectGuestWriteAction()) { -+ return null; -+ } - const normalizedObjects = nextObjects.map(cloneObject); - let result = objectsRepository.replaceObjects(normalizedObjects); - if (result?.error) { -@@ -602,6 +1065,7 @@ function persistDraftedObjects(nextObjects) { - } - - function editingObjectFromRow(row) { -+ const existingObject = draftedObjects.find((object) => objectId(object) === editingRow?.originalId) || {}; - const renderType = row.querySelector("[data-objects-row-render-type]")?.value || "None"; - const render = renderType === "Sprite" - ? { -@@ -612,6 +1076,8 @@ function editingObjectFromRow(row) { - : { type: "None" }; - - return cloneObject({ -+ ...existingObject, -+ id: objectKeyFromText(row.querySelector("[data-objects-row-name]")?.value), - name: row.querySelector("[data-objects-row-name]")?.value, - render, - role: row.querySelector("[data-objects-row-type]")?.value, -@@ -732,6 +1198,7 @@ function renderSavedRow(object) { - const id = objectId(object); - const actions = actionCell([ - actionButton("Edit", "objectsEditRow", id), -+ actionButton("Details", "objectsDetailsRow", id), - ...rowConfigurationActions(object), - actionButton("Trash", "objectsTrashRow", id), - ]); -@@ -822,6 +1289,107 @@ function renderOutput(objects, findings) { - } - } - -+function messageDisplayText(message, fallbackKey = "") { -+ const key = normalizeText(message?.key || message?.id || fallbackKey); -+ const name = normalizeText(message?.name); -+ if (key && name && name !== key) { -+ return `${name} (${key})`; -+ } -+ return name || key || "Message missing"; -+} -+ -+function referenceStatusItem({ -+ action = null, -+ label, -+ linkDataName = "", -+ linkText = "", -+ linkValue = "", -+ linkedText = "", -+ missingText = "", -+ reference, -+ warningName = "", -+}) { -+ const item = document.createElement("li"); -+ const heading = document.createElement("strong"); -+ heading.textContent = `${label}:`; -+ item.append(heading, " "); -+ if (!reference) { -+ item.append("No reference set."); -+ return item; -+ } -+ if (!linkedText) { -+ item.dataset.objectsReferenceWarning = warningName || label; -+ item.append(missingText); -+ if (action) { -+ item.append(" ", actionLink(linkText, action, linkDataName, linkValue)); -+ } -+ return item; -+ } -+ item.append(linkedText); -+ if (action) { -+ item.append(" ", actionLink(linkText, action, linkDataName, linkValue)); -+ } -+ return item; -+} -+ -+function renderAssetLinks() { -+ if (!elements.assetLinks) { -+ return; -+ } -+ elements.assetLinks.replaceChildren(); -+ const object = selectedObject(); -+ if (!object) { -+ elements.assetLinks.append(listItem("Select an object to review sprite, audio, and message links.")); -+ return; -+ } -+ -+ const details = detailsForObject(object); -+ const spriteAsset = details.spriteReference ? linkedSpriteAsset(details.spriteReference) : null; -+ const audioAsset = details.audioReference ? linkedAudioAsset(details.audioReference) : null; -+ const messageRecord = details.messageReference ? linkedMessageRecord(details.messageReference) : null; -+ -+ elements.assetLinks.append( -+ referenceStatusItem({ -+ action: spriteAsset ? spriteEditorHref({ -+ ...object, -+ render: { assetKey: details.spriteReference, type: "Sprite" }, -+ }) : null, -+ label: "Sprite", -+ linkDataName: "objectsAssetLinkSprite", -+ linkText: "Edit Sprite", -+ linkValue: objectId(object), -+ linkedText: spriteAsset ? assetDisplayText(spriteAsset, details.spriteReference) : "", -+ missingText: `Sprite reference "${details.spriteReference}" is not linked to an existing sprite asset.`, -+ reference: details.spriteReference, -+ warningName: "sprite", -+ }), -+ referenceStatusItem({ -+ action: details.audioReference ? "/toolbox/assets/index.html" : null, -+ label: "Audio", -+ linkDataName: "objectsAssetLinkAudio", -+ linkText: "Open Assets", -+ linkValue: normalizeText(audioAsset?.id || details.audioReference), -+ linkedText: audioAsset ? assetDisplayText(audioAsset, details.audioReference) : "", -+ missingText: `Audio reference "${details.audioReference}" is not linked to an existing audio asset.`, -+ reference: details.audioReference, -+ warningName: "audio", -+ }), -+ referenceStatusItem({ -+ action: details.messageReference ? "/toolbox/messages/index.html" : null, -+ label: "Message", -+ linkDataName: "objectsMessageLink", -+ linkText: "Open Messages", -+ linkValue: normalizeText(messageRecord?.key || details.messageReference), -+ linkedText: messageRecord ? messageDisplayText(messageRecord, details.messageReference) : "", -+ missingText: messageLoadIssue -+ ? "Message references could not be checked. Reload Objects after the API is available." -+ : `Message reference "${details.messageReference}" is not linked to an existing Message.`, -+ reference: details.messageReference, -+ warningName: "message", -+ }), -+ ); -+} -+ - function renderTemplateCatalog() { - if (elements.templateCatalog) { - elements.templateCatalog.replaceChildren(); -@@ -841,6 +1409,8 @@ function renderTemplateCatalog() { - elements.templateSelect.append(optionElement(template.type, template.type)); - }); - } -+ -+ populateDetailTypeOptions(elements.detailType?.value || ""); - } - - function renderRegistryBasics() { -@@ -871,6 +1441,8 @@ function render() { - renderObjectList(draftedObjects); - renderOutput(draftedObjects, findings); - renderValidation(findings); -+ renderDetailsPanel({ preserveDirty: true }); -+ renderAssetLinks(); - if (elements.addRow) { - elements.addRow.disabled = Boolean(editingRow); - } -@@ -994,6 +1566,8 @@ function saveRow() { - return; - } - draftedObjects = savedObjects; -+ selectedObjectKey = objectId(object); -+ detailDraftBaseline = null; - setText( - elements.log, - editingRow.mode === "edit" -@@ -1014,6 +1588,10 @@ function trashRow(objectKey) { - return; - } - draftedObjects = savedObjects; -+ if (selectedObjectKey === objectKey) { -+ selectedObjectKey = objectId(draftedObjects[0] || {}); -+ detailDraftBaseline = null; -+ } - } - setText(elements.log, removed ? "Trashed object row." : "Trash skipped: object row was already absent."); - render(); -@@ -1029,6 +1607,8 @@ function seedStarterObjects() { - return; - } - draftedObjects = savedObjects; -+ selectedObjectKey = objectId(draftedObjects[0] || {}); -+ detailDraftBaseline = null; - setText(elements.log, "Seeded starter objects: Hero, Projectile, and Wall."); - render(); - } -@@ -1055,11 +1635,15 @@ function resetTable() { - } - draftedObjects = savedObjects; - editingRow = null; -+ selectedObjectKey = ""; -+ detailDraftBaseline = null; - setText(elements.log, "Reset the Objects table."); - render(); - } - - function refreshLinkedRenderAssetDisplay() { -+ messageRecords = null; -+ messageLoadIssue = null; - if (!editingRow) { - render(); - return; -@@ -1073,6 +1657,12 @@ function refreshLinkedRenderAssetDisplay() { - } - - elements.addRow?.addEventListener("click", addRow); -+elements.detailCancel?.addEventListener("click", cancelDetails); -+elements.detailSave?.addEventListener("click", saveDetails); -+detailFieldElements().forEach((field) => { -+ field.addEventListener("input", renderDetailDirtyState); -+ field.addEventListener("change", renderDetailDirtyState); -+}); - elements.seedStarter?.addEventListener("click", seedStarterObjects); - elements.validate?.addEventListener("click", validateObjects); - elements.resetTable?.addEventListener("click", resetTable); -@@ -1100,6 +1690,8 @@ elements.list?.addEventListener("click", (event) => { - cancelRow(); - } else if (button.dataset.objectsEditRow !== undefined) { - editRow(button.dataset.objectsEditRow || ""); -+ } else if (button.dataset.objectsDetailsRow !== undefined) { -+ selectDetails(button.dataset.objectsDetailsRow || ""); - } else if (button.dataset.objectsTrashRow !== undefined) { - trashRow(button.dataset.objectsTrashRow || ""); - } -diff --git a/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_branch-validation.md b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_branch-validation.md -new file mode 100644 -index 000000000..292777e45 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_branch-validation.md -@@ -0,0 +1,10 @@ -+# PR_26179_ALFA_011 Branch Validation -+ -+| Check | Result | Evidence | -+| --- | --- | --- | -+| Project Instructions read | PASS | `dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md` and `PROJECT_INSTRUCTIONS.md` read before implementation. | -+| Branch | PASS | Work performed on `PR_26179_ALFA_011-objects-manager-mvp`. | -+| Main sync before branch | PASS | `main...origin/main` was `0 0` before branch creation. | -+| Canonical report path | PASS | Reports generated under `dev/reports/`. | -+| Canonical ZIP path | PASS | ZIP generated under `dev/workspace/zips/`. | -+| Legacy output paths avoided | PASS | No new `docs_build/` or `tmp/` artifacts used for this PR. | -diff --git a/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_manual-validation-notes.md b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_manual-validation-notes.md -new file mode 100644 -index 000000000..c6433377b ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_manual-validation-notes.md -@@ -0,0 +1,19 @@ -+# PR_26179_ALFA_011 Manual Validation Notes -+ -+## Recommended Owner Checks -+ -+1. Start the local API/site using the approved developer command. -+2. Open `toolbox/objects/index.html`. -+3. Confirm Objects loads the Object Builder table. -+4. Sign in as a Creator. -+5. Add a Hero object and save. -+6. Refresh the page and confirm the Hero remains. -+7. Edit the object, save, refresh, and confirm the edit remains. -+8. Delete the object, refresh, and confirm the table is empty. -+9. Click Seed Starter Objects and confirm Hero, Projectile, and Wall appear. -+10. Sign out, attempt a save, and confirm redirect to `account/sign-in.html`. -+ -+## Notes -+ -+- No fallback test lane was required because targeted validation passed. -+- No runtime data is owned by the browser page. -diff --git a/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_report.md b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_report.md -new file mode 100644 -index 000000000..e9a5b3d16 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_report.md -@@ -0,0 +1,39 @@ -+# PR_26179_ALFA_011 Objects Manager MVP Report -+ -+## Product Owner Testable Outcome -+ -+The Objects tool now saves and reloads object rows through the server API/database contract. A signed-in Creator can add, edit, delete, seed starter objects, validate setup, reload the page, and see persisted Objects data for the current Game Hub game. -+ -+## Implementation Summary -+ -+- Added a DB-backed Objects API service for `object_definition_records`. -+- Routed the active Objects tool repository away from the mock repository and into the shared API service path. -+- Preserved the browser as an API client only; the server owns record keys, game scoping, and audit fields. -+- Added guest write protection so create/update/delete actions redirect to `account/sign-in.html`. -+- Kept the existing Theme V2 Objects UI and table workflow intact. -+ -+## What Can The Product Owner Test After Applying This ZIP? -+ -+Open `toolbox/objects/index.html`, sign in as a Creator, add an object, save it, refresh the page, edit it, delete it, seed starter objects, and confirm the rows persist through the API/database path. As a guest, attempt to save a row and confirm the browser redirects to sign-in. -+ -+## Playwright Coverage -+ -+- `dev/tests/playwright/tools/ObjectsTool.spec.mjs` -+- Covers production copy, object table workflow, guest redirect, persisted add/edit/delete, sprite-linked rows, catalog prefills, and Toolbox beta routing. -+ -+## Manual Validation -+ -+- Open Objects from Toolbox. -+- Confirm the Object Builder table loads without console errors. -+- Add a Hero object and save. -+- Refresh and confirm the Hero remains. -+- Edit the object name/state and save. -+- Refresh and confirm edits remain. -+- Delete the object and refresh. -+- Sign out, try to save, and confirm redirect to `account/sign-in.html`. -+ -+## Stack Context -+ -+- Part of stacked MVP sequence: yes. -+- Previous PR dependency: `PR_26179_ALFA_010-objects-inventory-audit`. -+- Next PR dependency: `PR_26179_ALFA_012-objects-properties-mvp`. -diff --git a/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_requirement-checklist.md b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_requirement-checklist.md -new file mode 100644 -index 000000000..91c048d12 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_requirement-checklist.md -@@ -0,0 +1,16 @@ -+# PR_26179_ALFA_011 Requirement Checklist -+ -+| Requirement | Result | Notes | -+| --- | --- | --- | -+| Implement Objects Manager MVP exactly as described | PASS | Objects Manager now supports API/database-backed add, edit, delete, seed, validate, reload persistence. | -+| Use Local API / Local DB contract | PASS | Browser uses `createServerRepositoryClient("objects")`; server routes Objects to `createObjectsApiService`. | -+| Browser must not own product data | PASS | Browser submits object rows to API; persistence, keys, game scoping, and audit fields are server-owned. | -+| No page-local product data arrays | PASS | No authoritative object records are stored in page-local arrays; page state mirrors API data only. | -+| No silent fallbacks | PASS | Missing setup raises controlled Objects API setup errors instead of returning fake rows. | -+| Use Theme V2 classes first | PASS | Existing Theme V2 tool layout and button/table classes remain in use. | -+| No inline styles/scripts/events | PASS | No inline styles, script blocks, or inline event handlers were added. | -+| Targeted Playwright for Objects Manager | PASS | Objects Playwright lane passed 7/7. | -+| `git diff --check` | PASS | Whitespace check passed. | -+| `npm run validate:canonical-structure` | PASS | Canonical structure guardrail passed. | -+| Fallback `npm run test:workspace-v2` | PASS | Not required because targeted validation passed. | -+| Required reports and ZIP | PASS | Reports created under `dev/reports/`; ZIP created under `dev/workspace/zips/`. | -diff --git a/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_validation-lane.md b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_validation-lane.md -new file mode 100644 -index 000000000..34e8316bd ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_validation-lane.md -@@ -0,0 +1,17 @@ -+# PR_26179_ALFA_011 Validation Lane -+ -+## Lane -+ -+Objects Manager focused lane. -+ -+## Coverage -+ -+- Service-level DB adapter behavior for `createObjectsApiService`. -+- Browser Objects workflow against the shared API route. -+- Guest write redirect. -+- Reload persistence. -+- Toolbox registry visibility for Objects beta. -+ -+## Result -+ -+PASS. Objects API service tests passed 3/3. Objects Playwright tests passed 7/7. -diff --git a/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_validation-report.md b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_validation-report.md -new file mode 100644 -index 000000000..df6ba49a3 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_011-objects-manager-mvp_validation-report.md -@@ -0,0 +1,19 @@ -+# PR_26179_ALFA_011 Validation Report -+ -+## Commands -+ -+| Command | Result | -+| --- | --- | -+| `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` | PASS | -+| `node --check src/dev-runtime/server/local-api-router.mjs` | PASS | -+| `node --check assets/toolbox/objects/js/index.js` | PASS | -+| `node --check dev/tests/dev-runtime/ObjectsApiService.test.mjs` | PASS | -+| `node --check dev/tests/playwright/tools/ObjectsTool.spec.mjs` | PASS | -+| `node ./dev/scripts/run-node-test-files.mjs dev/tests/dev-runtime/ObjectsApiService.test.mjs` | PASS, 3/3 | -+| `git diff --check` | PASS | -+| `npm run validate:canonical-structure` | PASS | -+| `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs --config=dev/config/playwright.config.cjs --project=playwright --workers=1 --reporter=list` | PASS, 7/7 | -+ -+## Result -+ -+PASS. Required targeted validation passed. `npm run test:workspace-v2` fallback was not required. -diff --git a/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_branch_validation.md b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_branch_validation.md -new file mode 100644 -index 000000000..0855cfc52 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_branch_validation.md -@@ -0,0 +1,26 @@ -+# PR_26179_ALFA_012 Branch Validation -+ -+Branch: PR_26179_ALFA_012-objects-properties-mvp -+Base stack: PR_26179_ALFA_011-objects-manager-mvp -+ -+## Gates -+- Current branch is PR_26179_ALFA_012-objects-properties-mvp: PASS -+- Project Instructions loaded from `dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md`: PASS -+- Canonical report path `dev/reports/`: PASS -+- Canonical ZIP path `dev/workspace/zips/`: PASS -+- No `docs_build/` report output created: PASS -+- No `tmp/` ZIP output created: PASS -+- One PR purpose only: PASS -+- No API architecture change: PASS -+ -+## Validation Commands -+- `node --check assets/toolbox/objects/js/index.js`: PASS -+- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs`: PASS -+- `node --check dev/tests/playwright/tools/ObjectsTool.spec.mjs`: PASS -+- `node --test dev/tests/dev-runtime/ObjectsApiService.test.mjs`: PASS -+- `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs -g "Object Details panel"`: PASS -+- `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs`: PASS -+- `git diff --check`: PASS -+- `npm run validate:canonical-structure`: PASS -+ -+Branch validation: PASS -diff --git a/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_manual_validation_notes.md b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_manual_validation_notes.md -new file mode 100644 -index 000000000..8577afa84 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_manual_validation_notes.md -@@ -0,0 +1,22 @@ -+# PR_26179_ALFA_012 Manual Validation Notes -+ -+## Manual Review Path -+1. Open `toolbox/objects/index.html` with the API server running. -+2. Add an object from the Objects table. -+3. Select `Details` on the object row. -+4. Confirm Object Details shows Name, Description, Type, Tags, Active, Visible, Sprite reference, Audio reference, and Default values. -+5. Edit Description and confirm `Unsaved changes: yes` appears. -+6. Press Cancel and confirm the field resets and dirty state clears. -+7. Clear Name and press Save Details; confirm the friendly Name validation message appears. -+8. Enter valid details and press Save Details. -+9. Refresh the page; confirm saved details reload from the API-backed object record. -+ -+## Expected Owner Review Result -+The panel is reviewable as a normal product form. It does not expose a behavior editor, Rules, Worlds, JSON editor, or engine internals. -+ -+## Known Out-of-Scope Items -+- Behavior editor. -+- Rules integration. -+- Worlds integration. -+- Database schema expansion. -+- New API routes. -diff --git a/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_report.md b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_report.md -new file mode 100644 -index 000000000..67433e20b ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_report.md -@@ -0,0 +1,40 @@ -+# PR_26179_ALFA_012-objects-properties-mvp Report -+ -+## Summary -+Implemented the Objects Object Details panel as the PR012 vertical slice. The panel lets a signed-in Creator review and edit Name, Description, Type, Tags, Active, Visible, Sprite reference, Audio reference, and Default values for the selected object. -+ -+## Scope -+- Added Object Details inspector form to `toolbox/objects/index.html`. -+- Added detail selection, unsaved-change detection, save/cancel behavior, required-field validation, duplicate-name validation, and sprite-reference validation to `assets/toolbox/objects/js/index.js`. -+- Persisted details through the existing Objects API service and existing `object_definition_records` JSON payloads without adding routes, tables, or new API architecture. -+- Updated Objects service tests to prove details round-trip through the database adapter. -+- Updated Objects Playwright tests to cover the human-testable details flow and refresh persistence. -+ -+## Architecture Notes -+- No behavior editor added. -+- No Rules integration added. -+- No Worlds integration added. -+- No browser-owned product data added. -+- No page-local product data source of truth added. -+- Object Details are persisted through the existing API-backed Objects service from PR011. -+- Review-only details are stripped before engine object-definition validation so engine schema remains unchanged. -+ -+## Validation Outcome -+PASS. Final targeted validation completed successfully. -+ -+## Corrective Notes From Validation -+- Initial Playwright run exposed engine validation rejecting the new review-only `details` field. Fixed by validating a stripped engine-definition shape. -+- Initial details save exposed nonexistent sprite references reaching DB persistence. Fixed by adding a friendly Sprite reference validation message. -+- Row/details rename saves preserved the old object slug. Fixed by regenerating object ids from the edited name. -+ -+## Changed Runtime Surface -+- Objects page Object Details inspector. -+- Objects browser controller. -+- Objects API service serialization for existing object records. -+ -+## Not Changed -+- API routes. -+- Database schema. -+- Behavior editor. -+- Rules or Worlds integrations. -+- Engine object model contract. -diff --git a/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_requirement_checklist.md b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_requirement_checklist.md -new file mode 100644 -index 000000000..f1dedcfbe ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_requirement_checklist.md -@@ -0,0 +1,23 @@ -+# PR_26179_ALFA_012 Requirement Checklist -+ -+- Complete Object Details panel using existing Objects API persistence from PR011: PASS -+- Do not modify API architecture: PASS -+- Focus on Product Owner reviewability: PASS -+- Name property: PASS -+- Description property: PASS -+- Type property: PASS -+- Tags property: PASS -+- Active property: PASS -+- Visible property: PASS -+- Sprite reference property: PASS -+- Audio reference property: PASS -+- Default values property: PASS -+- Required-field validation: PASS -+- Friendly validation messages: PASS -+- Save behavior: PASS -+- Cancel behavior: PASS -+- Unsaved-change detection: PASS -+- No behavior editor: PASS -+- No Rules integration: PASS -+- No Worlds integration: PASS -+- One PR purpose only: PASS -diff --git a/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_validation_lane.md b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_validation_lane.md -new file mode 100644 -index 000000000..56167a54b ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_validation_lane.md -@@ -0,0 +1,23 @@ -+# PR_26179_ALFA_012 Validation Lane Report -+ -+Lane: Objects tool focused validation -+ -+## Commands -+1. `node --check assets/toolbox/objects/js/index.js` -+2. `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` -+3. `node --check dev/tests/playwright/tools/ObjectsTool.spec.mjs` -+4. `node --test dev/tests/dev-runtime/ObjectsApiService.test.mjs` -+5. `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs -g "Object Details panel"` -+6. `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs` -+7. `git diff --check` -+8. `npm run validate:canonical-structure` -+ -+## Result -+PASS -+ -+## Impacted Coverage -+- Objects API service database adapter round-trip. -+- Objects table behavior. -+- Object Details panel properties, validation, save/cancel, dirty state, and refresh persistence. -+- Guest write redirect coverage retained. -+- Sprite asset link behavior retained. -diff --git a/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_validation_report.md b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_validation_report.md -new file mode 100644 -index 000000000..064660e33 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_012-objects-properties-mvp_validation_report.md -@@ -0,0 +1,18 @@ -+# PR_26179_ALFA_012 Validation Report -+ -+## Final Targeted Validation -+ -+PASS: `node --check assets/toolbox/objects/js/index.js` -+PASS: `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` -+PASS: `node --check dev/tests/playwright/tools/ObjectsTool.spec.mjs` -+PASS: `node --test dev/tests/dev-runtime/ObjectsApiService.test.mjs` -+PASS: `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs -g "Object Details panel"` -+PASS: `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs` -+PASS: `git diff --check` -+PASS: `npm run validate:canonical-structure` -+ -+## Playwright Coverage -+The focused Objects Playwright suite passed 8/8 tests after fixes. -+ -+## Notes -+The first browser run intentionally remained in this report history because it caught useful defects before final packaging. Those defects were fixed and final validation passed. -diff --git a/dev/reports/PR_26179_ALFA_013-objects-asset-links_branch_validation.md b/dev/reports/PR_26179_ALFA_013-objects-asset-links_branch_validation.md -new file mode 100644 -index 000000000..a4d9e7204 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_013-objects-asset-links_branch_validation.md -@@ -0,0 +1,27 @@ -+# PR_26179_ALFA_013 Branch Validation -+ -+Branch: PR_26179_ALFA_013-objects-asset-links -+Base stack: PR_26179_ALFA_012-objects-properties-mvp -+ -+## Gates -+- Current branch is PR_26179_ALFA_013-objects-asset-links: PASS -+- Project Instructions loaded from `dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md`: PASS -+- Batch governance addendum loaded from `dev/build/ProjectInstructions/addendums/batch_governance_mode.md`: PASS -+- Canonical report path `dev/reports/`: PASS -+- Canonical ZIP path `dev/workspace/zips/`: PASS -+- No `docs_build/` report output created: PASS -+- No `tmp/` ZIP output created: PASS -+- One PR purpose only: PASS -+- No API architecture change: PASS -+ -+## Validation Commands -+- `node --check assets/toolbox/objects/js/index.js`: PASS -+- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs`: PASS -+- `node --check dev/tests/playwright/tools/ObjectsTool.spec.mjs`: PASS -+- `node --test dev/tests/dev-runtime/ObjectsApiService.test.mjs`: PASS -+- `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs --grep "Object Details panel saves reviewable properties through shared DB"`: PASS -+- `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs`: PASS -+- `git diff --check`: PASS -+- `npm run validate:canonical-structure`: PASS -+ -+Branch validation: PASS -diff --git a/dev/reports/PR_26179_ALFA_013-objects-asset-links_manual_validation_notes.md b/dev/reports/PR_26179_ALFA_013-objects-asset-links_manual_validation_notes.md -new file mode 100644 -index 000000000..b9e40bc90 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_013-objects-asset-links_manual_validation_notes.md -@@ -0,0 +1,24 @@ -+# PR_26179_ALFA_013 Manual Validation Notes -+ -+## Manual Review Path -+1. Open `toolbox/objects/index.html` with the API server running. -+2. Add an object with Sprite render enabled. -+3. Select `Details` on the object row. -+4. Confirm Object Details includes Sprite reference, Audio reference, and Message reference. -+5. Confirm the Asset Links panel shows the selected object's sprite, audio, and message statuses. -+6. Enter a missing audio or message reference and press Save Details. -+7. Confirm friendly validation appears without exposing API/database internals. -+8. Enter an existing message reference and save. -+9. Refresh the page; confirm the message reference reloads and the Asset Links panel resolves it. -+ -+## Expected Owner Review Result -+The Objects inspector is reviewable as a product surface for asset and message references. It keeps object details, reference links, and missing-reference guidance in one place without exposing a behavior editor, Rules integration, Worlds integration, JSON editor, or engine internals. -+ -+## Known Out-of-Scope Items -+- Creating new audio assets from Objects. -+- Creating new messages from Objects. -+- Behavior editor. -+- Rules integration. -+- Worlds integration. -+- Database schema expansion. -+- New API routes. -diff --git a/dev/reports/PR_26179_ALFA_013-objects-asset-links_report.md b/dev/reports/PR_26179_ALFA_013-objects-asset-links_report.md -new file mode 100644 -index 000000000..7caafd383 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_013-objects-asset-links_report.md -@@ -0,0 +1,41 @@ -+# PR_26179_ALFA_013-objects-asset-links Report -+ -+## Summary -+Improved the Objects inspector so Product Owners can review object sprite, audio, and message references from the selected object without adding behavior editing or changing the existing Objects API architecture. -+ -+## Scope -+- Added a Message reference field to the existing Object Details form in `toolbox/objects/index.html`. -+- Added an Asset Links accordion that reviews the selected object's Sprite, Audio, and Message references. -+- Linked valid sprite references to Sprite Editor and valid message references to Messages. -+- Added friendly missing-reference warnings for stale sprite, audio, and message references. -+- Persisted `messageReference` through the existing Objects service details payload. -+- Updated focused Objects API and Playwright validation. -+ -+## Architecture Notes -+- Objects continues to use the existing Browser -> API -> Database path. -+- No new API routes were added. -+- No database schema changes were added. -+- No browser-owned product data was added. -+- No page-local product data arrays were added. -+- No behavior editor was added. -+- No Rules integration was added. -+- No Worlds integration was added. -+ -+## Validation Outcome -+PASS. Final targeted and impacted validation completed successfully. -+ -+## Corrective Notes From Validation -+- Initial Playwright setup tried to create an audio upload asset and hit a storage-provider setup failure unrelated to Objects. The automated lane now validates audio missing-reference messaging while keeping the runtime resolver ready for existing audio asset records. -+- Initial Messages setup missed the required emotion profile key. The test now creates a TTS profile and passes the returned emotion profile key through the Messages API. -+ -+## Changed Runtime Surface -+- Objects page inspector. -+- Objects browser controller. -+- Objects API service serialization for existing object details payloads. -+ -+## Not Changed -+- Objects repository contract. -+- API architecture. -+- Database schema. -+- Behavior editor. -+- Rules or Worlds integrations. -diff --git a/dev/reports/PR_26179_ALFA_013-objects-asset-links_requirement_checklist.md b/dev/reports/PR_26179_ALFA_013-objects-asset-links_requirement_checklist.md -new file mode 100644 -index 000000000..d8fea7ece ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_013-objects-asset-links_requirement_checklist.md -@@ -0,0 +1,15 @@ -+# PR_26179_ALFA_013 Requirement Checklist -+ -+- Improve Object asset/reference linking for Product Owner review: PASS -+- Link graphics/sprite references: PASS -+- Link audio/message references: PASS -+- Show friendly missing-reference warnings: PASS -+- Preserve existing Objects API architecture: PASS -+- Do not add behavior editor: PASS -+- Do not add Rules integration: PASS -+- Do not add Worlds integration: PASS -+- No unrelated cleanup: PASS -+- Use current Project Instructions: PASS -+- Use canonical `dev/reports/` reports path: PASS -+- Use canonical `dev/workspace/zips/` ZIP path: PASS -+- Return repo-structured ZIP: PASS -diff --git a/dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_lane.md b/dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_lane.md -new file mode 100644 -index 000000000..7a6f29a6b ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_lane.md -@@ -0,0 +1,23 @@ -+# PR_26179_ALFA_013 Validation Lane Report -+ -+Lane: Objects tool focused validation -+ -+## Commands -+1. `node --check assets/toolbox/objects/js/index.js` -+2. `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` -+3. `node --check dev/tests/playwright/tools/ObjectsTool.spec.mjs` -+4. `node --test dev/tests/dev-runtime/ObjectsApiService.test.mjs` -+5. `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs --grep "Object Details panel saves reviewable properties through shared DB"` -+6. `npx playwright test dev/tests/playwright/tools/ObjectsTool.spec.mjs` -+7. `git diff --check` -+8. `npm run validate:canonical-structure` -+ -+## Result -+PASS -+ -+## Impacted Coverage -+- Objects API service database adapter round-trip for `messageReference`. -+- Objects Object Details panel save/cancel/dirty state retained. -+- Selected object Asset Links review for sprite, audio, and message references. -+- Friendly missing-reference validation for audio and message references. -+- Existing guest write redirect and sprite asset link behavior retained. -diff --git a/dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_report.md b/dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_report.md -new file mode 100644 -index 000000000..82aa33596 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_report.md -@@ -0,0 +1,16 @@ -+# PR_26179_ALFA_013 Validation Report -+ -+## Result -+PASS -+ -+## Validation Performed -+- Static JS/MJS syntax checks passed. -+- Objects API service test passed. -+- Focused Object Details Playwright scenario passed. -+- Full Objects Playwright spec passed: 8 passed. -+- Canonical repository structure validation passed. -+- Git whitespace validation passed. -+ -+## Notes -+- The audio upload creation path depends on storage-provider setup and was not used as a PR013 fixture. Runtime audio reference resolution remains implemented against existing shared Assets records, and missing audio references render friendly action text. -+- The Messages fixture uses the Messages API to create a TTS profile and message record, then verifies Objects can resolve the message reference after save and refresh. -diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md -new file mode 100644 -index 000000000..5ba8f9771 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md -@@ -0,0 +1,24 @@ -+# PR_26179_ALFA_014 Branch Validation -+ -+Branch: PR_26179_ALFA_014-objects-journey-readiness -+Base stack: PR_26179_ALFA_013-objects-asset-links -+ -+## Gates -+- Current branch is PR_26179_ALFA_014-objects-journey-readiness: PASS -+- Project Instructions loaded from `dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md`: PASS -+- Batch governance addendum loaded from `dev/build/ProjectInstructions/addendums/batch_governance_mode.md`: PASS -+- Canonical report path `dev/reports/`: PASS -+- Canonical ZIP path `dev/workspace/zips/`: PASS -+- No `docs_build/` report output created: PASS -+- No `tmp/` ZIP output created: PASS -+- One PR purpose only: PASS -+- Existing Objects API architecture preserved: PASS -+ -+## Validation Commands -+- `node --check assets/toolbox/game-journey/js/index.js`: PASS -+- `node --check dev/tests/playwright/tools/GameJourneyTool.spec.mjs`: PASS -+- `npx playwright test dev/tests/playwright/tools/GameJourneyTool.spec.mjs --grep "progress dashboard|Objects readiness"`: PASS -+- `git diff --check`: PASS -+- `npm run validate:canonical-structure`: PASS -+ -+Branch validation: PASS -diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_eod_report.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_eod_report.md -new file mode 100644 -index 000000000..f16f2e700 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_eod_report.md -@@ -0,0 +1,27 @@ -+# PR_26179_ALFA_014 Objects Journey Readiness EOD Report -+ -+## Branch -+- Branch: PR_26179_ALFA_014-objects-journey-readiness -+- Base stack dependency: PR_26179_ALFA_013-objects-asset-links -+- Implementation commit before EOD notes: f34da790f38942aa048a8794b7947c450b7e4659 -+ -+## EOD Status -+- Objects Journey Readiness implementation: complete -+- Required PR014 reports: present -+- Product Owner review notes for Objects stack: prepared -+- Repo-structured ZIP: rebuilt under `dev/workspace/zips/` -+- Worktree before EOD notes: clean -+- Worlds work: not started -+ -+## Fresh Validation -+- `node --check assets/toolbox/game-journey/js/index.js`: PASS -+- `node --check dev/tests/playwright/tools/GameJourneyTool.spec.mjs`: PASS -+- `npx playwright test dev/tests/playwright/tools/GameJourneyTool.spec.mjs --grep "progress dashboard|Objects readiness"`: PASS, 2 tests passed -+- `git diff --check`: PASS -+- `npm run validate:canonical-structure`: PASS -+ -+## Review Readiness -+PR014 is ready for Product Owner review as the final Objects stack readiness PR. The Product Owner should review the stack in order: PR011, PR012, PR013, PR014. -+ -+## Merge Note -+Do not start Worlds until Product Owner review of the completed Objects stack is complete. -diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md -new file mode 100644 -index 000000000..56ecf33af ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md -@@ -0,0 +1,14 @@ -+# PR_26179_ALFA_014 Manual Validation Notes -+ -+## Manual Review Path -+1. Open Game Journey for the active Demo Game. -+2. Confirm Journey Progress Dashboard renders normally when there are no saved Objects. -+3. Save a review-ready Object for the same Game Hub game in Objects. -+4. Return to Game Journey and confirm Objects Stage Readiness shows the criteria table and Product Owner checklist. -+5. Confirm the Objects completion bucket reflects meaningful Objects work while the fullscreen, Rules, Worlds, and behavior editor areas remain unchanged. -+ -+## Automated Proxy -+The focused Playwright lane performed the API-backed setup, loaded Game Journey, verified readiness copy, checked the Objects bucket, reloaded the page, and verified persisted display. -+ -+## Result -+PASS. No manual-only blockers identified. -diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md -new file mode 100644 -index 000000000..988365739 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md -@@ -0,0 +1,34 @@ -+# PR_26179_ALFA_014-objects-journey-readiness Report -+ -+## Summary -+Added Game Journey visibility for Objects stage readiness so Product Owners can see whether the current Game Hub game has meaningful Objects work ready for review. -+ -+## Scope -+- Added an Objects Stage Readiness accordion to `toolbox/game-journey/index.html`. -+- Read current-game Objects through the existing Objects API repository from `assets/toolbox/game-journey/js/index.js`. -+- Defined five readiness criteria: saved objects, name/type/state, player-facing interactivity, sprite reference coverage, and Product Owner review details. -+- Added a Product Owner review checklist for the Objects stage. -+- Updated the Journey progress dashboard display so completed Objects readiness criteria can raise the Objects bucket progress without writing Journey metrics. -+- Added targeted Playwright coverage for no-Objects baseline progress and review-ready Objects progress. -+ -+## Architecture Notes -+- Objects data continues through Browser -> API -> Database. -+- Game Journey consumes Objects as read-only API data for progress display. -+- No Objects API architecture changes were made. -+- No page-local product data arrays or browser-owned product data were added. -+- No Rules integration was added. -+- No Worlds integration was added. -+- No behavior editor was added. -+ -+## Validation Outcome -+PASS. Targeted Journey validation, syntax checks, diff check, and canonical structure validation completed successfully. -+ -+## Changed Runtime Surface -+- Game Journey inspector UI. -+- Game Journey browser controller progress display. -+ -+## Not Changed -+- Objects API contract. -+- Objects persistence. -+- Game Journey completion metrics persistence. -+- Rules, Worlds, or behavior editor flows. -diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md -new file mode 100644 -index 000000000..3d4a45113 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md -@@ -0,0 +1,17 @@ -+# PR_26179_ALFA_014 Requirement Checklist -+ -+- Define Objects stage readiness criteria: PASS -+- Update Journey progress when Objects has meaningful completion: PASS -+- Add Product Owner review checklist for Objects stage: PASS -+- Preserve existing Objects API architecture: PASS -+- Do not add Rules integration: PASS -+- Do not add Worlds integration: PASS -+- Do not add behavior editor: PASS -+- Use Browser -> API -> Database data flow: PASS -+- Avoid browser-owned product data: PASS -+- Avoid page-local product data arrays: PASS -+- Use current Game Hub game context: PASS -+- Use current Project Instructions: PASS -+- Use canonical `dev/reports/` reports path: PASS -+- Use canonical `dev/workspace/zips/` ZIP path: PASS -+- Return repo-structured ZIP: PASS -diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md -new file mode 100644 -index 000000000..c561a478a ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md -@@ -0,0 +1,20 @@ -+# PR_26179_ALFA_014 Validation Lane -+ -+Lane: Focused Game Journey Objects readiness -+ -+## Coverage -+- Game Journey progress dashboard baseline with no Objects rows. -+- Game Journey Objects Stage Readiness accordion. -+- Current-game Object persistence through the Objects API repository. -+- Product Owner Objects checklist rendering. -+- Objects readiness contribution to the Journey Objects completion bucket. -+- Reload persistence of Journey readiness display. -+ -+## Command -+`npx playwright test dev/tests/playwright/tools/GameJourneyTool.spec.mjs --grep "progress dashboard|Objects readiness"` -+ -+## Result -+PASS. 2 tests passed. -+ -+## Follow-up -+None for this PR. -diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md -new file mode 100644 -index 000000000..1abd35fed ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md -@@ -0,0 +1,19 @@ -+# PR_26179_ALFA_014 Validation Report -+ -+## Commands Run -+- `node --check assets/toolbox/game-journey/js/index.js` -+- `node --check dev/tests/playwright/tools/GameJourneyTool.spec.mjs` -+- `npx playwright test dev/tests/playwright/tools/GameJourneyTool.spec.mjs --grep "progress dashboard|Objects readiness"` -+- `git diff --check` -+- `npm run validate:canonical-structure` -+- `npm run codex:review-artifacts` -+ -+## Results -+- JavaScript syntax checks: PASS -+- Focused Game Journey Playwright lane: PASS, 2 tests passed -+- Diff whitespace check: PASS -+- Canonical repository structure guardrail: PASS -+- Review artifacts generated: PASS -+ -+## Notes -+The Playwright lane verifies that Game Journey keeps the baseline progress at 0 when no Objects are saved, then reflects a persisted review-ready Object as 5 of 5 Objects criteria complete and 100% Objects bucket progress after reload. -diff --git a/dev/reports/PR_26179_ALFA_objects-stack_product-owner-review-notes.md b/dev/reports/PR_26179_ALFA_objects-stack_product-owner-review-notes.md -new file mode 100644 -index 000000000..38f2e22c5 ---- /dev/null -+++ b/dev/reports/PR_26179_ALFA_objects-stack_product-owner-review-notes.md -@@ -0,0 +1,87 @@ -+# Product Owner Review Notes - Objects Stack -+ -+## Stack Under Review -+- PR_26179_ALFA_011 Objects Manager -+- PR_26179_ALFA_012 Object Properties -+- PR_26179_ALFA_013 Object Asset Links -+- PR_26179_ALFA_014 Objects Journey Readiness -+ -+## Review Goal -+Confirm the Objects MVP is Product Owner testable from object creation through Journey readiness without starting Worlds, Rules, or behavior-editor work. -+ -+## PR011 - Objects Manager -+Product Owner testable outcome: -+- Open `toolbox/objects/index.html`. -+- Sign in as a Creator. -+- Add an object row with a name, type, state, render mode, and capabilities. -+- Save, refresh, and confirm the object remains for the current Game Hub game. -+- Edit and delete the object. -+- Sign out, attempt to save, and confirm redirect to `account/sign-in.html`. -+ -+Acceptance checks: -+- Objects load through the API/database path. -+- Browser does not own product data. -+- Guest writes redirect to sign-in. -+- Object rows persist after refresh. -+ -+## PR012 - Object Properties -+Product Owner testable outcome: -+- Select an object and open Object Details. -+- Edit Name, Description, Type, Tags, Active, Visible, Sprite reference, Audio reference, and Default values. -+- Confirm required-field validation and friendly messages. -+- Confirm Save and Cancel behavior. -+- Confirm unsaved-change messaging appears only when edits are pending. -+- Refresh and confirm saved details persist. -+ -+Acceptance checks: -+- Details persist through the existing Objects API/database service. -+- Duplicate or invalid details are blocked with friendly messages. -+- No behavior editor, Rules integration, or Worlds integration appears. -+ -+## PR013 - Object Asset Links -+Product Owner testable outcome: -+- Select an object with sprite, audio, or message references. -+- Review the Asset Links section. -+- Confirm valid sprite references link to Sprite Editor. -+- Confirm valid message references link to Messages. -+- Confirm missing sprite, audio, and message references show friendly warnings. -+ -+Acceptance checks: -+- Reference review is readable without internal implementation wording. -+- Missing references are surfaced as review guidance, not runtime errors. -+- Existing Objects API architecture is preserved. -+- No new Rules, Worlds, or behavior-editor scope appears. -+ -+## PR014 - Objects Journey Readiness -+Product Owner testable outcome: -+- Open Game Journey for the current Game Hub game. -+- Review the Objects Stage Readiness accordion. -+- Confirm readiness criteria list saved objects, name/type/state coverage, player-facing interactivity, sprite reference coverage, and Product Owner review details. -+- Confirm the Product Owner review checklist is present. -+- Confirm Journey progress reflects meaningful Objects completion when Objects are review-ready. -+- Refresh and confirm readiness display remains stable. -+ -+Acceptance checks: -+- Journey consumes Objects as read-only API data. -+- Journey does not create, edit, or duplicate Objects logic. -+- Journey progress display updates without writing new Journey completion metrics. -+- No Rules, Worlds, or behavior-editor work starts. -+ -+## End-to-End Review Flow -+1. Open Game Hub and confirm the active game. -+2. Open Objects and save a review-ready object. -+3. Edit Object Details and save review context. -+4. Verify asset/reference warnings or links. -+5. Open Game Journey for the same game. -+6. Confirm Objects Stage Readiness and the Objects progress bucket. -+7. Record any Product Owner findings before Worlds begins. -+ -+## Explicit Non-Scope For This Stack -+- Worlds integration: not started. -+- Rules integration: not started. -+- Behavior editor: not started. -+- Collision configuration beyond current Objects metadata: not started. -+- Engine runtime object instantiation work: not started. -+ -+## Product Owner Decision Needed -+Product Owner review should decide whether the Objects MVP is accepted as testable before Alfa proceeds to any Worlds-related work. -diff --git a/dev/reports/codex_changed_files.txt b/dev/reports/codex_changed_files.txt -index 8889f7d86..d0c49a466 100644 ---- a/dev/reports/codex_changed_files.txt -+++ b/dev/reports/codex_changed_files.txt -@@ -1,6 +1,49 @@ --dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md --dev/reports/PR_26180_OWNER_002-add-delta-to-canonical-active-teams_report.md --dev/reports/PR_26180_OWNER_002-add-delta-to-canonical-active-teams_validation-checklist.md --dev/reports/PR_26180_OWNER_002-add-delta-to-canonical-active-teams_manual-validation-notes.md --dev/reports/codex_changed_files.txt --dev/reports/codex_review.diff -+# PR014 artifact base -+Base ref: PR_26179_ALFA_013-objects-asset-links -+Merge base: 8a75ce77275576f96ab4bb6db1ba8dc4669c10c7 -+Head: f34da790f38942aa048a8794b7947c450b7e4659 -+ -+# git status --short -+M dev/reports/codex_review.diff -+ M dev/reports/coverage_changed_js_guardrail.txt -+ M dev/reports/playwright_v8_coverage_report.txt -+?? dev/reports/PR_26179_ALFA_014-objects-journey-readiness_eod_report.md -+?? dev/reports/PR_26179_ALFA_objects-stack_product-owner-review-notes.md -+ -+# git diff --name-status PR_26179_ALFA_013-objects-asset-links...HEAD -+M assets/toolbox/game-journey/js/index.js -+A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md -+A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md -+A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md -+A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md -+A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md -+A dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md -+M dev/reports/codex_changed_files.txt -+M dev/reports/codex_review.diff -+M dev/reports/playwright_v8_coverage_report.txt -+M dev/tests/playwright/tools/GameJourneyTool.spec.mjs -+M toolbox/game-journey/index.html -+ -+# git diff --name-status -+M dev/reports/codex_review.diff -+M dev/reports/coverage_changed_js_guardrail.txt -+M dev/reports/playwright_v8_coverage_report.txt -+ -+# git ls-files --others --exclude-standard -+dev/reports/PR_26179_ALFA_014-objects-journey-readiness_eod_report.md -+dev/reports/PR_26179_ALFA_objects-stack_product-owner-review-notes.md -+ -+# git diff --stat PR_26179_ALFA_013-objects-asset-links...HEAD -+assets/toolbox/game-journey/js/index.js | 285 +- -+ ...-objects-journey-readiness_branch_validation.md | 24 + -+ ...ts-journey-readiness_manual_validation_notes.md | 14 + -+ ...79_ALFA_014-objects-journey-readiness_report.md | 34 + -+ ...ects-journey-readiness_requirement_checklist.md | 17 + -+ ...14-objects-journey-readiness_validation_lane.md | 20 + -+ ...-objects-journey-readiness_validation_report.md | 19 + -+ dev/reports/codex_changed_files.txt | 45 +- -+ dev/reports/codex_review.diff | 2710 +++++++++++++++----- -+ dev/reports/playwright_v8_coverage_report.txt | 31 +- -+ .../playwright/tools/GameJourneyTool.spec.mjs | 94 + -+ toolbox/game-journey/index.html | 21 + -+ 12 files changed, 2585 insertions(+), 729 deletions(-) -diff --git a/dev/reports/codex_review.diff b/dev/reports/codex_review.diff -index 354b473ef..43777a8d4 100644 ---- a/dev/reports/codex_review.diff -+++ b/dev/reports/codex_review.diff -@@ -1,11 +1,983 @@ --Repository sync recovery review artifact for PR_26180_OWNER_002-add-delta-to-canonical-active-teams. -+diff --git a/assets/toolbox/game-journey/js/index.js b/assets/toolbox/game-journey/js/index.js -+index 085df839c..c2c4f1d05 100644 -+--- a/assets/toolbox/game-journey/js/index.js -++++ b/assets/toolbox/game-journey/js/index.js -+@@ -11,8 +11,10 @@ import { -+ getActiveToolRegistry, -+ getToolRegistryApiDiagnostic, -+ } from "../../../../toolbox/tool-registry-api-client.js"; -++import { createServerRepositoryClient } from "../../../../src/api/server-api-client.js"; - --Resolved generated report conflicts only: --- dev/reports/codex_changed_files.txt --- dev/reports/codex_review.diff -+ const repository = createGameJourneyApiRepository(); -++const objectsRepository = createServerRepositoryClient("objects"); -+ const registryDiagnostic = getToolRegistryApiDiagnostic(); -+ const registryTools = getActiveToolRegistry(); -+ const params = new URLSearchParams(window.location.search); -+@@ -51,6 +53,9 @@ const diagnostics = document.querySelector("[data-journey-diagnostics]"); -+ const searchInput = document.querySelector("[data-journey-search-input]"); -+ const searchStatus = document.querySelector("[data-journey-search-status]"); -+ const completionMetrics = document.querySelector("[data-journey-completion-metrics]"); -++const objectsReadinessTable = document.querySelector("[data-journey-objects-readiness]"); -++const objectsReadinessStatus = document.querySelector("[data-journey-objects-readiness-status]"); -++const objectsReviewChecklist = document.querySelector("[data-journey-objects-review-checklist]"); -+ const recommendedTargets = document.querySelector("[data-journey-recommended-targets]"); -+ const recommendedTargetStatus = document.querySelector("[data-journey-target-status]"); -+ const SUMMARY_TABLE_COLUMN_COUNT = 13; -+@@ -88,6 +93,54 @@ let routeForcesNoActiveGame = false; -+ const recommendedTargetValues = new Map( -+ GAME_JOURNEY_RECOMMENDED_TARGETS.map((target) => [target.key, target.suggestedCount]), -+ ); -++const OBJECTS_COMPLETION_BUCKET_KEY = "006-objects"; -++const OBJECTS_STAGE_CRITERIA = Object.freeze([ -++ Object.freeze({ -++ key: "saved-objects", -++ label: "At least one object is saved for the current game.", -++ }), -++ Object.freeze({ -++ key: "named-typed", -++ label: "Every saved object has a name, type, and starting state.", -++ }), -++ Object.freeze({ -++ key: "interactive-object", -++ label: "At least one object represents something the player can use, collect, avoid, or reach.", -++ }), -++ Object.freeze({ -++ key: "render-links", -++ label: "Sprite-rendered objects have a sprite asset reference.", -++ }), -++ Object.freeze({ -++ key: "owner-review-details", -++ label: "Object Details include Product Owner review context such as description, tags, or defaults.", -++ }), -++]); -++const OBJECTS_REVIEW_CHECKLIST = Object.freeze([ -++ "Confirm the object list matches the current Game Hub game.", -++ "Confirm each object name and type is understandable to a Creator.", -++ "Confirm sprite, audio, and message references are resolved or intentionally empty.", -++ "Confirm at least one meaningful player-facing object exists.", -++ "Confirm Objects validation is clean before expanding later gameplay work.", -++]); -++const INTERACTIVE_OBJECT_ROLES = Object.freeze([ -++ "collectible", -++ "enemy", -++ "goal", -++ "hazard", -++ "hero", -++ "projectile", -++]); -++const INTERACTIVE_OBJECT_TRAITS = Object.freeze([ -++ "collectible", -++ "goal", -++ "hazard", -++ "movable", -++ "playerControlled", -++ "scores", -++]); -++let objectsReadinessSnapshot = null; -++let objectsReadinessDiagnostic = ""; - --Preserved governance state: --- Delta remains listed in PROJECT_INSTRUCTIONS.md canonical active teams. --- dev/workspace/zips/ remains the canonical ZIP output path. -+ function currentRecommendedTargets() { -+ const result = repository.listRecommendedTargets(); -+@@ -135,6 +188,10 @@ function routedNotes(filterId) { -+ return routeForcesNoActiveGame ? [] : repository.listNotes(filterId); -+ } - --Runtime, API, UI, database, and Delta implementation files were not modified. -++function normalizeText(value) { -++ return String(value || "").trim(); -++} -++ -+ function createElement(tagName, options = {}) { -+ const element = document.createElement(tagName); -+ if (options.className) { -+@@ -146,6 +203,120 @@ function createElement(tagName, options = {}) { -+ return element; -+ } -+ -++function objectDetails(object = {}) { -++ return object.details && typeof object.details === "object" ? object.details : {}; -++} -++ -++function objectHasNameTypeAndState(object = {}) { -++ return Boolean(normalizeText(object.name) && normalizeText(object.role || object.type) && normalizeText(object.state)); -++} -++ -++function normalizedObjectRole(object = {}) { -++ return normalizeText(object.role || object.type).toLowerCase(); -++} -++ -++function normalizedObjectTraits(object = {}) { -++ return Array.isArray(object.traits) -++ ? object.traits.map(normalizeText).filter(Boolean) -++ : []; -++} -++ -++function objectIsInteractive(object = {}) { -++ const role = normalizedObjectRole(object); -++ if (INTERACTIVE_OBJECT_ROLES.includes(role)) { -++ return true; -++ } -++ const traits = normalizedObjectTraits(object); -++ return traits.some((trait) => INTERACTIVE_OBJECT_TRAITS.includes(trait)); -++} -++ -++function objectSpriteReference(object = {}) { -++ const details = objectDetails(object); -++ return normalizeText(object.render?.assetKey || details.spriteReference); -++} -++ -++function objectHasRenderReference(object = {}) { -++ return normalizeText(object.render?.type) !== "Sprite" || Boolean(objectSpriteReference(object)); -++} -++ -++function objectHasOwnerReviewDetails(object = {}) { -++ const details = objectDetails(object); -++ return Boolean( -++ normalizeText(details.description) || -++ normalizeText(details.defaultValues) || -++ normalizeText(details.spriteReference) || -++ normalizeText(details.audioReference) || -++ normalizeText(details.messageReference) || -++ (Array.isArray(details.tags) && details.tags.length > 0) -++ ); -++} -++ -++function evaluateObjectsStageReadiness(objects = []) { -++ const savedObjects = Array.isArray(objects) ? objects : []; -++ const hasObjects = savedObjects.length > 0; -++ const criteriaByKey = { -++ "saved-objects": hasObjects, -++ "named-typed": hasObjects && savedObjects.every(objectHasNameTypeAndState), -++ "interactive-object": savedObjects.some(objectIsInteractive), -++ "render-links": hasObjects && savedObjects.every(objectHasRenderReference), -++ "owner-review-details": savedObjects.some(objectHasOwnerReviewDetails), -++ }; -++ const criteria = OBJECTS_STAGE_CRITERIA.map((criterion) => ({ -++ ...criterion, -++ complete: Boolean(criteriaByKey[criterion.key]), -++ })); -++ const completedCriteria = criteria.filter((criterion) => criterion.complete).length; -++ return { -++ available: true, -++ completedCriteria, -++ criteria, -++ meaningful: completedCriteria >= 3, -++ objectCount: savedObjects.length, -++ ready: completedCriteria === criteria.length, -++ totalCriteria: criteria.length, -++ }; -++} -++ -++function unavailableObjectsStageReadiness(message) { -++ return { -++ available: false, -++ completedCriteria: 0, -++ criteria: OBJECTS_STAGE_CRITERIA.map((criterion) => ({ -++ ...criterion, -++ complete: false, -++ })), -++ meaningful: false, -++ objectCount: 0, -++ ready: false, -++ totalCriteria: OBJECTS_STAGE_CRITERIA.length, -++ unavailableMessage: message, -++ }; -++} -++ -++function objectRepositoryErrorMessage(result) { -++ if (result?.error || objectsRepository.__apiDiagnostic?.()) { -++ return "Objects readiness is temporarily unavailable. Continue building while the API refreshes."; -++ } -++ return "Objects readiness is temporarily unavailable. Continue building while the API refreshes."; -++} -++ -++function refreshObjectsReadinessSnapshot() { -++ const activeGame = routedActiveGame(); -++ if (!activeGame) { -++ objectsReadinessDiagnostic = "Open a game in Game Hub before checking Objects readiness."; -++ objectsReadinessSnapshot = unavailableObjectsStageReadiness(objectsReadinessDiagnostic); -++ return; -++ } -++ const result = objectsRepository.listObjects(activeGame.id || activeGame.key); -++ if (Array.isArray(result)) { -++ objectsReadinessDiagnostic = ""; -++ objectsReadinessSnapshot = evaluateObjectsStageReadiness(result); -++ return; -++ } -++ objectsReadinessDiagnostic = objectRepositoryErrorMessage(result); -++ objectsReadinessSnapshot = unavailableObjectsStageReadiness(objectsReadinessDiagnostic); -++} -++ -+ function formatDate(value) { -+ const date = new Date(value); -+ if (Number.isNaN(date.getTime())) { -+@@ -952,6 +1123,52 @@ function formatCompletionFocusStatus(metric) { -+ return metric.active ? "Active focus" : "Planning context"; -+ } -+ -++function metricPercentComplete(completedCount, plannedCount) { -++ if (!plannedCount) { -++ return 0; -++ } -++ return Math.round((completedCount / plannedCount) * 100); -++} -++ -++function recalculateCompletionSnapshot(snapshot, records) { -++ const plannedCount = records.reduce((total, metric) => total + metric.plannedCount, 0); -++ const completedCount = records.reduce((total, metric) => total + metric.completedCount, 0); -++ const activeCount = records.filter((metric) => metric.active).length; -++ return { -++ ...snapshot, -++ activeCount, -++ completedCount, -++ inactiveCount: records.length - activeCount, -++ percentComplete: metricPercentComplete(completedCount, plannedCount), -++ plannedCount, -++ records, -++ }; -++} -++ -++function completionSnapshotWithObjectsReadiness(snapshot) { -++ if (!snapshot?.records || !objectsReadinessSnapshot?.available || objectsReadinessSnapshot.completedCriteria <= 0) { -++ return snapshot; -++ } -++ const records = snapshot.records.map((metric) => { -++ if (metric.bucketKey !== OBJECTS_COMPLETION_BUCKET_KEY) { -++ return metric; -++ } -++ const plannedCount = metric.plannedCount || objectsReadinessSnapshot.totalCriteria; -++ const completedCount = Math.min( -++ plannedCount, -++ Math.max(metric.completedCount, objectsReadinessSnapshot.completedCriteria), -++ ); -++ return { -++ ...metric, -++ completedCount, -++ percentComplete: metricPercentComplete(completedCount, plannedCount), -++ plannedCount, -++ status: metric.active ? "active" : metric.status, -++ }; -++ }); -++ return recalculateCompletionSnapshot(snapshot, records); -++} -++ -+ function renderCompletionMetrics() { -+ if (!completionMetrics) { -+ return; -+@@ -967,7 +1184,8 @@ function renderCompletionMetrics() { -+ return; -+ } -+ -+- const records = completionMetricsSnapshot?.records || []; -++ const snapshot = completionSnapshotWithObjectsReadiness(completionMetricsSnapshot); -++ const records = snapshot?.records || []; -+ if (!records.length) { -+ completionMetrics.append(createElement("p", { text: "No Journey progress targets are set yet. Add suggested targets to start tracking progress." })); -+ return; -+@@ -977,16 +1195,16 @@ function renderCompletionMetrics() { -+ dashboard.dataset.journeyProgressDashboard = ""; -+ const summary = createElement("p", { -+ className: "status", -+- text: `Journey progress: ${completionMetricsSnapshot.completedCount} of ${completionMetricsSnapshot.plannedCount} planned items done (${completionMetricsSnapshot.percentComplete}%). ${completionMetricsSnapshot.activeCount} sections are active focus areas; ${completionMetricsSnapshot.inactiveCount} are planning context.`, -++ text: `Journey progress: ${snapshot.completedCount} of ${snapshot.plannedCount} planned items done (${snapshot.percentComplete}%). ${snapshot.activeCount} sections are active focus areas; ${snapshot.inactiveCount} are planning context.`, -+ }); -+ const overallHeading = createElement("h3", { text: "Overall Progress" }); -+ const overallGrid = createElement("div", { className: "grid cols-2" }); -+ overallGrid.dataset.journeyOverallProgress = ""; -+ [ -+- ["Overall", `${completionMetricsSnapshot.percentComplete}%`], -+- ["Done", String(completionMetricsSnapshot.completedCount)], -+- ["Planned", String(completionMetricsSnapshot.plannedCount)], -+- ["Active Focus", String(completionMetricsSnapshot.activeCount)], -++ ["Overall", `${snapshot.percentComplete}%`], -++ ["Done", String(snapshot.completedCount)], -++ ["Planned", String(snapshot.plannedCount)], -++ ["Active Focus", String(snapshot.activeCount)], -+ ].forEach(([label, value]) => { -+ const stat = createElement("article", { className: "mini-stat mini-stat--inline" }); -+ stat.append( -+@@ -1062,7 +1280,7 @@ function renderCompletionMetrics() { -+ const insightHeading = createElement("h3", { text: "What To Do Next" }); -+ const insightList = createElement("ul"); -+ insightList.dataset.journeyCompletionInsights = ""; -+- completionInsightMessages(completionMetricsSnapshot, records, mostComplete, leastComplete).forEach((message) => { -++ completionInsightMessages(snapshot, records, mostComplete, leastComplete).forEach((message) => { -+ insightList.append(createElement("li", { text: message })); -+ }); -+ -+@@ -1082,6 +1300,47 @@ function renderCompletionMetrics() { -+ completionMetrics.append(dashboard); -+ } -+ -++function objectsReadinessStatusText(snapshot) { -++ if (!snapshot?.available) { -++ return snapshot?.unavailableMessage || "Objects readiness is temporarily unavailable."; -++ } -++ if (snapshot.objectCount === 0) { -++ return "Objects readiness: no saved objects for this game yet."; -++ } -++ if (snapshot.ready) { -++ return `Objects readiness: ${snapshot.completedCriteria} of ${snapshot.totalCriteria} criteria complete. Product Owner review can start.`; -++ } -++ if (snapshot.meaningful) { -++ return `Objects readiness: ${snapshot.completedCriteria} of ${snapshot.totalCriteria} criteria complete. Journey progress now reflects meaningful Objects work.`; -++ } -++ return `Objects readiness: ${snapshot.completedCriteria} of ${snapshot.totalCriteria} criteria complete. Continue Objects before treating this stage as ready.`; -++} -++ -++function renderObjectsReadiness() { -++ const snapshot = objectsReadinessSnapshot || unavailableObjectsStageReadiness("Objects readiness is waiting for API data."); -++ if (objectsReadinessStatus) { -++ objectsReadinessStatus.textContent = objectsReadinessStatusText(snapshot); -++ } -++ if (objectsReadinessTable) { -++ objectsReadinessTable.replaceChildren(); -++ snapshot.criteria.forEach((criterion) => { -++ const row = createElement("tr"); -++ row.dataset.journeyObjectsCriterion = criterion.key; -++ row.append( -++ createElement("td", { text: criterion.label }), -++ createElement("td", { text: criterion.complete ? "PASS" : "Needs work" }), -++ ); -++ objectsReadinessTable.append(row); -++ }); -++ } -++ if (objectsReviewChecklist) { -++ objectsReviewChecklist.replaceChildren(); -++ OBJECTS_REVIEW_CHECKLIST.forEach((item) => { -++ objectsReviewChecklist.append(createElement("li", { text: item })); -++ }); -++ } -++} -++ -+ function normalizeTargetCount(value) { -+ const parsed = Number(value); -+ if (!Number.isFinite(parsed)) { -+@@ -1474,6 +1733,7 @@ function render() { -+ renderStatScope(selectedStatsNote, notes); -+ renderStats(statCounts); -+ renderCompletionMetrics(); -++ renderObjectsReadiness(); -+ renderRecommendedTargets(); -+ renderSuggestedTools(displayNote); -+ renderRecentActivity(); -+@@ -1788,6 +2048,16 @@ recommendedTargets?.addEventListener("input", (event) => { -+ } -+ }); -+ -++window.addEventListener("focus", () => { -++ refreshObjectsReadinessSnapshot(); -++ render(); -++}); -++ -++window.addEventListener("pageshow", () => { -++ refreshObjectsReadinessSnapshot(); -++ render(); -++}); -++ -+ addNoteButton?.addEventListener("click", () => { -+ if (redirectGuestWriteAction(noteStatus)) { -+ return; -+@@ -1821,4 +2091,5 @@ addTypeButton?.addEventListener("click", () => { -+ renderStatusOptions(); -+ refreshCompletionMetricsSnapshot(); -+ applyInitialGameRoute(); -++refreshObjectsReadinessSnapshot(); -+ render(); -+diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md -+new file mode 100644 -+index 000000000..5ba8f9771 -+--- /dev/null -++++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md -+@@ -0,0 +1,24 @@ -++# PR_26179_ALFA_014 Branch Validation -++ -++Branch: PR_26179_ALFA_014-objects-journey-readiness -++Base stack: PR_26179_ALFA_013-objects-asset-links -++ -++## Gates -++- Current branch is PR_26179_ALFA_014-objects-journey-readiness: PASS -++- Project Instructions loaded from `dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md`: PASS -++- Batch governance addendum loaded from `dev/build/ProjectInstructions/addendums/batch_governance_mode.md`: PASS -++- Canonical report path `dev/reports/`: PASS -++- Canonical ZIP path `dev/workspace/zips/`: PASS -++- No `docs_build/` report output created: PASS -++- No `tmp/` ZIP output created: PASS -++- One PR purpose only: PASS -++- Existing Objects API architecture preserved: PASS -++ -++## Validation Commands -++- `node --check assets/toolbox/game-journey/js/index.js`: PASS -++- `node --check dev/tests/playwright/tools/GameJourneyTool.spec.mjs`: PASS -++- `npx playwright test dev/tests/playwright/tools/GameJourneyTool.spec.mjs --grep "progress dashboard|Objects readiness"`: PASS -++- `git diff --check`: PASS -++- `npm run validate:canonical-structure`: PASS -++ -++Branch validation: PASS -+diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md -+new file mode 100644 -+index 000000000..56ecf33af -+--- /dev/null -++++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md -+@@ -0,0 +1,14 @@ -++# PR_26179_ALFA_014 Manual Validation Notes -++ -++## Manual Review Path -++1. Open Game Journey for the active Demo Game. -++2. Confirm Journey Progress Dashboard renders normally when there are no saved Objects. -++3. Save a review-ready Object for the same Game Hub game in Objects. -++4. Return to Game Journey and confirm Objects Stage Readiness shows the criteria table and Product Owner checklist. -++5. Confirm the Objects completion bucket reflects meaningful Objects work while the fullscreen, Rules, Worlds, and behavior editor areas remain unchanged. -++ -++## Automated Proxy -++The focused Playwright lane performed the API-backed setup, loaded Game Journey, verified readiness copy, checked the Objects bucket, reloaded the page, and verified persisted display. -++ -++## Result -++PASS. No manual-only blockers identified. -+diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md -+new file mode 100644 -+index 000000000..988365739 -+--- /dev/null -++++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md -+@@ -0,0 +1,34 @@ -++# PR_26179_ALFA_014-objects-journey-readiness Report -++ -++## Summary -++Added Game Journey visibility for Objects stage readiness so Product Owners can see whether the current Game Hub game has meaningful Objects work ready for review. -++ -++## Scope -++- Added an Objects Stage Readiness accordion to `toolbox/game-journey/index.html`. -++- Read current-game Objects through the existing Objects API repository from `assets/toolbox/game-journey/js/index.js`. -++- Defined five readiness criteria: saved objects, name/type/state, player-facing interactivity, sprite reference coverage, and Product Owner review details. -++- Added a Product Owner review checklist for the Objects stage. -++- Updated the Journey progress dashboard display so completed Objects readiness criteria can raise the Objects bucket progress without writing Journey metrics. -++- Added targeted Playwright coverage for no-Objects baseline progress and review-ready Objects progress. -++ -++## Architecture Notes -++- Objects data continues through Browser -> API -> Database. -++- Game Journey consumes Objects as read-only API data for progress display. -++- No Objects API architecture changes were made. -++- No page-local product data arrays or browser-owned product data were added. -++- No Rules integration was added. -++- No Worlds integration was added. -++- No behavior editor was added. -++ -++## Validation Outcome -++PASS. Targeted Journey validation, syntax checks, diff check, and canonical structure validation completed successfully. -++ -++## Changed Runtime Surface -++- Game Journey inspector UI. -++- Game Journey browser controller progress display. -++ -++## Not Changed -++- Objects API contract. -++- Objects persistence. -++- Game Journey completion metrics persistence. -++- Rules, Worlds, or behavior editor flows. -+diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md -+new file mode 100644 -+index 000000000..3d4a45113 -+--- /dev/null -++++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md -+@@ -0,0 +1,17 @@ -++# PR_26179_ALFA_014 Requirement Checklist -++ -++- Define Objects stage readiness criteria: PASS -++- Update Journey progress when Objects has meaningful completion: PASS -++- Add Product Owner review checklist for Objects stage: PASS -++- Preserve existing Objects API architecture: PASS -++- Do not add Rules integration: PASS -++- Do not add Worlds integration: PASS -++- Do not add behavior editor: PASS -++- Use Browser -> API -> Database data flow: PASS -++- Avoid browser-owned product data: PASS -++- Avoid page-local product data arrays: PASS -++- Use current Game Hub game context: PASS -++- Use current Project Instructions: PASS -++- Use canonical `dev/reports/` reports path: PASS -++- Use canonical `dev/workspace/zips/` ZIP path: PASS -++- Return repo-structured ZIP: PASS -+diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md -+new file mode 100644 -+index 000000000..c561a478a -+--- /dev/null -++++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md -+@@ -0,0 +1,20 @@ -++# PR_26179_ALFA_014 Validation Lane -++ -++Lane: Focused Game Journey Objects readiness -++ -++## Coverage -++- Game Journey progress dashboard baseline with no Objects rows. -++- Game Journey Objects Stage Readiness accordion. -++- Current-game Object persistence through the Objects API repository. -++- Product Owner Objects checklist rendering. -++- Objects readiness contribution to the Journey Objects completion bucket. -++- Reload persistence of Journey readiness display. -++ -++## Command -++`npx playwright test dev/tests/playwright/tools/GameJourneyTool.spec.mjs --grep "progress dashboard|Objects readiness"` -++ -++## Result -++PASS. 2 tests passed. -++ -++## Follow-up -++None for this PR. -+diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md -+new file mode 100644 -+index 000000000..1abd35fed -+--- /dev/null -++++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md -+@@ -0,0 +1,19 @@ -++# PR_26179_ALFA_014 Validation Report -++ -++## Commands Run -++- `node --check assets/toolbox/game-journey/js/index.js` -++- `node --check dev/tests/playwright/tools/GameJourneyTool.spec.mjs` -++- `npx playwright test dev/tests/playwright/tools/GameJourneyTool.spec.mjs --grep "progress dashboard|Objects readiness"` -++- `git diff --check` -++- `npm run validate:canonical-structure` -++- `npm run codex:review-artifacts` -++ -++## Results -++- JavaScript syntax checks: PASS -++- Focused Game Journey Playwright lane: PASS, 2 tests passed -++- Diff whitespace check: PASS -++- Canonical repository structure guardrail: PASS -++- Review artifacts generated: PASS -++ -++## Notes -++The Playwright lane verifies that Game Journey keeps the baseline progress at 0 when no Objects are saved, then reflects a persisted review-ready Object as 5 of 5 Objects criteria complete and 100% Objects bucket progress after reload. -+diff --git a/dev/reports/codex_changed_files.txt b/dev/reports/codex_changed_files.txt -+index df3e293b6..8979242c9 100644 -+--- a/dev/reports/codex_changed_files.txt -++++ b/dev/reports/codex_changed_files.txt -+@@ -1,15 +1,30 @@ -+-assets/toolbox/objects/js/index.js -+-dev/reports/codex_changed_files.txt -+-dev/reports/codex_review.diff -+-dev/reports/coverage_changed_js_guardrail.txt -+-dev/reports/playwright_v8_coverage_report.txt -+-dev/reports/PR_26179_ALFA_013-objects-asset-links_branch_validation.md -+-dev/reports/PR_26179_ALFA_013-objects-asset-links_manual_validation_notes.md -+-dev/reports/PR_26179_ALFA_013-objects-asset-links_report.md -+-dev/reports/PR_26179_ALFA_013-objects-asset-links_requirement_checklist.md -+-dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_lane.md -+-dev/reports/PR_26179_ALFA_013-objects-asset-links_validation_report.md -+-dev/tests/dev-runtime/ObjectsApiService.test.mjs -+-dev/tests/playwright/tools/ObjectsTool.spec.mjs -+-src/dev-runtime/toolbox-api/alfa-tool-services.mjs -+-toolbox/objects/index.html -++# git status --short -++M assets/toolbox/game-journey/js/index.js -++ M dev/reports/codex_changed_files.txt -++ M dev/reports/codex_review.diff -++ M dev/reports/playwright_v8_coverage_report.txt -++ M dev/tests/playwright/tools/GameJourneyTool.spec.mjs -++ M toolbox/game-journey/index.html -++?? dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md -++?? dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md -++?? dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md -++?? dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md -++?? dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md -++?? dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md -++ -++# git ls-files --others --exclude-standard -++dev/reports/PR_26179_ALFA_014-objects-journey-readiness_branch_validation.md -++dev/reports/PR_26179_ALFA_014-objects-journey-readiness_manual_validation_notes.md -++dev/reports/PR_26179_ALFA_014-objects-journey-readiness_report.md -++dev/reports/PR_26179_ALFA_014-objects-journey-readiness_requirement_checklist.md -++dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_lane.md -++dev/reports/PR_26179_ALFA_014-objects-journey-readiness_validation_report.md -++ -++# git diff --stat -++assets/toolbox/game-journey/js/index.js | 285 ++++- -++ dev/reports/codex_changed_files.txt | 30 +- -++ dev/reports/codex_review.diff | 1249 +++++++++----------- -++ dev/reports/playwright_v8_coverage_report.txt | 31 +- -++ .../playwright/tools/GameJourneyTool.spec.mjs | 94 ++ -++ toolbox/game-journey/index.html | 21 + -++ 6 files changed, 949 insertions(+), 761 deletions(-) -+\ No newline at end of file -+diff --git a/dev/reports/playwright_v8_coverage_report.txt b/dev/reports/playwright_v8_coverage_report.txt -+index eaea8c7d9..43a555fca 100644 -+--- a/dev/reports/playwright_v8_coverage_report.txt -++++ b/dev/reports/playwright_v8_coverage_report.txt -+@@ -12,42 +12,33 @@ Note: entry percentages use function coverage when available, otherwise line cov -+ Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. -+ -+ Exercised tool entry points detected: -+-(62%) Toolbox Index - exercised 4 runtime JS files -++(44%) Toolbox Index - exercised 1 runtime JS files -+ (0%) Tool Template V2 - not exercised by this Playwright run -+-(70%) Theme V2 Shared JS - exercised 6 runtime JS files -++(72%) Theme V2 Shared JS - exercised 4 runtime JS files -+ -+ Changed runtime JS files covered: -+ (0%) src/dev-runtime/toolbox-api/alfa-tool-services.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -+ -+ Files with executed line/function counts where available: -+-(12%) toolbox/messages/messages-api-client.js - executed lines 87/87; executed functions 3/25 -+-(25%) src/api/session-api-client.js - executed lines 67/67; executed functions 3/12 -+-(33%) src/api/toolbox-votes-api-client.js - executed lines 46/46; executed functions 2/6 -+ (36%) src/shared/toolbox/tool-metadata-inventory.js - executed lines 2044/2044; executed functions 12/33 -+-(50%) src/api/public-config-client.js - executed lines 209/209; executed functions 13/26 -+-(50%) toolbox/game-hub/game-hub-api-client.js - executed lines 26/26; executed functions 2/4 -++(44%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 11/25 -+ (53%) src/api/server-api-client.js - executed lines 168/168; executed functions 10/19 -+-(57%) assets/theme-v2/js/account-auth-service.js - executed lines 64/64; executed functions 4/7 -+-(57%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 1046/1046; executed functions 55/97 -+-(60%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 15/25 -+-(67%) assets/js/shared/assets-api-client.js - executed lines 19/19; executed functions 2/3 -++(64%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 1046/1046; executed functions 63/98 -++(65%) src/api/public-config-client.js - executed lines 209/209; executed functions 17/26 -+ (67%) src/api/game-journey-completion-api-client.js - executed lines 15/15; executed functions 2/3 -+-(67%) src/engine/object-model/objectModelRegistry.js - executed lines 139/139; executed functions 4/6 -+-(70%) assets/theme-v2/js/login-session.js - executed lines 113/113; executed functions 7/10 -+-(70%) src/engine/object-model/objectDefinitionValidator.js - executed lines 360/360; executed functions 16/23 -+-(74%) toolbox/tools-page-accordions.js - executed lines 1156/1156; executed functions 80/108 -+ (75%) assets/theme-v2/js/tool-display-mode.js - executed lines 251/251; executed functions 21/28 -+-(75%) src/engine/object-model/objectDefinitionSchema.js - executed lines 58/58; executed functions 3/4 -++(75%) assets/toolbox/game-journey/js/index.js - executed lines 1913/1913; executed functions 132/177 -+ (80%) assets/theme-v2/js/theme-icons.js - executed lines 69/69; executed functions 4/5 -+-(95%) assets/toolbox/objects/js/index.js - executed lines 1574/1574; executed functions 146/153 -+-(100%) assets/theme-v2/js/toolbox-status-bar.js - executed lines 427/427; executed functions 37/37 -+-(100%) src/engine/object-model/index.js - executed lines 29/29; executed functions 1/1 -++(89%) assets/theme-v2/js/toolbox-status-bar.js - executed lines 427/427; executed functions 32/36 -++(100%) assets/js/shared/game-journey-api-client.js - executed lines 19/19; executed functions 2/2 -+ -+ Uncovered or low-coverage changed JS files: -+ (0%) src/dev-runtime/toolbox-api/alfa-tool-services.mjs - WARNING: uncovered changed runtime JS file; advisory only -+ -+ Changed JS files considered: -++(0%) assets/toolbox/objects/js/index.js - changed JS file not collected as browser runtime coverage -+ (0%) dev/tests/dev-runtime/ObjectsApiService.test.mjs - changed JS file not collected as browser runtime coverage -++(0%) dev/tests/playwright/tools/GameJourneyTool.spec.mjs - changed JS file not collected as browser runtime coverage -+ (0%) dev/tests/playwright/tools/ObjectsTool.spec.mjs - changed JS file not collected as browser runtime coverage -+ (0%) src/dev-runtime/toolbox-api/alfa-tool-services.mjs - changed JS file not collected as browser runtime coverage -+-(95%) assets/toolbox/objects/js/index.js - changed JS file with browser V8 coverage -++(75%) assets/toolbox/game-journey/js/index.js - changed JS file with browser V8 coverage -+diff --git a/dev/tests/playwright/tools/GameJourneyTool.spec.mjs b/dev/tests/playwright/tools/GameJourneyTool.spec.mjs -+index c478c40e5..cafea7135 100644 -+--- a/dev/tests/playwright/tools/GameJourneyTool.spec.mjs -++++ b/dev/tests/playwright/tools/GameJourneyTool.spec.mjs -+@@ -81,6 +81,9 @@ async function openRepoPage(page, pathName, options = {}) { -+ headers: { "content-type": "application/json" }, -+ method: "POST", -+ }); -++ if (typeof options.beforeGoto === "function") { -++ await options.beforeGoto({ server, sessionUserKey }); -++ } -+ await page.goto(`${server.baseUrl}${pathName}`, { waitUntil: "networkidle" }); -+ return { -+ consoleErrors, -+@@ -104,6 +107,22 @@ async function fetchApiData(server, pathName, options = {}) { -+ return payload.data; -+ } -+ -++async function replaceObjectsForActiveGame(server, objects) { -++ const repositoryData = await fetchApiData(server, "/api/toolbox/objects/repositories", { -++ body: JSON.stringify({ options: {} }), -++ method: "POST", -++ }); -++ const resultData = await fetchApiData( -++ server, -++ `/api/toolbox/objects/repositories/${repositoryData.repositoryId}/methods/replaceObjects`, -++ { -++ body: JSON.stringify({ args: [objects] }), -++ method: "POST", -++ }, -++ ); -++ return resultData.result; -++} -++ -+ function expectNoPageFailures(failures) { -+ expect(failures.failedRequests).toEqual([]); -+ expect(failures.pageErrors).toEqual([]); -+@@ -306,6 +325,7 @@ test("Game Journey progress dashboard summarizes completion metrics", async ({ p -+ headers: { "content-type": "application/json" }, -+ method: "POST", -+ }); -++ await replaceObjectsForActiveGame(server, []); -+ await page.goto(`${server.baseUrl}/toolbox/game-journey/index.html?game=demo-game`, { waitUntil: "networkidle" }); -+ -+ await expect(page.locator("[data-journey-active-game]")).toHaveText("Active game: Demo Game."); -+@@ -428,6 +448,80 @@ test("Game Journey progress dashboard summarizes completion metrics", async ({ p -+ } -+ }); -+ -++test("Game Journey reflects meaningful Objects readiness in Journey progress", async ({ page }) => { -++ const reviewReadyObject = { -++ details: { -++ defaultValues: "speed=8", -++ description: "Primary playable object for Product Owner review.", -++ spriteReference: "sprite_journey_hero", -++ tags: ["hero", "review"], -++ }, -++ name: "Journey Hero", -++ render: { -++ assetKey: "sprite_journey_hero", -++ previewPath: "projects/demo-game/sprites/journey-hero.png", -++ type: "Sprite", -++ }, -++ role: "Hero", -++ state: "Active", -++ traits: ["playerControlled", "movable"], -++ }; -++ const failures = await openRepoPage(page, "/toolbox/game-journey/index.html?game=demo-game", { -++ beforeGoto: async ({ server }) => { -++ await replaceObjectsForActiveGame(server, [reviewReadyObject]); -++ }, -++ }); -++ -++ try { -++ await expect(page.locator("[data-journey-active-game]")).toHaveText("Active game: Demo Game."); -++ await expect(page.locator("details > summary", { hasText: /^Objects Stage Readiness$/ })).toHaveCount(1); -++ await expect(page.locator("[data-journey-objects-readiness-status]")).toHaveText( -++ "Objects readiness: 5 of 5 criteria complete. Product Owner review can start.", -++ ); -++ await expect(page.locator("[data-journey-objects-readiness] tr")).toHaveCount(5); -++ await expect(page.locator("[data-journey-objects-readiness] tr td:nth-child(2)")).toHaveText([ -++ "PASS", -++ "PASS", -++ "PASS", -++ "PASS", -++ "PASS", -++ ]); -++ await expect(page.locator("[data-journey-objects-review-checklist] li")).toHaveText([ -++ "Confirm the object list matches the current Game Hub game.", -++ "Confirm each object name and type is understandable to a Creator.", -++ "Confirm sprite, audio, and message references are resolved or intentionally empty.", -++ "Confirm at least one meaningful player-facing object exists.", -++ "Confirm Objects validation is clean before expanding later gameplay work.", -++ ]); -++ await expect(page.locator("[data-journey-completion-metrics]")).toContainText( -++ "Journey progress: 5 of 66 planned items done (8%). 10 sections are active focus areas; 4 are planning context.", -++ ); -++ await expect(page.locator("[data-journey-completion-bucket='006-objects'] td")).toHaveText([ -++ "Objects", -++ "5", -++ "5", -++ "100%", -++ "Active focus", -++ ]); -++ -++ await page.reload({ waitUntil: "networkidle" }); -++ await expect(page.locator("[data-journey-objects-readiness-status]")).toHaveText( -++ "Objects readiness: 5 of 5 criteria complete. Product Owner review can start.", -++ ); -++ await expect(page.locator("[data-journey-completion-bucket='006-objects'] td")).toHaveText([ -++ "Objects", -++ "5", -++ "5", -++ "100%", -++ "Active focus", -++ ]); -++ await expectNoPageFailures(failures); -++ } finally { -++ await replaceObjectsForActiveGame(failures.server, []); -++ await closeWithCoverage(page, failures); -++ } -++}); -++ -+ async function closeWithCoverage(page, failures) { -+ await workspaceV2CoverageReporter.stop(page); -+ await failures.server.close(); -+diff --git a/toolbox/game-journey/index.html b/toolbox/game-journey/index.html -+index 2965e2b19..ad48c27f3 100644 -+--- a/toolbox/game-journey/index.html -++++ b/toolbox/game-journey/index.html -+@@ -161,6 +161,27 @@ -+
-+ -+ -++
-++ Objects Stage Readiness -++
-++
Objects readiness is waiting for API data.
-++
-++ -++ -++ -++ -++ -++ -++ -++ -++
CriterionStatus
-++
-++
-++

Product Owner Review Checklist

-++
    -++
    -++
    -++
    -+
    -+ Suggested Tools -+
    -+diff --git a/dev/reports/coverage_changed_js_guardrail.txt b/dev/reports/coverage_changed_js_guardrail.txt -+index d9fc5da23..7b1c51f19 100644 -+--- a/dev/reports/coverage_changed_js_guardrail.txt -++++ b/dev/reports/coverage_changed_js_guardrail.txt -+@@ -6,7 +6,7 @@ Missing changed runtime JS files are WARN, not FAIL. -+ Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. -+ -+ Changed runtime JS files considered: -+-(0%) src/dev-runtime/toolbox-api/alfa-tool-services.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -++(100%) none changed - no changed runtime JS files -+ -+ Guardrail warnings: -+-(0%) src/dev-runtime/toolbox-api/alfa-tool-services.mjs - WARNING: changed runtime JS file missing from coverage; advisory only -++(100%) none changed - no changed runtime JS files -+diff --git a/dev/reports/playwright_v8_coverage_report.txt b/dev/reports/playwright_v8_coverage_report.txt -+index 43a555fca..c90ded425 100644 -+--- a/dev/reports/playwright_v8_coverage_report.txt -++++ b/dev/reports/playwright_v8_coverage_report.txt -+@@ -17,7 +17,7 @@ Exercised tool entry points detected: -+ (72%) Theme V2 Shared JS - exercised 4 runtime JS files -+ -+ Changed runtime JS files covered: -+-(0%) src/dev-runtime/toolbox-api/alfa-tool-services.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -++(100%) none changed - no changed runtime JS files -+ -+ Files with executed line/function counts where available: -+ (36%) src/shared/toolbox/tool-metadata-inventory.js - executed lines 2044/2044; executed functions 12/33 -+@@ -33,12 +33,8 @@ Files with executed line/function counts where available: -+ (100%) assets/js/shared/game-journey-api-client.js - executed lines 19/19; executed functions 2/2 -+ -+ Uncovered or low-coverage changed JS files: -+-(0%) src/dev-runtime/toolbox-api/alfa-tool-services.mjs - WARNING: uncovered changed runtime JS file; advisory only -++(100%) none changed - no changed runtime JS files -+ -+ Changed JS files considered: -+-(0%) assets/toolbox/objects/js/index.js - changed JS file not collected as browser runtime coverage -+-(0%) dev/tests/dev-runtime/ObjectsApiService.test.mjs - changed JS file not collected as browser runtime coverage -+ (0%) dev/tests/playwright/tools/GameJourneyTool.spec.mjs - changed JS file not collected as browser runtime coverage -+-(0%) dev/tests/playwright/tools/ObjectsTool.spec.mjs - changed JS file not collected as browser runtime coverage -+-(0%) src/dev-runtime/toolbox-api/alfa-tool-services.mjs - changed JS file not collected as browser runtime coverage -+ (75%) assets/toolbox/game-journey/js/index.js - changed JS file with browser V8 coverage -+diff --git a/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_eod_report.md b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_eod_report.md -+new file mode 100644 -+index 000000000..f16f2e700 -+--- /dev/null -++++ b/dev/reports/PR_26179_ALFA_014-objects-journey-readiness_eod_report.md -+@@ -0,0 +1,27 @@ -++# PR_26179_ALFA_014 Objects Journey Readiness EOD Report -++ -++## Branch -++- Branch: PR_26179_ALFA_014-objects-journey-readiness -++- Base stack dependency: PR_26179_ALFA_013-objects-asset-links -++- Implementation commit before EOD notes: f34da790f38942aa048a8794b7947c450b7e4659 -++ -++## EOD Status -++- Objects Journey Readiness implementation: complete -++- Required PR014 reports: present -++- Product Owner review notes for Objects stack: prepared -++- Repo-structured ZIP: rebuilt under `dev/workspace/zips/` -++- Worktree before EOD notes: clean -++- Worlds work: not started -++ -++## Fresh Validation -++- `node --check assets/toolbox/game-journey/js/index.js`: PASS -++- `node --check dev/tests/playwright/tools/GameJourneyTool.spec.mjs`: PASS -++- `npx playwright test dev/tests/playwright/tools/GameJourneyTool.spec.mjs --grep "progress dashboard|Objects readiness"`: PASS, 2 tests passed -++- `git diff --check`: PASS -++- `npm run validate:canonical-structure`: PASS -++ -++## Review Readiness -++PR014 is ready for Product Owner review as the final Objects stack readiness PR. The Product Owner should review the stack in order: PR011, PR012, PR013, PR014. -++ -++## Merge Note -++Do not start Worlds until Product Owner review of the completed Objects stack is complete. -+diff --git a/dev/reports/PR_26179_ALFA_objects-stack_product-owner-review-notes.md b/dev/reports/PR_26179_ALFA_objects-stack_product-owner-review-notes.md -+new file mode 100644 -+index 000000000..38f2e22c5 -+--- /dev/null -++++ b/dev/reports/PR_26179_ALFA_objects-stack_product-owner-review-notes.md -+@@ -0,0 +1,87 @@ -++# Product Owner Review Notes - Objects Stack -++ -++## Stack Under Review -++- PR_26179_ALFA_011 Objects Manager -++- PR_26179_ALFA_012 Object Properties -++- PR_26179_ALFA_013 Object Asset Links -++- PR_26179_ALFA_014 Objects Journey Readiness -++ -++## Review Goal -++Confirm the Objects MVP is Product Owner testable from object creation through Journey readiness without starting Worlds, Rules, or behavior-editor work. -++ -++## PR011 - Objects Manager -++Product Owner testable outcome: -++- Open `toolbox/objects/index.html`. -++- Sign in as a Creator. -++- Add an object row with a name, type, state, render mode, and capabilities. -++- Save, refresh, and confirm the object remains for the current Game Hub game. -++- Edit and delete the object. -++- Sign out, attempt to save, and confirm redirect to `account/sign-in.html`. -++ -++Acceptance checks: -++- Objects load through the API/database path. -++- Browser does not own product data. -++- Guest writes redirect to sign-in. -++- Object rows persist after refresh. -++ -++## PR012 - Object Properties -++Product Owner testable outcome: -++- Select an object and open Object Details. -++- Edit Name, Description, Type, Tags, Active, Visible, Sprite reference, Audio reference, and Default values. -++- Confirm required-field validation and friendly messages. -++- Confirm Save and Cancel behavior. -++- Confirm unsaved-change messaging appears only when edits are pending. -++- Refresh and confirm saved details persist. -++ -++Acceptance checks: -++- Details persist through the existing Objects API/database service. -++- Duplicate or invalid details are blocked with friendly messages. -++- No behavior editor, Rules integration, or Worlds integration appears. -++ -++## PR013 - Object Asset Links -++Product Owner testable outcome: -++- Select an object with sprite, audio, or message references. -++- Review the Asset Links section. -++- Confirm valid sprite references link to Sprite Editor. -++- Confirm valid message references link to Messages. -++- Confirm missing sprite, audio, and message references show friendly warnings. -++ -++Acceptance checks: -++- Reference review is readable without internal implementation wording. -++- Missing references are surfaced as review guidance, not runtime errors. -++- Existing Objects API architecture is preserved. -++- No new Rules, Worlds, or behavior-editor scope appears. -++ -++## PR014 - Objects Journey Readiness -++Product Owner testable outcome: -++- Open Game Journey for the current Game Hub game. -++- Review the Objects Stage Readiness accordion. -++- Confirm readiness criteria list saved objects, name/type/state coverage, player-facing interactivity, sprite reference coverage, and Product Owner review details. -++- Confirm the Product Owner review checklist is present. -++- Confirm Journey progress reflects meaningful Objects completion when Objects are review-ready. -++- Refresh and confirm readiness display remains stable. -++ -++Acceptance checks: -++- Journey consumes Objects as read-only API data. -++- Journey does not create, edit, or duplicate Objects logic. -++- Journey progress display updates without writing new Journey completion metrics. -++- No Rules, Worlds, or behavior-editor work starts. -++ -++## End-to-End Review Flow -++1. Open Game Hub and confirm the active game. -++2. Open Objects and save a review-ready object. -++3. Edit Object Details and save review context. -++4. Verify asset/reference warnings or links. -++5. Open Game Journey for the same game. -++6. Confirm Objects Stage Readiness and the Objects progress bucket. -++7. Record any Product Owner findings before Worlds begins. -++ -++## Explicit Non-Scope For This Stack -++- Worlds integration: not started. -++- Rules integration: not started. -++- Behavior editor: not started. -++- Collision configuration beyond current Objects metadata: not started. -++- Engine runtime object instantiation work: not started. -++ -++## Product Owner Decision Needed -++Product Owner review should decide whether the Objects MVP is accepted as testable before Alfa proceeds to any Worlds-related work. -diff --git a/dev/reports/coverage_changed_js_guardrail.txt b/dev/reports/coverage_changed_js_guardrail.txt -index b58055c0d..7b1c51f19 100644 ---- a/dev/reports/coverage_changed_js_guardrail.txt -+++ b/dev/reports/coverage_changed_js_guardrail.txt -@@ -6,7 +6,7 @@ Missing changed runtime JS files are WARN, not FAIL. - Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. - - Changed runtime JS files considered: --(89%) assets/theme-v2/js/tool-display-mode.js - executed lines 251/251; executed functions 25/28 -+(100%) none changed - no changed runtime JS files - - Guardrail warnings: --(100%) none - no changed runtime JS coverage warnings -+(100%) none changed - no changed runtime JS files -diff --git a/dev/reports/playwright_v8_coverage_report.txt b/dev/reports/playwright_v8_coverage_report.txt -index 23ae016c0..c90ded425 100644 ---- a/dev/reports/playwright_v8_coverage_report.txt -+++ b/dev/reports/playwright_v8_coverage_report.txt -@@ -12,38 +12,29 @@ Note: entry percentages use function coverage when available, otherwise line cov - Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. - - Exercised tool entry points detected: --(49%) Toolbox Index - exercised 3 runtime JS files -+(44%) Toolbox Index - exercised 1 runtime JS files - (0%) Tool Template V2 - not exercised by this Playwright run --(76%) Theme V2 Shared JS - exercised 4 runtime JS files -+(72%) Theme V2 Shared JS - exercised 4 runtime JS files - - Changed runtime JS files covered: --(89%) assets/theme-v2/js/tool-display-mode.js - executed lines 251/251; executed functions 25/28 -+(100%) none changed - no changed runtime JS files - - Files with executed line/function counts where available: --(25%) src/api/session-api-client.js - executed lines 67/67; executed functions 3/12 - (36%) src/shared/toolbox/tool-metadata-inventory.js - executed lines 2044/2044; executed functions 12/33 --(38%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 9/24 --(50%) toolbox/game-hub/game-hub-api-client.js - executed lines 26/26; executed functions 2/4 -+(44%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 11/25 - (53%) src/api/server-api-client.js - executed lines 168/168; executed functions 10/19 --(54%) toolbox/game-hub/game-hub.js - executed lines 787/787; executed functions 33/61 --(55%) assets/toolbox/game-configuration/js/index.js - executed lines 257/257; executed functions 12/22 - (64%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 1046/1046; executed functions 63/98 - (65%) src/api/public-config-client.js - executed lines 209/209; executed functions 17/26 - (67%) src/api/game-journey-completion-api-client.js - executed lines 15/15; executed functions 2/3 --(69%) assets/toolbox/game-design/js/index.js - executed lines 340/340; executed functions 18/26 -+(75%) assets/theme-v2/js/tool-display-mode.js - executed lines 251/251; executed functions 21/28 -+(75%) assets/toolbox/game-journey/js/index.js - executed lines 1913/1913; executed functions 132/177 - (80%) assets/theme-v2/js/theme-icons.js - executed lines 69/69; executed functions 4/5 --(89%) assets/theme-v2/js/tool-display-mode.js - executed lines 251/251; executed functions 25/28 --(97%) assets/theme-v2/js/toolbox-status-bar.js - executed lines 427/427; executed functions 35/36 -+(89%) assets/theme-v2/js/toolbox-status-bar.js - executed lines 427/427; executed functions 32/36 -+(100%) assets/js/shared/game-journey-api-client.js - executed lines 19/19; executed functions 2/2 - - Uncovered or low-coverage changed JS files: --(100%) none - no low-coverage changed runtime JS files -+(100%) none changed - no changed runtime JS files - - Changed JS files considered: --(0%) dev/scripts/run-targeted-test-lanes.mjs - changed JS file not collected as browser runtime coverage --(0%) dev/tests/helpers/playwrightRepoServer.mjs - changed JS file not collected as browser runtime coverage --(0%) dev/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs - changed JS file not collected as browser runtime coverage --(0%) dev/tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs - changed JS file not collected as browser runtime coverage --(0%) dev/tests/playwright/tools/ToolDisplayModeSingleLineSummary.spec.mjs - changed JS file not collected as browser runtime coverage --(0%) dev/tests/playwright/tools/ToolImageRegistry.spec.mjs - changed JS file not collected as browser runtime coverage --(0%) dev/tests/playwright/tools/ToolNavigationPrevNext.spec.mjs - changed JS file not collected as browser runtime coverage --(89%) assets/theme-v2/js/tool-display-mode.js - changed JS file with browser V8 coverage -+(0%) dev/tests/playwright/tools/GameJourneyTool.spec.mjs - changed JS file not collected as browser runtime coverage -+(75%) assets/toolbox/game-journey/js/index.js - changed JS file with browser V8 coverage -diff --git a/dev/tests/dev-runtime/ObjectsApiService.test.mjs b/dev/tests/dev-runtime/ObjectsApiService.test.mjs -new file mode 100644 -index 000000000..ccc835d21 ---- /dev/null -+++ b/dev/tests/dev-runtime/ObjectsApiService.test.mjs -@@ -0,0 +1,224 @@ -+import assert from "node:assert/strict"; -+import test from "node:test"; -+import { -+ OBJECTS_TOOL_TABLES, -+ createObjectsApiService, -+ gameWorkspaceGameKeyForApi, -+} from "../../../src/dev-runtime/toolbox-api/alfa-tool-services.mjs"; -+import { SEED_DB_KEYS, makeSeedUlid } from "../../../src/dev-runtime/seed/seed-db-keys.mjs"; -+ -+function cloneRows(rows) { -+ return rows.map((row) => ({ ...row })); -+} -+ -+function createGameWorkspaceRepository() { -+ let activeGame = { -+ id: "demo-game", -+ name: "Demo Game", -+ purpose: "Game", -+ status: "Under Construction", -+ }; -+ return { -+ getActiveGame() { -+ return activeGame; -+ }, -+ openGame(gameId) { -+ activeGame = { -+ id: gameId, -+ name: gameId === "gravity-demo" ? "Gravity Demo" : "Demo Game", -+ purpose: "Game", -+ status: "Under Construction", -+ }; -+ return activeGame; -+ }, -+ }; -+} -+ -+class ObjectsServiceTestAdapter { -+ constructor() { -+ this.calls = []; -+ this.recordIndex = 0; -+ this.tables = { -+ game_workspace_games: [], -+ object_definition_records: [], -+ users: [], -+ }; -+ } -+ -+ createRecordKey() { -+ this.recordIndex += 1; -+ return makeSeedUlid(9900 + this.recordIndex); -+ } +diff --git a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs +index 39ba2d5c7..f3411521d 100644 +--- a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs ++++ b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs +@@ -11,11 +11,19 @@ import { + } from "../../../src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs"; + + const syncEnv = Object.freeze({ ++ GAMEFOUNDRY_DATABASE_SSL: "disable", ++ GAMEFOUNDRY_DATABASE_URL: "postgres://sync_user:sync_password@dev-sync.example.test/gamefoundry", + GAMEFOUNDRY_ENV: "dev", + GAMEFOUNDRY_SUPABASE_ANON_KEY: "test-anon-key", + GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + GAMEFOUNDRY_SUPABASE_URL: "https://supabase-sync.example.test", + }); ++const DEV_DB_USER_KEYS = Object.freeze({ ++ davidq: "01J00000000000000000000014", ++ user1: "01J00000000000000000000011", ++ user2: "01J00000000000000000000012", ++ user3: "01J00000000000000000000013", ++}); + + function decodeEqFilter(searchParams, key) { + const value = searchParams.get(key) || ""; +@@ -43,10 +51,10 @@ function createFakeSupabaseSyncState() { + { key: "role-user", roleSlug: "user", name: "User", isActive: true, isSystemRole: false, ...audit }, + ], + user_roles: [ +- { key: "old-user-role", userKey: SEED_DB_KEYS.users.user1, roleKey: "role-user", ...audit }, +- { key: "missing-role-reference", userKey: SEED_DB_KEYS.users.admin, roleKey: "01KV6FVP0ASR2RRR9WXCBKTV6C", ...audit }, ++ { key: "old-user-role", userKey: DEV_DB_USER_KEYS.user1, roleKey: "role-user", ...audit }, ++ { key: "missing-role-reference", userKey: DEV_DB_USER_KEYS.davidq, roleKey: "01KV6FVP0ASR2RRR9WXCBKTV6C", ...audit }, + { key: "extra-user-role", userKey: "user-extra-codex", roleKey: "role-creator", ...audit }, +- { key: "davidq-admin-role", userKey: SEED_DB_KEYS.users.admin, roleKey: "role-admin", ...audit }, ++ { key: "davidq-admin-role", userKey: DEV_DB_USER_KEYS.davidq, roleKey: "role-admin", ...audit }, + ], + users: [ + { +@@ -55,7 +63,34 @@ function createFakeSupabaseSyncState() { + displayName: "User 1", + email: "user1@example.invalid", + isActive: true, +- key: SEED_DB_KEYS.users.user1, ++ key: DEV_DB_USER_KEYS.user1, ++ ...audit, ++ }, ++ { ++ authProvider: "dev-static-seed", ++ authProviderUserId: "user-2", ++ displayName: "User 2", ++ email: "user2@example.invalid", ++ isActive: true, ++ key: DEV_DB_USER_KEYS.user2, ++ ...audit, ++ }, ++ { ++ authProvider: "dev-static-seed", ++ authProviderUserId: "user-3", ++ displayName: "User 3", ++ email: "user3@example.invalid", ++ isActive: true, ++ key: DEV_DB_USER_KEYS.user3, ++ ...audit, ++ }, ++ { ++ authProvider: "dev-static-seed", ++ authProviderUserId: "davidq", ++ displayName: "DavidQ", ++ email: "qbytes.dq@gmail.com", ++ isActive: true, ++ key: DEV_DB_USER_KEYS.davidq, + ...audit, + }, + { +@@ -72,6 +107,65 @@ function createFakeSupabaseSyncState() { + const calls = []; + let generatedKey = 1; + ++ function mutateTableRequest(tableName, { body = {}, method = "GET", query = "select=*" } = {}) { ++ calls.push({ ++ body, ++ method, ++ path: `/rest/v1/${tableName}?${query}`, ++ }); + -+ async requestTable(tableName, options = {}) { -+ this.calls.push(["requestTable", tableName, options.method || "GET", options.query || ""]); -+ if (!Object.hasOwn(this.tables, tableName)) { -+ throw new Error(`relation "${tableName}" does not exist`); -+ } -+ if (options.method === "DELETE") { -+ const key = String(options.query || "").match(/key=eq\.([^&]+)/)?.[1] || ""; -+ const decodedKey = decodeURIComponent(key); -+ const deletedRows = this.tables[tableName].filter((row) => row.key === decodedKey); -+ this.tables[tableName] = this.tables[tableName].filter((row) => row.key !== decodedKey); -+ return cloneRows(deletedRows); ++ state[tableName] = state[tableName] || []; ++ if (method === "POST") { ++ const rows = Array.isArray(body) ? body : [body]; ++ rows.forEach((row) => { ++ const rowWithKey = { ++ ...row, ++ key: row.key || `generated-${generatedKey++}`, ++ }; ++ const index = state[tableName].findIndex((existing) => existing.key === rowWithKey.key); ++ if (index === -1) { ++ state[tableName].push(rowWithKey); ++ } else { ++ state[tableName][index] = { ++ ...state[tableName][index], ++ ...rowWithKey, ++ }; ++ } ++ }); ++ return rows; + } -+ const gameId = String(options.query || "").match(/gameId=eq\.([^&]+)/)?.[1] || ""; -+ if (gameId) { -+ const decodedGameId = decodeURIComponent(gameId); -+ return cloneRows(this.tables[tableName].filter((row) => row.gameId === decodedGameId)); ++ if (method === "DELETE") { ++ const searchParams = new URLSearchParams(query); ++ const filterField = searchParams.has("key") ++ ? "key" ++ : searchParams.has("roleKey") ++ ? "roleKey" ++ : tableName === "users" ++ ? "key" ++ : "userKey"; ++ const filterValue = decodeEqFilter(searchParams, filterField); ++ const deleted = state[tableName].filter((row) => row[filterField] === filterValue); ++ state[tableName] = state[tableName].filter((row) => row[filterField] !== filterValue); ++ return deleted; + } -+ return cloneRows(this.tables[tableName]); -+ } -+ -+ async upsertProductTable(tableName, rows) { -+ this.calls.push(["upsertProductTable", tableName, rows.length]); -+ this.#upsert(tableName, rows); -+ return cloneRows(rows); -+ } -+ -+ async upsertTable(tableName, rows) { -+ this.calls.push(["upsertTable", tableName, rows.length]); -+ this.#upsert(tableName, rows); -+ return cloneRows(rows); -+ } -+ -+ #upsert(tableName, rows) { -+ if (!Object.hasOwn(this.tables, tableName)) { -+ throw new Error(`relation "${tableName}" does not exist`); ++ if (method === "PATCH") { ++ const searchParams = new URLSearchParams(query); ++ const filterField = searchParams.has("createdBy") ? "createdBy" : "updatedBy"; ++ const filterValue = decodeEqFilter(searchParams, filterField); ++ const changed = []; ++ state[tableName] = state[tableName].map((row) => { ++ if (row[filterField] !== filterValue) { ++ return row; ++ } ++ const nextRow = { ...row, ...body }; ++ changed.push(nextRow); ++ return nextRow; ++ }); ++ return changed; + } -+ rows.forEach((row) => { -+ const index = this.tables[tableName].findIndex((candidate) => candidate.key === row.key); -+ if (index >= 0) { -+ this.tables[tableName][index] = { ...this.tables[tableName][index], ...row }; -+ } else { -+ this.tables[tableName].push({ ...row }); -+ } -+ }); ++ return state[tableName].map((row) => ({ ...row })); + } -+} -+ -+function createService(adapter, gameWorkspaceRepository = createGameWorkspaceRepository()) { -+ return createObjectsApiService({ -+ databaseAdapter: () => adapter, -+ gameWorkspaceRepository, -+ sessionUserKey: SEED_DB_KEYS.users.user1, -+ }); -+} -+ -+test("Objects API service lists and replaces objects through the database adapter", async () => { -+ const adapter = new ObjectsServiceTestAdapter(); -+ const service = createService(adapter); -+ const gameKey = gameWorkspaceGameKeyForApi("demo-game"); -+ -+ const saved = await service.replaceObjects([ -+ { -+ behavior: "Moves with player input.", -+ details: { -+ active: true, -+ audioReference: "audio/hero-hop.wav", -+ defaultValues: "speed=8", -+ description: "Player-controlled hero used for review.", -+ messageReference: "msg-hero-hop", -+ spriteReference: "sprite-hero", -+ tags: ["hero", "playable"], -+ visible: true, -+ }, -+ interaction: "Can collect keys.", -+ name: "Persistent Hero", -+ render: { assetKey: "sprite-hero", type: "Sprite" }, -+ role: "Hero", -+ state: "Active", -+ traits: ["playerControlled", "movable"], -+ }, -+ ]); + -+ assert.equal(saved.saved, true); -+ assert.equal(saved.objects.length, 1); -+ assert.equal(adapter.tables.object_definition_records.length, 1); -+ assert.equal(adapter.tables.object_definition_records[0].gameId, gameKey); -+ assert.equal(adapter.tables.object_definition_records[0].key.length, 26); -+ assert.equal(adapter.tables.object_definition_records[0].createdBy, SEED_DB_KEYS.users.user1); -+ assert.equal(adapter.tables.object_definition_records[0].updatedBy, SEED_DB_KEYS.users.user1); -+ assert.deepEqual(adapter.tables.object_definition_records[0].capabilities, ["playerControlled", "movable"]); -+ assert.deepEqual(adapter.tables.object_definition_records[0].interaction.details, { -+ active: true, -+ audioReference: "audio/hero-hop.wav", -+ defaultValues: "speed=8", -+ description: "Player-controlled hero used for review.", -+ messageReference: "msg-hero-hop", -+ spriteReference: "sprite-hero", -+ tags: ["hero", "playable"], -+ visible: true, -+ }); -+ assert.equal(adapter.tables.game_workspace_games[0].key, gameKey); -+ assert.deepEqual(await service.listObjects(), [ -+ { -+ behavior: "Moves with player input.", -+ details: { -+ active: true, -+ audioReference: "audio/hero-hop.wav", -+ defaultValues: "speed=8", -+ description: "Player-controlled hero used for review.", -+ messageReference: "msg-hero-hop", -+ spriteReference: "sprite-hero", -+ tags: ["hero", "playable"], -+ visible: true, -+ }, -+ id: "persistent-hero", -+ interaction: "Can collect keys.", -+ name: "Persistent Hero", -+ render: { assetKey: "sprite-hero", previewPath: "", type: "Sprite" }, -+ role: "Hero", -+ state: "Active", -+ traits: ["playerControlled", "movable"], -+ type: "Dynamic", + async function fetchImpl(url, options = {}) { + const requestUrl = new URL(url); + const method = options.method || "GET"; +@@ -130,57 +224,6 @@ function createFakeSupabaseSyncState() { + }; + } + +- if (requestUrl.pathname.startsWith("/rest/v1/")) { +- const tableName = decodeURIComponent(requestUrl.pathname.split("/").pop() || ""); +- state[tableName] = state[tableName] || []; +- if (method === "POST") { +- const rows = Array.isArray(body) ? body : [body]; +- rows.forEach((row) => { +- const rowWithKey = { +- ...row, +- key: row.key || `generated-${generatedKey++}`, +- }; +- const index = state[tableName].findIndex((existing) => existing.key === rowWithKey.key); +- if (index === -1) { +- state[tableName].push(rowWithKey); +- } else { +- state[tableName][index] = { +- ...state[tableName][index], +- ...rowWithKey, +- }; +- } +- }); +- return { +- json: async () => rows, +- ok: true, +- status: 200, +- }; +- } +- if (method === "DELETE") { +- const filterField = requestUrl.searchParams.has("key") ? "key" : tableName === "users" ? "key" : "userKey"; +- const filterValue = decodeEqFilter(requestUrl.searchParams, filterField); +- const deleted = state[tableName].filter((row) => row[filterField] === filterValue); +- state[tableName] = state[tableName].filter((row) => row[filterField] !== filterValue); +- return { +- json: async () => deleted, +- ok: true, +- status: 200, +- }; +- } +- if (method === "PATCH") { +- return { +- json: async () => [], +- ok: true, +- status: 200, +- }; +- } +- return { +- json: async () => state[tableName].map((row) => ({ ...row })), +- ok: true, +- status: 200, +- }; +- } +- + return { + json: async () => ({ message: "Unhandled fake route" }), + ok: false, +@@ -188,7 +231,14 @@ function createFakeSupabaseSyncState() { + }; + } + +- return { calls, fetchImpl, state }; ++ return { ++ calls, ++ fetchImpl, ++ postgresClient: { ++ requestTable: async (tableName, options = {}) => mutateTableRequest(tableName, options), + }, -+ ]); -+ assert.deepEqual(OBJECTS_TOOL_TABLES, ["object_definition_records"]); -+ assert.equal(adapter.calls.some(([action, table]) => action === "requestTable" && table === "object_definition_records"), true); -+ assert.equal(adapter.calls.some(([action, table]) => action === "upsertProductTable" && table === "object_definition_records"), true); -+}); -+ -+test("Objects API service replaces only the active game object rows", async () => { -+ const adapter = new ObjectsServiceTestAdapter(); -+ const gameWorkspaceRepository = createGameWorkspaceRepository(); -+ const service = createService(adapter, gameWorkspaceRepository); -+ const demoKey = gameWorkspaceGameKeyForApi("demo-game"); -+ const gravityKey = gameWorkspaceGameKeyForApi("gravity-demo"); -+ -+ await service.replaceObjects([{ name: "Demo Hero", role: "Hero", state: "Active" }], "demo-game"); -+ await service.replaceObjects([{ name: "Gravity Hero", role: "Hero", state: "Active" }], "gravity-demo"); -+ await service.replaceObjects([{ name: "Demo Wall", role: "Wall", state: "Active" }], "demo-game"); -+ -+ assert.deepEqual( -+ adapter.tables.object_definition_records -+ .filter((row) => row.gameId === demoKey) -+ .map((row) => row.name), -+ ["Demo Wall"], -+ ); -+ assert.deepEqual( -+ adapter.tables.object_definition_records -+ .filter((row) => row.gameId === gravityKey) -+ .map((row) => row.name), -+ ["Gravity Hero"], -+ ); -+}); -+ -+test("Objects API service reports controlled setup errors", async () => { -+ const service = createObjectsApiService({ -+ gameWorkspaceRepository: createGameWorkspaceRepository(), -+ sessionUserKey: SEED_DB_KEYS.users.user1, -+ }); ++ state, ++ }; + } + + test("Supabase DEV creator identity sync upserts canonical users and deletes extra managed accounts", async () => { +@@ -201,8 +251,8 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext + }), + databaseProvider: new SupabasePostgresProviderAdapter({ + env: syncEnv, +- fetchImpl: fake.fetchImpl, + keyFactory: () => `provider-generated-key-${providerGeneratedKey++}`, ++ postgresClient: fake.postgresClient, + }), + env: syncEnv, + }); +@@ -239,6 +289,11 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext + assert.deepEqual(fake.state.authUsers.map((user) => user.email).sort(), canonicalEmails); + assert.deepEqual(fake.state.users.map((user) => user.email).sort(), canonicalEmails); + assert.equal(fake.state.users.find((user) => user.email === "qbytes.dq@gmail.com").displayName, "DavidQ"); ++ assert.equal(fake.state.users.find((user) => user.email === "qbytes.dq@gmail.com").key, DEV_DB_USER_KEYS.davidq); ++ assert.equal(fake.state.users.find((user) => user.email === "user1@example.invalid").key, DEV_DB_USER_KEYS.user1); ++ assert.equal(fake.state.users.find((user) => user.email === "user2@example.invalid").key, DEV_DB_USER_KEYS.user2); ++ assert.equal(fake.state.users.find((user) => user.email === "user3@example.invalid").key, DEV_DB_USER_KEYS.user3); ++ assert.equal(fake.state.users.some((user) => Object.values(SEED_DB_KEYS.users).includes(user.key)), false); + assert.equal(fake.state.users.every((user) => user.authProvider === "supabase-auth"), true); + assert.equal(fake.state.roles.some((role) => role.roleSlug === "user"), false); + assert.equal(fake.state.roles.find((role) => role.roleSlug === "owner").isActive, true); +@@ -247,3 +302,22 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext + assert.equal(fake.state.user_roles.some((row) => row.userKey === davidq.key && row.roleKey === ownerRole.key), true); + assert.equal(fake.calls.some((call) => call.path === "/auth/v1/admin/users?page=1&per_page=100" && call.method === "GET"), true); + }); + ++test("Supabase DEV creator identity sync requires existing database users rows by email", async () => { ++ const fake = createFakeSupabaseSyncState(); ++ fake.state.users = fake.state.users.filter((user) => user.email !== "qbytes.dq@gmail.com"); + await assert.rejects( -+ () => service.listObjects(), -+ (error) => { -+ assert.equal(error.name, "ObjectsApiSetupError"); -+ assert.equal(error.statusCode, 503); -+ assert.match(error.message, /Objects API database setup is unavailable/); -+ assert.doesNotMatch(error.message, /requires the configured API database adapter/); -+ assert.match(error.operatorDiagnostic, /requires the configured API database adapter/); -+ return true; -+ }, ++ () => syncSupabaseDevCreatorIdentities({ ++ authProvider: new SupabaseAuthProviderAdapter({ ++ env: syncEnv, ++ fetchImpl: fake.fetchImpl, ++ }), ++ databaseProvider: new SupabasePostgresProviderAdapter({ ++ env: syncEnv, ++ postgresClient: fake.postgresClient, ++ }), ++ env: syncEnv, ++ }), ++ /requires existing database users rows for: qbytes\.dq@gmail\.com/, + ); +}); -diff --git a/dev/tests/playwright/tools/GameJourneyTool.spec.mjs b/dev/tests/playwright/tools/GameJourneyTool.spec.mjs -index c478c40e5..cafea7135 100644 ---- a/dev/tests/playwright/tools/GameJourneyTool.spec.mjs -+++ b/dev/tests/playwright/tools/GameJourneyTool.spec.mjs -@@ -81,6 +81,9 @@ async function openRepoPage(page, pathName, options = {}) { - headers: { "content-type": "application/json" }, - method: "POST", - }); -+ if (typeof options.beforeGoto === "function") { -+ await options.beforeGoto({ server, sessionUserKey }); -+ } - await page.goto(`${server.baseUrl}${pathName}`, { waitUntil: "networkidle" }); - return { - consoleErrors, -@@ -104,6 +107,22 @@ async function fetchApiData(server, pathName, options = {}) { - return payload.data; +diff --git a/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs b/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs +index 3f5d81a00..b86a95014 100644 +--- a/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs ++++ b/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs +@@ -38,8 +38,8 @@ function withEnv(nextEnv, callback) { + }); } - -+async function replaceObjectsForActiveGame(server, objects) { -+ const repositoryData = await fetchApiData(server, "/api/toolbox/objects/repositories", { -+ body: JSON.stringify({ options: {} }), -+ method: "POST", -+ }); -+ const resultData = await fetchApiData( -+ server, -+ `/api/toolbox/objects/repositories/${repositoryData.repositoryId}/methods/replaceObjects`, -+ { -+ body: JSON.stringify({ args: [objects] }), -+ method: "POST", -+ }, -+ ); -+ return resultData.result; + +-function startApiServer() { +- const handleRequest = createLocalApiRouter(); ++function startApiServer(routerOptions = {}) { ++ const handleRequest = createLocalApiRouter(routerOptions); + const server = http.createServer((request, response) => { + const address = server.address(); + const port = address && typeof address !== "string" ? address.port : 0; +@@ -313,6 +313,12 @@ function fakeSupabaseIdentityTables(overrides = {}) { + }; + } + ++function fakePostgresIdentityClient(identityTables = {}) { ++ return { ++ requestTable: async (tableName) => (identityTables[tableName] || []).map((row) => ({ ...row })), ++ }; +} + - function expectNoPageFailures(failures) { - expect(failures.failedRequests).toEqual([]); - expect(failures.pageErrors).toEqual([]); -@@ -306,6 +325,7 @@ test("Game Journey progress dashboard summarizes completion metrics", async ({ p - headers: { "content-type": "application/json" }, - method: "POST", - }); -+ await replaceObjectsForActiveGame(server, []); - await page.goto(`${server.baseUrl}/toolbox/game-journey/index.html?game=demo-game`, { waitUntil: "networkidle" }); - - await expect(page.locator("[data-journey-active-game]")).toHaveText("Active game: Demo Game."); -@@ -428,6 +448,80 @@ test("Game Journey progress dashboard summarizes completion metrics", async ({ p - } + test("Supabase provider contract does not require provider selector variables", () => { + const snapshot = createProviderContractSnapshot({}); + assert.equal(snapshot.activeProviders.authProviderId, "supabase-auth"); +@@ -881,6 +887,134 @@ test("Create account provisions Supabase identity user default role and user_rol + await fakeSupabase.close(); }); - -+test("Game Journey reflects meaningful Objects readiness in Journey progress", async ({ page }) => { -+ const reviewReadyObject = { -+ details: { -+ defaultValues: "speed=8", -+ description: "Primary playable object for Product Owner review.", -+ spriteReference: "sprite_journey_hero", -+ tags: ["hero", "review"], -+ }, -+ name: "Journey Hero", -+ render: { -+ assetKey: "sprite_journey_hero", -+ previewPath: "projects/demo-game/sprites/journey-hero.png", -+ type: "Sprite", -+ }, -+ role: "Hero", -+ state: "Active", -+ traits: ["playerControlled", "movable"], + ++test("Supabase sign-in resolves the session from database users.key matched by Auth id", async () => { ++ const databaseOwnedUserKey = "01J00000000000000000000001"; ++ const timestamp = "2026-06-15T00:00:00.000Z"; ++ const audit = { ++ createdAt: timestamp, ++ createdBy: SEED_DB_KEYS.users.admin, ++ updatedAt: timestamp, ++ updatedBy: SEED_DB_KEYS.users.admin, + }; -+ const failures = await openRepoPage(page, "/toolbox/game-journey/index.html?game=demo-game", { -+ beforeGoto: async ({ server }) => { -+ await replaceObjectsForActiveGame(server, [reviewReadyObject]); -+ }, ++ const identityTables = fakeSupabaseIdentityTables({ ++ user_roles: [ ++ { ++ key: SEED_DB_KEYS.userRoles.user1User, ++ userKey: databaseOwnedUserKey, ++ roleKey: SEED_DB_KEYS.roles.creator, ++ ...audit, ++ }, ++ ], ++ users: [ ++ { ++ key: databaseOwnedUserKey, ++ displayName: "Database Creator", ++ email: "user1@example.invalid", ++ authProvider: "supabase-auth", ++ authProviderUserId: "supabase-user-1", ++ isActive: true, ++ ...audit, ++ }, ++ ], + }); ++ const fakeSupabase = await startFakeSupabaseAuthServer({ identityTables }); ++ await withEnv({ ++ GAMEFOUNDRY_DATABASE_SSL: "disable", ++ GAMEFOUNDRY_DATABASE_URL: "postgres://contract_user:contract_password@supabase-provider.example.test/gamefoundry", ++ GAMEFOUNDRY_AUTH_PROVIDER: "supabase-auth", ++ GAMEFOUNDRY_DB_PROVIDER: "supabase-postgres", ++ GAMEFOUNDRY_SUPABASE_ANON_KEY: "test-anon-key", ++ GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", ++ GAMEFOUNDRY_SUPABASE_URL: fakeSupabase.baseUrl, ++ }, async () => { ++ const server = await startApiServer({ ++ supabasePostgresClient: fakePostgresIdentityClient(identityTables), ++ }); ++ try { ++ const signIn = await postApiPayload(server.baseUrl, "/api/auth/sign-in", { ++ email: "user1@example.invalid", ++ password: "not-stored-locally", ++ }); ++ assert.equal(signIn.status, 200); ++ assert.equal(signIn.payload.data.sessionResolved, true); ++ assert.equal(signIn.payload.data.userKey, databaseOwnedUserKey); ++ assert.equal(Object.values(SEED_DB_KEYS.users).includes(signIn.payload.data.userKey), false); ++ ++ const current = await apiJson(server.baseUrl, "/api/session/current"); ++ assert.equal(current.authenticated, true); ++ assert.equal(current.userKey, databaseOwnedUserKey); ++ assert.equal(current.displayName, "Database Creator"); ++ } finally { ++ await server.close(); ++ } ++ }); ++ await fakeSupabase.close(); ++}); + -+ try { -+ await expect(page.locator("[data-journey-active-game]")).toHaveText("Active game: Demo Game."); -+ await expect(page.locator("details > summary", { hasText: /^Objects Stage Readiness$/ })).toHaveCount(1); -+ await expect(page.locator("[data-journey-objects-readiness-status]")).toHaveText( -+ "Objects readiness: 5 of 5 criteria complete. Product Owner review can start.", -+ ); -+ await expect(page.locator("[data-journey-objects-readiness] tr")).toHaveCount(5); -+ await expect(page.locator("[data-journey-objects-readiness] tr td:nth-child(2)")).toHaveText([ -+ "PASS", -+ "PASS", -+ "PASS", -+ "PASS", -+ "PASS", -+ ]); -+ await expect(page.locator("[data-journey-objects-review-checklist] li")).toHaveText([ -+ "Confirm the object list matches the current Game Hub game.", -+ "Confirm each object name and type is understandable to a Creator.", -+ "Confirm sprite, audio, and message references are resolved or intentionally empty.", -+ "Confirm at least one meaningful player-facing object exists.", -+ "Confirm Objects validation is clean before expanding later gameplay work.", -+ ]); -+ await expect(page.locator("[data-journey-completion-metrics]")).toContainText( -+ "Journey progress: 5 of 66 planned items done (8%). 10 sections are active focus areas; 4 are planning context.", -+ ); -+ await expect(page.locator("[data-journey-completion-bucket='006-objects'] td")).toHaveText([ -+ "Objects", -+ "5", -+ "5", -+ "100%", -+ "Active focus", -+ ]); -+ -+ await page.reload({ waitUntil: "networkidle" }); -+ await expect(page.locator("[data-journey-objects-readiness-status]")).toHaveText( -+ "Objects readiness: 5 of 5 criteria complete. Product Owner review can start.", -+ ); -+ await expect(page.locator("[data-journey-completion-bucket='006-objects'] td")).toHaveText([ -+ "Objects", -+ "5", -+ "5", -+ "100%", -+ "Active focus", -+ ]); -+ await expectNoPageFailures(failures); -+ } finally { -+ await replaceObjectsForActiveGame(failures.server, []); -+ await closeWithCoverage(page, failures); -+ } ++test("Supabase sign-in does not resolve a session from email when authProviderUserId is stale", async () => { ++ const databaseOwnedUserKey = "01J00000000000000000000002"; ++ const timestamp = "2026-06-15T00:00:00.000Z"; ++ const audit = { ++ createdAt: timestamp, ++ createdBy: SEED_DB_KEYS.users.admin, ++ updatedAt: timestamp, ++ updatedBy: SEED_DB_KEYS.users.admin, ++ }; ++ const identityTables = fakeSupabaseIdentityTables({ ++ user_roles: [ ++ { ++ key: SEED_DB_KEYS.userRoles.user1User, ++ userKey: databaseOwnedUserKey, ++ roleKey: SEED_DB_KEYS.roles.creator, ++ ...audit, ++ }, ++ ], ++ users: [ ++ { ++ key: databaseOwnedUserKey, ++ displayName: "Database Creator", ++ email: "user1@example.invalid", ++ authProvider: "supabase-auth", ++ authProviderUserId: "stale-supabase-user-1", ++ isActive: true, ++ ...audit, ++ }, ++ ], ++ }); ++ const fakeSupabase = await startFakeSupabaseAuthServer({ identityTables }); ++ await withEnv({ ++ GAMEFOUNDRY_DATABASE_SSL: "disable", ++ GAMEFOUNDRY_DATABASE_URL: "postgres://contract_user:contract_password@supabase-provider.example.test/gamefoundry", ++ GAMEFOUNDRY_AUTH_PROVIDER: "supabase-auth", ++ GAMEFOUNDRY_DB_PROVIDER: "supabase-postgres", ++ GAMEFOUNDRY_SUPABASE_ANON_KEY: "test-anon-key", ++ GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", ++ GAMEFOUNDRY_SUPABASE_URL: fakeSupabase.baseUrl, ++ }, async () => { ++ const server = await startApiServer({ ++ supabasePostgresClient: fakePostgresIdentityClient(identityTables), ++ }); ++ try { ++ const signIn = await postApiPayload(server.baseUrl, "/api/auth/sign-in", { ++ email: "user1@example.invalid", ++ password: "not-stored-locally", ++ }); ++ assert.equal(signIn.status, 503); ++ assert.equal(signIn.payload.ok, false); ++ assert.equal(signIn.payload.error, "Account identity setup is incomplete. Please contact support."); ++ assert.equal(JSON.stringify(signIn.payload).includes("stale-supabase-user-1"), false); ++ assert.equal(JSON.stringify(signIn.payload).includes(databaseOwnedUserKey), false); ++ ++ const current = await apiJson(server.baseUrl, "/api/session/current"); ++ assert.equal(current.authenticated, false); ++ assert.equal(current.userKey, null); ++ } finally { ++ await server.close(); ++ } ++ }); ++ await fakeSupabase.close(); +}); + - async function closeWithCoverage(page, failures) { - await workspaceV2CoverageReporter.stop(page); - await failures.server.close(); -diff --git a/dev/tests/playwright/tools/ObjectsTool.spec.mjs b/dev/tests/playwright/tools/ObjectsTool.spec.mjs -index 7e7d967d4..74e132e67 100644 ---- a/dev/tests/playwright/tools/ObjectsTool.spec.mjs -+++ b/dev/tests/playwright/tools/ObjectsTool.spec.mjs -@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; - import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; - import { clearPlaywrightStorage, installPlaywrightStorageIsolation } from "../../helpers/playwrightStorageIsolation.mjs"; - import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs"; -+import { SEED_DB_KEYS } from "../../../../src/dev-runtime/seed/seed-db-keys.mjs"; - - const TYPE_OPTIONS = ["Collectible", "Custom", "Decoration", "Enemy", "Goal", "Hazard", "Hero", "Platform", "Projectile", "Spawn Point", "Wall"]; - const OLD_SAMPLE_PATH_PATTERN = new RegExp(["M" + "VP", "Padd" + "le", "Ba" + "ll"].join("|"), "i"); -@@ -74,9 +75,72 @@ function collectPageFailures(page) { - return { consoleErrors, failedRequests, pageErrors }; + test("Create account provider failure returns generic browser error and logs safe operator diagnostics", async () => { + const fakeSupabase = await startFakeSupabaseAuthServer({ + failAdminCreateAccount: { +diff --git a/src/dev-runtime/server/local-api-router.mjs b/src/dev-runtime/server/local-api-router.mjs +index bb54b12b7..7d204e6fd 100644 +--- a/src/dev-runtime/server/local-api-router.mjs ++++ b/src/dev-runtime/server/local-api-router.mjs +@@ -3787,9 +3787,11 @@ class ApiRuntimeDataSource { + messagesPostgresClient = null, + messagesService = null, + repoRoot = process.cwd(), ++ supabasePostgresClient = null, + } = {}) { + this.messagesService = messagesService || createMessagesPostgresService({ postgresClient: messagesPostgresClient }); + this.gameJourneyCompletionMetricsPostgresClient = gameJourneyCompletionMetricsPostgresClient; ++ this.supabasePostgresClient = supabasePostgresClient; + this.repositoryCounter = 1; + this.repositoryById = new Map(); + this.sessionModeId = FIXED_ACCOUNT_SESSION_MODE.id; +@@ -3826,7 +3828,7 @@ class ApiRuntimeDataSource { + + supabaseDatabaseAdapter(action) { + this.assertSupabaseDatabaseProvider(action); +- const adapter = new SupabasePostgresProviderAdapter(); ++ const adapter = new SupabasePostgresProviderAdapter({ postgresClient: this.supabasePostgresClient }); + adapter.connect(); + return adapter; + } +@@ -3837,8 +3839,7 @@ class ApiRuntimeDataSource { + } + + async readSupabaseIdentityTablesUnchecked(action) { +- this.assertSupabaseDatabaseProvider(action); +- const adapter = new SupabasePostgresProviderAdapter(); ++ const adapter = this.supabaseDatabaseAdapter(action); + const [users, roles, userRoles] = await Promise.all([ + adapter.getUsers(), + adapter.getRoles(), +@@ -6202,15 +6203,20 @@ SELECT pg_database_size(current_database()) AS database_size_bytes, + throw authUnavailableError(action, "Supabase Auth did not return an account identity to resolve."); + } + const tables = await this.readSupabaseIdentityTables(`${action} session resolution`); +- const matchingUser = (tables.users || []).find((user) => { ++ const activeSupabaseUsers = (tables.users || []).filter((user) => { + if (user.isActive === false || user.authProvider !== SUPABASE_AUTH_PROVIDER_ID) { + return false; + } +- return (authUserId && String(user.authProviderUserId || "") === authUserId) || +- (authEmail && normalizedAuthEmail(user.email).toLowerCase() === authEmail.toLowerCase()); ++ return true; + }); ++ const matchingUser = activeSupabaseUsers.find((user) => authUserId && String(user.authProviderUserId || "") === authUserId); + if (!matchingUser) { +- throw authIdentitySetupError(action, "Account authentication succeeded, but no matching users record exists for this account."); ++ const matchingEmailUser = activeSupabaseUsers.find((user) => ++ authEmail && normalizedAuthEmail(user.email).toLowerCase() === authEmail.toLowerCase()); ++ const diagnostic = matchingEmailUser ++ ? "Account authentication succeeded, but the users record is not linked to this account identity. Run the approved DEV identity sync before signing in." ++ : "Account authentication succeeded, but no matching users record exists for this account."; ++ throw authIdentitySetupError(action, diagnostic); + } + const session = sessionUserFromIdentityTables(tables, matchingUser.key, this.sessionModeId, "Supabase identity"); + if (!session.authenticated) { +@@ -6230,7 +6236,7 @@ SELECT pg_database_size(current_database()) AS database_size_bytes, + throw authIdentitySetupError(action, "Supabase Auth did not return the user id and email required for identity provisioning."); + } + +- const adapter = new SupabasePostgresProviderAdapter(); ++ const adapter = this.supabaseDatabaseAdapter(`${action} identity provisioning`); + const tables = await this.readSupabaseIdentityTablesUnchecked(`${action} identity provisioning`); + const users = tables.users || []; + const roles = tables.roles || []; +@@ -7415,12 +7421,14 @@ export function createLocalApiRouter({ + messagesPostgresClient = null, + messagesService = null, + repoRoot = process.cwd(), ++ supabasePostgresClient = null, + } = {}) { + const dataSource = new ApiRuntimeDataSource({ + gameJourneyCompletionMetricsPostgresClient, + messagesPostgresClient, + messagesService, + repoRoot, ++ supabasePostgresClient, + }); + + async function handleApiRuntimeRequest(request, response, requestUrl) { +diff --git a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs +index 6f214f7a2..0eeb0cd0b 100644 +--- a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs ++++ b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs +@@ -1,4 +1,3 @@ +-import { SEED_DB_KEYS } from "../seed/seed-db-keys.mjs"; + import { + SUPABASE_AUTH_PROVIDER_ID, + SupabaseAuthProviderAdapter, +@@ -10,31 +9,27 @@ export const DEV_CREATOR_IDENTITIES = Object.freeze([ + Object.freeze({ + displayName: "User 1", + email: "user1@example.invalid", +- key: SEED_DB_KEYS.users.user1, + passwordSuffix: "1!!", + }), + Object.freeze({ + displayName: "User 2", + email: "user2@example.invalid", +- key: SEED_DB_KEYS.users.user2, + passwordSuffix: "2!!", + }), + Object.freeze({ + displayName: "User 3", + email: "user3@example.invalid", +- key: SEED_DB_KEYS.users.user3, + passwordSuffix: "3!!", + }), + Object.freeze({ + displayName: "DavidQ", + email: "qbytes.dq@gmail.com", +- key: SEED_DB_KEYS.users.admin, + passwordSuffix: "$2026", + }), + ]); + + const CANONICAL_EMAILS = new Set(DEV_CREATOR_IDENTITIES.map((identity) => identity.email.toLowerCase())); +-const CANONICAL_KEYS = new Set(DEV_CREATOR_IDENTITIES.map((identity) => identity.key)); ++const DAVIDQ_DEV_EMAIL = "qbytes.dq@gmail.com"; + const DEV_PASSWORD_PREFIX = ["GF", "S"].join(""); + const LEGACY_DEV_AUTH_USER_IDS = new Set(["user-1", "user-2", "user-3", "davidq", "davidq-admin"]); + const KNOWN_STALE_ROLE_KEYS = Object.freeze(["01KV6FVP0ASR2RRR9WXCBKTV6C"]); +@@ -125,8 +120,7 @@ export function isManagedExtraDevIdentityEmail(email) { + + function isManagedExtraPublicUser(user = {}) { + const email = normalizedEmail(user.email); +- const key = normalizedId(user.key); +- if (CANONICAL_KEYS.has(key) || isCanonicalDevIdentityEmail(email)) { ++ if (isCanonicalDevIdentityEmail(email)) { + return false; + } + if (isManagedExtraDevIdentityEmail(email)) { +@@ -191,25 +185,55 @@ function roleBySlug(roles, roleSlug) { + return roles.find((role) => normalizedId(role.roleSlug) === roleSlug && role.isActive !== false) || null; } - --async function openObjectsPage(page) { -+async function postApiPayload(baseUrl, path, body = {}) { -+ const response = await fetch(`${baseUrl}${path}`, { -+ body: JSON.stringify(body), -+ headers: { "content-type": "application/json" }, -+ method: "POST", -+ }); -+ const payload = await response.json(); -+ return { payload, response }; + +-function canonicalDesiredUserRolePairs(roles) { ++function canonicalUserKeys(canonicalUsers = []) { ++ return new Set(canonicalUsers.map((user) => normalizedId(user.key)).filter(Boolean)); +} + -+async function signInCreator(baseUrl) { -+ const { response } = await postApiPayload(baseUrl, "/api/session/user", { -+ userKey: SEED_DB_KEYS.users.user1, -+ }); -+ expect(response.ok).toBe(true); ++function davidqUserFromCanonicalUsers(canonicalUsers = []) { ++ return canonicalUsers.find((user) => normalizedEmail(user.email) === DAVIDQ_DEV_EMAIL) || null; +} + -+async function clearObjectsForActiveGame(baseUrl) { -+ const repository = await postApiPayload(baseUrl, "/api/toolbox/objects/repositories", { options: {} }); -+ expect(repository.response.ok).toBe(true); -+ const repositoryId = repository.payload.data.repositoryId; -+ const cleared = await postApiPayload(baseUrl, `/api/toolbox/objects/repositories/${repositoryId}/methods/replaceObjects`, { -+ args: [[]], -+ }); -+ expect(cleared.response.ok).toBe(true); ++function canonicalUserRoleRequests(canonicalUsers = []) { ++ const davidq = davidqUserFromCanonicalUsers(canonicalUsers); ++ return [ ++ ...canonicalUsers ++ .filter((user) => normalizedId(user.key)) ++ .map((user) => ({ ++ roleSlug: "creator", ++ userKey: normalizedId(user.key), ++ })), ++ davidq ? { ++ roleSlug: "admin", ++ userKey: normalizedId(davidq.key), ++ } : null, ++ davidq ? { ++ roleSlug: "owner", ++ userKey: normalizedId(davidq.key), ++ } : null, ++ ].filter(Boolean); +} + -+async function createReviewMessage(baseUrl) { -+ const suffix = Date.now().toString(36); -+ const profile = await postApiPayload(baseUrl, "/api/messages/tts-profiles", { -+ emotionSettings: [{ -+ emotion: "calm", -+ emotionLabel: "Calm", -+ pitch: 1, -+ rate: 1, -+ volume: 1, -+ }], -+ language: "en-US", -+ name: `Objects Review Voice ${suffix}`, -+ pitch: 1, -+ providerKey: "browser-speech", -+ rate: 1, -+ voiceName: "Default browser voice", -+ volume: 1, -+ }); -+ expect(profile.response.ok).toBe(true); -+ const message = await postApiPayload(baseUrl, "/api/messages/messages", { -+ active: true, -+ emotionProfileKey: profile.payload.data.ttsProfile.emotionSettings[0].key, -+ messageText: "Review hero is ready.", -+ name: `Objects Review Message ${suffix}`, -+ voiceProfileKey: profile.payload.data.ttsProfile.key, ++function canonicalDesiredUserRolePairs(roles, canonicalUsers = []) { + const creatorRole = roleBySlug(roles, "creator"); + const adminRole = roleBySlug(roles, "admin"); + const ownerRole = roleBySlug(roles, "owner"); ++ const davidq = davidqUserFromCanonicalUsers(canonicalUsers); + return new Set([ +- ...DEV_CREATOR_IDENTITIES ++ ...canonicalUsers + .filter(() => creatorRole) +- .map((identity) => `${identity.key}\u0000${creatorRole.key}`), +- adminRole ? `${SEED_DB_KEYS.users.admin}\u0000${adminRole.key}` : "", +- ownerRole ? `${SEED_DB_KEYS.users.admin}\u0000${ownerRole.key}` : "", ++ .map((user) => `${normalizedId(user.key)}\u0000${creatorRole.key}`), ++ adminRole && davidq ? `${normalizedId(davidq.key)}\u0000${adminRole.key}` : "", ++ ownerRole && davidq ? `${normalizedId(davidq.key)}\u0000${ownerRole.key}` : "", + ].filter(Boolean)); + } + +-function staleCanonicalUserRoleRecords({ roles, userRoles }) { ++function staleCanonicalUserRoleRecords({ canonicalUsers, roles, userRoles }) { + const validRoleKeys = new Set(roles.map((role) => normalizedId(role.key)).filter(Boolean)); +- const desiredPairs = canonicalDesiredUserRolePairs(roles); ++ const desiredPairs = canonicalDesiredUserRolePairs(roles, canonicalUsers); ++ const canonicalKeys = canonicalUserKeys(canonicalUsers); + const staleRoleKeys = new Set(KNOWN_STALE_ROLE_KEYS); + return userRoles +- .filter((row) => CANONICAL_KEYS.has(normalizedId(row.userKey))) ++ .filter((row) => canonicalKeys.has(normalizedId(row.userKey))) + .filter((row) => { + const roleKey = normalizedId(row.roleKey); + return staleRoleKeys.has(roleKey) || +@@ -229,8 +253,8 @@ function staleCanonicalUserRoleRecords({ roles, userRoles }) { + .filter((row) => row.key); + } + +-async function repairCanonicalUserRoles({ databaseProvider, dryRun, roles, userRoles }) { +- const staleRecords = staleCanonicalUserRoleRecords({ roles, userRoles }); ++async function repairCanonicalUserRoles({ canonicalUsers, databaseProvider, dryRun, roles, userRoles }) { ++ const staleRecords = staleCanonicalUserRoleRecords({ canonicalUsers, roles, userRoles }); + const deletedRecords = []; + for (const record of staleRecords) { + if (dryRun) { +@@ -352,20 +376,57 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu + }; + } + +-function canonicalUserRows(authUsers) { ++function canonicalPublicUsersByEmail(publicUsers = []) { ++ const byEmail = new Map(); ++ const duplicates = []; ++ publicUsers.forEach((user) => { ++ const email = normalizedEmail(user.email); ++ if (!isCanonicalDevIdentityEmail(email)) { ++ return; ++ } ++ if (byEmail.has(email)) { ++ duplicates.push(email); ++ return; ++ } ++ byEmail.set(email, user); + }); -+ expect(message.response.ok).toBe(true); -+ return message.payload.data.message; ++ if (duplicates.length) { ++ throw new Error(`Supabase DEV identity sync found duplicate database users rows for: ${Array.from(new Set(duplicates)).join(", ")}.`); ++ } ++ const missing = DEV_CREATOR_IDENTITIES ++ .map((identity) => identity.email) ++ .filter((email) => !byEmail.has(normalizedEmail(email))); ++ if (missing.length) { ++ throw new Error(`Supabase DEV identity sync requires existing database users rows for: ${missing.join(", ")}.`); ++ } ++ return byEmail; +} + -+async function openObjectsPage(page, { signedIn = true } = {}) { - const server = await startRepoServer(); - const failures = collectPageFailures(page); -+ await signInCreator(server.baseUrl); -+ await clearObjectsForActiveGame(server.baseUrl); -+ if (!signedIn) { -+ const { response } = await postApiPayload(server.baseUrl, "/api/session/logout", {}); -+ expect(response.ok).toBe(true); -+ } - await page.addInitScript(({ apiUrl, siteUrl }) => { - window.GameFoundryPublicConfig = { - apiUrl, -@@ -246,11 +310,13 @@ test("Objects exposes production copy, setup status, and broad table input", asy - await expect(firstActionGroup).toHaveClass(/action-group--tight/); - await expect(firstActionGroup.locator("button, a")).toHaveText([ - "Edit", -+ "Details", - "Open Hitboxes", - "Open Events", - "Trash", - ]); - await expect(firstActionCell.locator("[data-objects-edit-row='hero']")).toBeVisible(); -+ await expect(firstActionCell.locator("[data-objects-details-row='hero']")).toBeVisible(); - await expect(firstActionCell.locator("[data-objects-row-open-hitboxes]")).toBeVisible(); - await expect(firstActionCell.locator("[data-objects-row-open-events]")).toBeVisible(); - await expect(firstActionCell.locator("[data-objects-trash-row='hero']")).toBeVisible(); -@@ -309,6 +375,7 @@ test("Objects table add disables while active row can cancel, save, edit, and tr - await expect(page.locator("[data-objects-list]")).toContainText("None"); - await expect(page.locator("[data-objects-validation-list]")).not.toContainText("Render Asset"); - await expect(page.locator("[data-objects-edit-row='hero']")).toBeVisible(); -+ await expect(page.locator("[data-objects-details-row='hero']")).toBeVisible(); - await expect(page.locator("[data-objects-trash-row='hero']")).toBeVisible(); - const savedActionCell = page.locator("[data-objects-list] tr").first().locator("td").last(); - const savedActionGroup = savedActionCell.locator(".action-group"); -@@ -316,6 +383,7 @@ test("Objects table add disables while active row can cancel, save, edit, and tr - await expect(savedActionGroup).toHaveClass(/action-group--tight/); - await expect(savedActionGroup.locator("button, a")).toHaveText([ - "Edit", -+ "Details", - "Open Hitboxes", - "Open Events", - "Trash", -@@ -343,6 +411,26 @@ test("Objects table add disables while active row can cancel, save, edit, and tr ++function canonicalUsersFromPublicRows(publicUsersByEmail) { ++ return DEV_CREATOR_IDENTITIES.map((identity) => publicUsersByEmail.get(normalizedEmail(identity.email))); ++} ++ ++function canonicalUserRows(authUsers, publicUsersByEmail) { + const byEmail = authUsersByEmail(authUsers); + return DEV_CREATOR_IDENTITIES.map((identity) => { +- const authId = authUserId(byEmail.get(identity.email.toLowerCase())); ++ const email = normalizedEmail(identity.email); ++ const publicUser = publicUsersByEmail.get(email); ++ const authId = authUserId(byEmail.get(email)); ++ const userKey = normalizedId(publicUser?.key); ++ if (!userKey) { ++ throw new Error(`Supabase DEV identity sync could not resolve database users.key for ${identity.email}.`); ++ } + if (!authId) { + throw new Error(`Supabase DEV identity sync could not resolve Auth user id for ${identity.email}.`); + } + return { ++ ...publicUser, + authProvider: SUPABASE_AUTH_PROVIDER_ID, + authProviderUserId: authId, +- displayName: identity.displayName, +- email: identity.email, +- isActive: true, +- key: identity.key, ++ displayName: publicUser.displayName || identity.displayName, ++ email: publicUser.email || identity.email, ++ isActive: publicUser.isActive !== false, ++ key: userKey, + }; + }); + } +@@ -479,7 +540,6 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU + .filter((role) => String(role.roleSlug || "") === "user") + .map((role) => normalizedId(role.key)) + .filter(Boolean)); +- const desiredPairs = canonicalDesiredUserRolePairs(afterRoles); + const identityEvidence = DEV_CREATOR_IDENTITIES.map((identity) => { + const auth = authByEmail.get(identity.email.toLowerCase()); + const appUser = publicByEmail.get(identity.email.toLowerCase()); +@@ -496,6 +556,11 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU + appUser.authProviderUserId === authUserId(auth), + }; + }); ++ const canonicalUsers = identityEvidence ++ .map((record) => publicByEmail.get(normalizedEmail(record.email))) ++ .filter(Boolean); ++ const canonicalKeys = canonicalUserKeys(canonicalUsers); ++ const desiredPairs = canonicalDesiredUserRolePairs(afterRoles, canonicalUsers); + const creatorAssignments = DEV_CREATOR_IDENTITIES.map((identity) => { + const appUser = publicByEmail.get(identity.email.toLowerCase()); + return { +@@ -504,12 +569,12 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU + roleSlug: "creator", + }; + }); +- const davidq = publicByEmail.get("qbytes.dq@gmail.com"); ++ const davidq = publicByEmail.get(DAVIDQ_DEV_EMAIL); + const davidqAdminAssignment = Boolean(davidq && adminRole && userRolePairs.has(`${davidq.key}\u0000${adminRole.key}`)); + const davidqOwnerAssignment = Boolean(davidq && ownerRole && userRolePairs.has(`${davidq.key}\u0000${ownerRole.key}`)); + const roleEvidence = { + davidqAdmin: davidqAdminAssignment, +- davidqCreator: creatorAssignments.find((record) => record.email === "qbytes.dq@gmail.com")?.assigned === true, ++ davidqCreator: creatorAssignments.find((record) => record.email === DAVIDQ_DEV_EMAIL)?.assigned === true, + davidqOwner: davidqOwnerAssignment, + user1Creator: creatorAssignments.find((record) => record.email === "user1@example.invalid")?.assigned === true, + user2Creator: creatorAssignments.find((record) => record.email === "user2@example.invalid")?.assigned === true, +@@ -539,7 +604,7 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU + userRoleKey: normalizedId(row.key), + })); + const unexpectedCanonicalAssignments = afterUserRoles +- .filter((row) => CANONICAL_KEYS.has(normalizedId(row.userKey))) ++ .filter((row) => canonicalKeys.has(normalizedId(row.userKey))) + .filter((row) => !desiredPairs.has(`${normalizedId(row.userKey)}\u0000${normalizedId(row.roleKey)}`)) + .map((row) => ({ + roleKey: normalizedId(row.roleKey), +@@ -621,6 +686,12 @@ export async function syncSupabaseDevCreatorIdentities({ + assertDevOnlyAuthTestCleanupEnvironment(env); + const beforeAuthUsers = await authProvider.listAllAdminUsers(); + const beforePublicUsers = await databaseProvider.getUsers(); ++ const publicUsersByEmail = canonicalPublicUsersByEmail(beforePublicUsers); ++ const canonicalPublicUsers = canonicalUsersFromPublicRows(publicUsersByEmail); ++ const davidqPublicUser = davidqUserFromCanonicalUsers(canonicalPublicUsers); ++ if (!davidqPublicUser) { ++ throw new Error(`Supabase DEV identity sync requires existing database users row for ${DAVIDQ_DEV_EMAIL}.`); ++ } + const beforeCounts = identityCounts({ authUsers: beforeAuthUsers, publicUsers: beforePublicUsers }); + + const existingByEmail = authUsersByEmail(beforeAuthUsers); +@@ -630,13 +701,15 @@ export async function syncSupabaseDevCreatorIdentities({ } - }); - -+test("Guest save redirects to sign-in", async ({ page }) => { -+ const failures = await openObjectsPage(page, { signedIn: false }); + + const authUsersForIdentity = dryRun ? beforeAuthUsers : await authProvider.listAllAdminUsers(); ++ const canonicalUsers = canonicalUserRows(authUsersForIdentity, publicUsersByEmail); ++ const userRoleRequests = canonicalUserRoleRequests(canonicalUsers); + const initialized = dryRun + ? { + dryRun: true, + initialized: { + roles: DEV_ROLE_DEFINITIONS.length, +- user_roles: DEV_CREATOR_IDENTITIES.length + 2, +- users: DEV_CREATOR_IDENTITIES.length, ++ user_roles: userRoleRequests.length, ++ users: canonicalUsers.length, + }, + written: { + roles: 0, +@@ -645,29 +718,17 @@ export async function syncSupabaseDevCreatorIdentities({ + }, + } + : await databaseProvider.initializeIdentity({ +- actorKey: SEED_DB_KEYS.users.admin, ++ actorKey: davidqPublicUser.key, + roles: DEV_ROLE_DEFINITIONS, +- userRoles: [ +- ...DEV_CREATOR_IDENTITIES.map((identity) => ({ +- roleSlug: "creator", +- userKey: identity.key, +- })), +- { +- roleSlug: "admin", +- userKey: SEED_DB_KEYS.users.admin, +- }, +- { +- roleSlug: "owner", +- userKey: SEED_DB_KEYS.users.admin, +- }, +- ], +- users: canonicalUserRows(authUsersForIdentity), ++ userRoles: userRoleRequests, ++ users: canonicalUsers, + }); + + const usersForCleanup = await databaseProvider.getUsers(); + const rolesForCleanup = await databaseProvider.getRoles(); + const userRolesForCleanup = await databaseProvider.getUserRoles(); + const userRoleRepair = await repairCanonicalUserRoles({ ++ canonicalUsers, + databaseProvider, + dryRun, + roles: rolesForCleanup, +@@ -685,7 +746,7 @@ export async function syncSupabaseDevCreatorIdentities({ + const userRolesForPublicCleanup = dryRun ? userRolesForCleanup : await databaseProvider.getUserRoles(); + const publicCleanupCandidates = usersForCleanup.filter(isManagedExtraPublicUser); + const publicCleanup = await deleteManagedPublicUsers({ +- auditUserKey: SEED_DB_KEYS.users.admin, ++ auditUserKey: davidqPublicUser.key, + authProvider, + candidates: publicCleanupCandidates, + databaseProvider, +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +new file mode 100644 +index 000000000..c807ea892 +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +@@ -0,0 +1,23 @@ ++# Branch Validation: PR_26179_OWNER_035-dev-auth-user-key-db-authority + -+ try { -+ await page.getByRole("button", { name: "Add Object" }).click(); -+ await fillActiveRow(page, { -+ name: "Guest Object", -+ type: "Hero", -+ }); -+ await page.locator("[data-objects-save-row]").click(); -+ await page.waitForURL(/\/account\/sign-in\.html$/); -+ expect(failures.failedRequests.filter((request) => /^\d/.test(request) && !request.includes("/account/sign-in.html"))).toEqual([]); -+ expect(failures.pageErrors).toEqual([]); -+ expect(failures.consoleErrors).toEqual([]); -+ } finally { -+ await workspaceV2CoverageReporter.stop(page); -+ await failures.server.close(); -+ } -+}); ++## Status + - test("Objects persists added, edited, deleted, and sprite-linked rows through shared DB", async ({ page }) => { - const failures = await openObjectsPage(page); - -@@ -430,6 +518,126 @@ test("Objects persists added, edited, deleted, and sprite-linked rows through sh - } - }); - -+test("Object Details panel saves reviewable properties through shared DB", async ({ page }) => { -+ const failures = await openObjectsPage(page); ++PASS + -+ try { -+ const message = await createReviewMessage(failures.server.baseUrl); ++## Branch + -+ await page.getByRole("button", { name: "Add Object" }).click(); -+ await fillActiveRow(page, { -+ name: "Review Hero", -+ renderType: "Sprite", -+ type: "Hero", -+ }); -+ await page.locator("[data-objects-save-row]").click(); -+ await expect(page.locator("[data-objects-log]")).toContainText("Added Review Hero."); -+ await expect(page.locator("[data-objects-log]")).toContainText("Created editable default sprite asset sprite_review_hero for Review Hero."); ++- Current branch: `PR_26179_OWNER_035-dev-auth-user-key-db-authority` ++- Source branch: `main` ++- Worktree at report time: modified files only for this PR plus generated report artifacts + -+ await page.locator("[data-objects-details-row='review-hero']").click(); -+ await expect(page.locator("[data-objects-detail-selected]")).toHaveText("Review Hero"); -+ await expect(page.locator("[data-objects-detail-name]")).toHaveValue("Review Hero"); -+ await expect(page.locator("[data-objects-detail-type] option")).toHaveText(["Select type", ...TYPE_OPTIONS]); -+ await expect(page.locator("[data-objects-detail-type]")).toHaveValue("Hero"); -+ await expect(page.locator("[data-objects-detail-active]")).toBeChecked(); -+ await expect(page.locator("[data-objects-detail-visible]")).toBeChecked(); -+ await expect(page.locator("[data-objects-detail-sprite]")).toHaveValue("sprite_review_hero"); -+ await expect(page.locator("[data-objects-detail-unsaved]")).toHaveText("Unsaved changes: none"); -+ await expect(page.locator("[data-objects-detail-save]")).toBeDisabled(); -+ await expect(page.locator("[data-objects-asset-links]")).toContainText("Sprite:"); -+ await expect(page.locator("[data-objects-asset-links]")).toContainText("sprite_review_hero"); -+ await expect(page.locator("[data-objects-asset-links]")).toContainText("Audio: No reference set."); -+ await expect(page.locator("[data-objects-asset-links]")).toContainText("Message: No reference set."); -+ await expect(page.locator("[data-objects-details-form]")).not.toContainText(/\bBehavior\b|\bRules\b|\bWorlds\b/); ++## Validation Commands + -+ await page.locator("[data-objects-detail-description]").fill("Hero object used by Product Owner review."); -+ await expect(page.locator("[data-objects-detail-unsaved]")).toHaveText("Unsaved changes: yes"); -+ await expect(page.locator("[data-objects-detail-save]")).toBeEnabled(); -+ await page.locator("[data-objects-detail-cancel]").click(); -+ await expect(page.locator("[data-objects-detail-description]")).toHaveValue(""); -+ await expect(page.locator("[data-objects-detail-unsaved]")).toHaveText("Unsaved changes: none"); -+ await expect(page.locator("[data-objects-log]")).toHaveText("Canceled Object Details changes."); ++- PASS `node --check src/dev-runtime/server/local-api-router.mjs` ++- PASS `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` ++- PASS `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- PASS `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- PASS `git diff --check` ++- PASS `npm run validate:canonical-structure` + -+ await page.locator("[data-objects-detail-name]").fill(""); -+ await page.locator("[data-objects-detail-save]").click(); -+ await expect(page.locator("[data-objects-validation-list]")).toContainText("Name: Enter a name before saving Object Details."); -+ await expect(page.locator("[data-objects-log]")).toHaveText("Object Details blocked: 1 validation action."); +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +new file mode 100644 +index 000000000..005ca851f +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +@@ -0,0 +1,14 @@ ++# Manual Validation Notes: PR_26179_OWNER_035-dev-auth-user-key-db-authority + -+ await page.locator("[data-objects-detail-name]").fill("Review Hero Prime"); -+ await page.locator("[data-objects-detail-description]").fill("Hero object used by Product Owner review."); -+ await page.locator("[data-objects-detail-type]").selectOption("Enemy"); -+ await page.locator("[data-objects-detail-tags]").fill("hero, review, boss-fight"); -+ await page.locator("[data-objects-detail-active]").uncheck(); -+ await page.locator("[data-objects-detail-visible]").uncheck(); -+ await page.locator("[data-objects-detail-sprite]").fill("sprite_review_hero"); -+ await page.locator("[data-objects-detail-audio]").fill("missing-review-hero.wav"); -+ await page.locator("[data-objects-detail-message]").fill("missing-message-key"); -+ await page.locator("[data-objects-detail-defaults]").fill("speed=8\nhealth=3"); -+ await page.locator("[data-objects-detail-save]").click(); -+ await expect(page.locator("[data-objects-validation-list]")).toContainText("Audio reference: Use an existing audio asset reference"); -+ await expect(page.locator("[data-objects-validation-list]")).toContainText("Message reference: Use an existing message reference"); -+ await expect(page.locator("[data-objects-log]")).toHaveText("Object Details blocked: 2 validation actions."); ++Manual browser validation was not run because this PR changes server-side auth/session resolution and DEV identity sync behavior. The targeted API-level tests cover the affected user-visible outcomes: + -+ await page.locator("[data-objects-detail-audio]").fill(""); -+ await page.locator("[data-objects-detail-message]").fill(message.key); -+ await page.locator("[data-objects-detail-save]").click(); -+ await expect(page.locator("[data-objects-log]")).toHaveText("Saved Object Details for Review Hero Prime."); -+ await expect(page.locator("[data-objects-list]")).toContainText("Review Hero Prime"); -+ await expect(page.locator("[data-objects-list] tr").first().locator("td").nth(1)).toHaveText("Enemy"); -+ await expect(page.locator("[data-objects-list] tr").first().locator("td").nth(2)).toHaveText("Disabled"); -+ await expect(page.locator("[data-objects-detail-unsaved]")).toHaveText("Unsaved changes: none"); -+ await expect(page.locator("[data-objects-asset-links]")).toContainText("Audio: No reference set."); -+ await expect(page.locator("[data-objects-asset-links]")).toContainText(message.name); -+ await expect(page.locator("[data-objects-asset-link-sprite]")).toHaveAttribute( -+ "href", -+ "/toolbox/sprites/index.html?assetKey=sprite_review_hero&objectKey=review-hero-prime&sourceTool=objects" -+ ); -+ await expect(page.locator("[data-objects-asset-link-audio]")).toHaveCount(0); -+ await expect(page.locator("[data-objects-message-link]")).toHaveAttribute("href", "/toolbox/messages/index.html"); -+ await expect(page.locator("[data-objects-reference-warning]")).toHaveCount(0); ++- A valid Supabase Auth id linked to `users.authProviderUserId` signs in and returns the database-owned `users.key`. ++- A matching email with stale `users.authProviderUserId` does not sign in and returns the existing Creator-safe identity setup message. ++- The browser response does not expose raw Auth ids or database user keys on the failure path. + -+ const rows = await objectDefinitionRecords(page); -+ expect(rows).toHaveLength(1); -+ expect(rows[0]).toEqual(expect.objectContaining({ -+ name: "Review Hero Prime", -+ renderAssetKey: "sprite_review_hero", -+ renderType: "Sprite", -+ state: "Disabled", -+ type: "Enemy", -+ })); -+ expect(rows[0].interaction.details).toEqual({ -+ active: false, -+ audioReference: "", -+ defaultValues: "speed=8\nhealth=3", -+ description: "Hero object used by Product Owner review.", -+ messageReference: message.key, -+ spriteReference: "sprite_review_hero", -+ tags: ["hero", "review", "boss-fight"], -+ visible: false, -+ }); ++Recommended manual follow-up in DEV after merge: + -+ await page.reload({ waitUntil: "networkidle" }); -+ await expect(page.locator("[data-objects-list]")).toContainText("Review Hero Prime"); -+ await expect(page.locator("[data-objects-detail-selected]")).toHaveText("Review Hero Prime"); -+ await expect(page.locator("[data-objects-detail-description]")).toHaveValue("Hero object used by Product Owner review."); -+ await expect(page.locator("[data-objects-detail-type]")).toHaveValue("Enemy"); -+ await expect(page.locator("[data-objects-detail-tags]")).toHaveValue("hero, review, boss-fight"); -+ await expect(page.locator("[data-objects-detail-active]")).not.toBeChecked(); -+ await expect(page.locator("[data-objects-detail-visible]")).not.toBeChecked(); -+ await expect(page.locator("[data-objects-detail-sprite]")).toHaveValue("sprite_review_hero"); -+ await expect(page.locator("[data-objects-detail-audio]")).toHaveValue(""); -+ await expect(page.locator("[data-objects-detail-message]")).toHaveValue(message.key); -+ await expect(page.locator("[data-objects-detail-defaults]")).toHaveValue("speed=8\nhealth=3"); -+ await expect(page.locator("[data-objects-asset-links]")).toContainText("Audio: No reference set."); -+ await expect(page.locator("[data-objects-asset-links]")).toContainText(message.name); ++- Run the approved DEV identity sync. ++- Sign in as `qbytes.dq@gmail.com`. ++- Confirm the session resolves to the existing database `users.key` row whose `authProviderUserId` equals the Supabase Auth user id. + -+ await expectNoPageFailures(failures); -+ } finally { -+ await workspaceV2CoverageReporter.stop(page); -+ await failures.server.close(); -+ } -+}); +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +new file mode 100644 +index 000000000..94dfcbd73 +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +@@ -0,0 +1,61 @@ ++# PR_26179_OWNER_035-dev-auth-user-key-db-authority + - test("Object Type Catalog selection prefills active table rows", async ({ page }) => { - const failures = await openObjectsPage(page); - -@@ -494,6 +702,7 @@ test("Object Type Catalog selection prefills active table rows", async ({ page } - await expect(catalogActionGroup).toHaveClass(/action-group--tight/); - await expect(catalogActionGroup.locator("button, a")).toHaveText([ - "Edit", -+ "Details", - "Edit Sprite", - "Open Hitboxes", - "Open Events", -diff --git a/src/dev-runtime/server/local-api-router.mjs b/src/dev-runtime/server/local-api-router.mjs -index bb54b12b7..bfe82e4e8 100644 ---- a/src/dev-runtime/server/local-api-router.mjs -+++ b/src/dev-runtime/server/local-api-router.mjs -@@ -7,9 +7,6 @@ import { - createAssetToolMockRepository, - pickerDiagnosticForRole, - } from "../persistence/tool-repositories/assets-mock-repository.js"; --import { -- createObjectsToolMockRepository, --} from "../persistence/tool-repositories/objects-mock-repository.js"; - import { - createHitboxesToolMockRepository, - } from "../persistence/tool-repositories/hitboxes-mock-repository.js"; -@@ -66,6 +63,7 @@ import { - TAGS_TOOL_TABLES, - createGameConfigurationApiService, - createGameDesignApiService, -+ createObjectsApiService, - createTagsApiService, - } from "../toolbox-api/alfa-tool-services.mjs"; - import { -@@ -4077,13 +4075,15 @@ class ApiRuntimeDataSource { - ...this.sharedOptions, - sessionUserKey: () => this.sessionUserKey, - }); -- this.objectsRepository = createObjectsToolMockRepository({ -- gameWorkspaceRepository: this.gameWorkspaceRepository, -- ...this.sharedOptions, -+ this.objectsRepository = createObjectsApiService({ -+ ...alfaServiceOptions, -+ sessionUserKey: () => this.sessionUserKey, - }); - this.hitboxesRepository = createHitboxesToolMockRepository({ - gameWorkspaceRepository: this.gameWorkspaceRepository, -- objectsRepository: this.objectsRepository, -+ objectsRepository: { -+ listObjects: (gameId = "") => this.objectsRepository.listCachedObjects(gameId), -+ }, - ...this.sharedOptions, - sessionUserKey: () => this.sessionUserKey, - }); -diff --git a/src/dev-runtime/toolbox-api/alfa-tool-services.mjs b/src/dev-runtime/toolbox-api/alfa-tool-services.mjs -index 239c22a4d..d24eb63ac 100644 ---- a/src/dev-runtime/toolbox-api/alfa-tool-services.mjs -+++ b/src/dev-runtime/toolbox-api/alfa-tool-services.mjs -@@ -17,6 +17,102 @@ export const GAME_CONFIGURATION_TABLES = Object.freeze([ - "game_configuration_validation_items", - ]); - -+export const OBJECTS_TOOL_TABLES = Object.freeze([ -+ "object_definition_records", -+]); ++## Summary + -+export const CAPABILITY_LABELS = Object.freeze({ -+ collectible: "Can Be Collected", -+ collides: "Can Collide", -+ damageable: "Takes Damage", -+ goal: "Completes Goal", -+ hazard: "Causes Damage", -+ killable: "Can Be Removed", -+ movable: "Can Move", -+ playerControlled: "Player Controlled", -+ scores: "Scores Points", -+}); ++This PR makes DEV account session resolution treat the database `users` row as authoritative. + -+export const OBJECT_TYPE_TEMPLATES = Object.freeze([ -+ Object.freeze({ -+ capabilities: Object.freeze(["collectible", "scores"]), -+ modelType: "Collectible", -+ renderType: "Sprite", -+ state: "Active", -+ type: "Collectible", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze([]), -+ modelType: "Static", -+ renderType: "None", -+ state: "Active", -+ type: "Custom", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze([]), -+ modelType: "Static", -+ renderType: "Sprite", -+ state: "Idle", -+ type: "Decoration", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze(["movable", "collides", "hazard", "damageable"]), -+ modelType: "Dynamic", -+ renderType: "Sprite", -+ state: "Active", -+ type: "Enemy", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze(["goal", "scores"]), -+ modelType: "Goal", -+ renderType: "Sprite", -+ state: "Active", -+ type: "Goal", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze(["hazard", "damageable"]), -+ modelType: "Hazard", -+ renderType: "Sprite", -+ state: "Active", -+ type: "Hazard", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze(["playerControlled", "movable", "collides", "damageable"]), -+ modelType: "Dynamic", -+ renderType: "Sprite", -+ state: "Active", -+ type: "Hero", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze(["collides"]), -+ modelType: "Static", -+ renderType: "None", -+ state: "Active", -+ type: "Platform", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze(["movable", "collides", "hazard"]), -+ modelType: "Dynamic", -+ renderType: "Sprite", -+ state: "Active", -+ type: "Projectile", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze([]), -+ modelType: "Static", -+ renderType: "None", -+ state: "Active", -+ type: "Spawn Point", -+ }), -+ Object.freeze({ -+ capabilities: Object.freeze(["collides"]), -+ modelType: "Static", -+ renderType: "None", -+ state: "Active", -+ type: "Wall", -+ }), -+]); ++Runtime sign-in now resolves a session only when the Supabase Auth user id matches `users.authProviderUserId`. Matching by email alone is no longer accepted for login/session resolution. Email remains valid only for the DEV identity sync step that locates the existing database `users` row before writing the real Supabase Auth id back to `users.authProviderUserId`. + - export const GAME_DESIGN_GAME_TYPES = Object.freeze([ - "2D Platformer", - "Arcade Action", -@@ -114,6 +210,33 @@ const STARTER_TAGS = Object.freeze([ - Object.freeze({ label: "boss-fight", description: "Includes a climactic boss encounter." }), - ]); - -+export const STARTER_OBJECTS = Object.freeze([ -+ Object.freeze({ -+ behavior: "Responds to player control mapping.", -+ interaction: "Can interact with platforms, collectibles, hazards, and goals.", -+ name: "Hero", -+ render: Object.freeze({ type: "None" }), -+ role: "Hero", -+ state: "Active", -+ }), -+ Object.freeze({ -+ behavior: "Moves through the scene under authored behavior.", -+ interaction: "Can collide with walls, platforms, or targets.", -+ name: "Projectile", -+ render: Object.freeze({ type: "None" }), -+ role: "Projectile", -+ state: "Active", -+ }), -+ Object.freeze({ -+ behavior: "Stays fixed in the scene.", -+ interaction: "Provides a stable collision surface.", -+ name: "Wall", -+ render: Object.freeze({ type: "None" }), -+ role: "Wall", -+ state: "Active", -+ }), -+]); ++## Diagnostic Findings + - const SECTION_LABELS = Object.freeze({ - gameDetails: "Game Details", - platforms: "Platforms", -@@ -348,6 +471,300 @@ function starterTagRow(tag, index) { - }; - } - -+function objectKeyFromText(value) { -+ return slugify(value); -+} ++### Where `users.key` currently comes from + -+function parseObjectCapabilities(value) { -+ if (Array.isArray(value)) { -+ return value.map(normalizeText).filter(Boolean); -+ } -+ const text = normalizeText(value); -+ if (!text) { -+ return []; -+ } -+ try { -+ const parsed = JSON.parse(text); -+ if (Array.isArray(parsed)) { -+ return parsed.map(normalizeText).filter(Boolean); -+ } -+ } catch {} -+ return text.split(",").map(normalizeText).filter(Boolean); -+} ++- DEV seed setup defines static user keys in `src/dev-runtime/seed/seed-db-keys.mjs`. ++- Current database seed DML also contains DEV account rows under `dev/build/database/dml/account.sql`. ++- Before this PR, `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` imported those seed keys and used them as the canonical keys during DEV Supabase identity sync. ++- After this PR, DEV sync locates existing `users` rows by email and reads `users.key` from the database row. The sync no longer imports `SEED_DB_KEYS`. + -+function objectTemplateForRole(role) { -+ return OBJECT_TYPE_TEMPLATES.find((template) => template.type === normalizeText(role)) || null; -+} ++### Where `authProviderUserId` is populated + -+function normalizeObjectRender(source = {}) { -+ const render = source.render && typeof source.render === "object" ? source.render : {}; -+ const renderType = normalizeText(source.renderType || render.type) === "Sprite" ? "Sprite" : "None"; -+ if (renderType !== "Sprite") { -+ return { -+ assetKey: "", -+ previewPath: "", -+ type: "None", -+ }; -+ } -+ return { -+ assetKey: normalizeText(source.renderAssetKey || render.assetKey), -+ previewPath: normalizeText(source.renderPreviewPath || render.previewPath), -+ type: "Sprite", -+ }; -+} ++- Account creation provisioning still writes `authProviderUserId` in `src/dev-runtime/server/local-api-router.mjs` through `provisionSupabaseIdentityForAuthPayload()`. ++- DEV identity sync now reads `auth.users.id` from Supabase Auth, reads `users.key` from the database row found by email, and sends that database-owned key plus the real Auth id through `databaseProvider.initializeIdentity()`. + -+function objectTextField(value) { -+ if (typeof value === "string") { -+ return normalizeText(value); -+ } -+ if (value && typeof value === "object") { -+ return normalizeText(value.text || value.description || value.summary); -+ } -+ return ""; -+} ++### Seed constant use + -+function parseObjectTags(value) { -+ const values = Array.isArray(value) -+ ? value -+ : normalizeText(value).split(","); -+ return [...new Set(values.map(normalizeText).filter(Boolean))]; -+} ++- Seed constants remain valid for DEV seed setup, seed fixtures, and existing test/demo data. ++- They are no longer used by the DEV identity sync helper as the authoritative app user key source. ++- Runtime sign-in/session resolution does not hardcode DavidQ or other user keys. It reads identity tables and resolves `matchingUser.key` after matching Supabase Auth id to `users.authProviderUserId`. + -+function objectBooleanField(value, fallback) { -+ if (value === true || value === "true" || value === "1") { -+ return true; -+ } -+ if (value === false || value === "false" || value === "0") { -+ return false; -+ } -+ return fallback; -+} ++## Exact Fix + -+function objectDetailsFromSource(source = {}, render = normalizeObjectRender(source)) { -+ const details = source.details && typeof source.details === "object" ? source.details : {}; -+ return { -+ active: objectBooleanField(details.active ?? source.active, true), -+ audioReference: normalizeText(details.audioReference ?? source.audioReference), -+ defaultValues: normalizeText(details.defaultValues ?? source.defaultValues), -+ description: normalizeText(details.description ?? source.description), -+ messageReference: normalizeText(details.messageReference ?? source.messageReference), -+ spriteReference: normalizeText(details.spriteReference ?? source.spriteReference) || normalizeText(render.assetKey), -+ tags: parseObjectTags(details.tags ?? source.tags), -+ visible: objectBooleanField(details.visible ?? source.visible, true), -+ }; -+} ++- Removed email fallback from Supabase sign-in session resolution. ++- Added a Creator-safe setup failure when Auth succeeds but the matching email row has not been linked to the Auth id. ++- Added a narrow server-side Postgres client injection hook for tests, with default runtime behavior unchanged. ++- Refactored DEV creator identity sync so canonical identity definitions are email-based and database `users.key` is read from existing rows. ++- Changed role assignment and cleanup repair logic to derive canonical user keys from database rows. ++- Added regression tests proving: ++ - Sign-in uses the database-owned `users.key` matched by `authProviderUserId`. ++ - Sign-in does not create a session from matching email when `authProviderUserId` is stale. ++ - DEV sync preserves non-seed database user keys. ++ - DEV sync fails if an expected database `users` row is missing. + -+function objectFromRecord(record = {}) { -+ const render = normalizeObjectRender(record); -+ const interaction = record.interaction && typeof record.interaction === "object" ? record.interaction : {}; -+ return { -+ behavior: objectTextField(record.behavior), -+ details: objectDetailsFromSource(interaction.details || interaction, render), -+ id: objectKeyFromText(record.id || record.name), -+ interaction: objectTextField(record.interaction), -+ name: normalizeText(record.name), -+ render: render.type === "Sprite" -+ ? { -+ assetKey: render.assetKey, -+ previewPath: render.previewPath, -+ type: "Sprite", -+ } -+ : { type: "None" }, -+ role: normalizeText(record.type), -+ state: normalizeText(record.state) || "Active", -+ traits: parseObjectCapabilities(record.capabilities), -+ type: normalizeText(record.modelType), -+ }; -+} ++## Files Changed + -+function sortedObjectRecords(rows = []) { -+ return cloneRows(rows).sort((left, right) => ( -+ (Number(left.recordOrder) || 0) - (Number(right.recordOrder) || 0) -+ || normalizeText(left.name).localeCompare(normalizeText(right.name)) -+ )); -+} ++- `src/dev-runtime/server/local-api-router.mjs` ++- `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` ++- `dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- `dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` + -+function normalizeObjectInput(input = {}) { -+ const role = normalizeText(input.role || input.type); -+ const template = objectTemplateForRole(role); -+ const id = objectKeyFromText(input.id || input.name); -+ const render = normalizeObjectRender(input); -+ const details = objectDetailsFromSource(input, render); -+ return { -+ behavior: normalizeText(input.behavior), -+ capabilities: parseObjectCapabilities(input.traits || input.capabilities), -+ details, -+ id, -+ interaction: normalizeText(input.interaction), -+ modelType: normalizeText(input.type) || template?.modelType || "", -+ name: normalizeText(input.name), -+ render, -+ role, -+ state: normalizeText(input.state) || template?.state || "Active", -+ }; -+} ++## Validation + -+function objectRecordFromInput(input, { -+ adapter, -+ existing = null, -+ gameKey, -+ index, -+ userKey, -+}) { -+ const object = normalizeObjectInput(input); -+ const now = timestamp(); -+ return { -+ behavior: object.behavior ? { text: object.behavior } : {}, -+ capabilities: object.capabilities, -+ createdAt: existing?.createdAt || now, -+ createdBy: existing?.createdBy || userKey, -+ gameId: gameKey, -+ id: object.id, -+ interaction: { -+ ...(object.interaction ? { text: object.interaction } : {}), -+ details: object.details, -+ }, -+ key: existing?.key || adapter.createRecordKey(), -+ modelType: object.modelType, -+ name: object.name, -+ recordOrder: index + 1, -+ renderAssetKey: object.render.assetKey || null, -+ renderPreviewPath: object.render.previewPath, -+ renderType: object.render.type, -+ state: object.state, -+ type: object.role, -+ updatedAt: now, -+ updatedBy: userKey, -+ }; -+} ++PASS: + -+function objectsApiSetupError(action, error) { -+ if (error?.name === "ObjectsApiSetupError") { -+ return error; -+ } -+ const message = `${action} failed because the Objects API database setup is unavailable. Verify the API database connection and apply the account, Game Hub, Assets, and Objects database setup before using Objects.`; -+ const wrapped = new Error(message); -+ wrapped.name = "ObjectsApiSetupError"; -+ wrapped.statusCode = typeof error?.statusCode === "number" ? error.statusCode : 503; -+ wrapped.operatorDiagnostic = `${message} Cause: ${error instanceof Error ? error.message : String(error || "Unknown database error.")}`; -+ wrapped.cause = error; -+ return wrapped; -+} ++- `node --check src/dev-runtime/server/local-api-router.mjs` ++- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` ++- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- `git diff --check` ++- `npm run validate:canonical-structure` + -+export function createObjectsApiService(options = {}) { -+ let lastTables = { object_definition_records: [] }; +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +new file mode 100644 +index 000000000..180e988c1 +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +@@ -0,0 +1,18 @@ ++# Requirement Checklist: PR_26179_OWNER_035-dev-auth-user-key-db-authority ++ ++- PASS Do not use hardcoded user keys as runtime identity source. ++- PASS Use email only to locate the existing DEV `users` row during DEV identity sync. ++- PASS Read `users.key` from the database row during DEV sync. ++- PASS Read `auth.users.id` from Supabase Auth during DEV sync. ++- PASS Write `auth.users.id` into `users.authProviderUserId`. ++- PASS Login/session resolution matches Supabase Auth id to `users.authProviderUserId`. ++- PASS Do not create browser-owned auth. ++- PASS Do not create fake login. ++- PASS Do not add password tables. ++- PASS Do not use `displayName` as an identity key. ++- PASS Do not hardcode DavidQ/user keys in runtime login resolution. ++- PASS Report where `users.key` currently comes from. ++- PASS Report where `authProviderUserId` is populated. ++- PASS Report whether seed constants are setup-only or runtime identity authority. ++- PASS Report exact fix. ++ +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +new file mode 100644 +index 000000000..634164f94 +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +@@ -0,0 +1,18 @@ ++# Validation Lane Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority + -+ function activeGame() { -+ return activeGameFromWorkspace(options.gameWorkspaceRepository); -+ } ++## Lane + -+ function objectsFromTables(tables = lastTables, gameKey = activeGame().key) { -+ return sortedObjectRecords(tables.object_definition_records) -+ .filter((record) => normalizeText(record.gameId || record.gameKey || record.projectKey) === gameKey) -+ .map(objectFromRecord); -+ } ++Targeted auth and DEV identity sync validation. + -+ async function readTables() { -+ const action = "Reading Objects"; -+ try { -+ const adapter = databaseAdapter(options, action); -+ await ensureGameRecord(adapter, activeGame(), activeUserKey(options)); -+ lastTables = { -+ object_definition_records: await adapter.requestTable("object_definition_records"), -+ }; -+ return lastTables; -+ } catch (error) { -+ throw objectsApiSetupError(action, error); -+ } -+ } ++## Commands + -+ async function listObjects(gameId = "") { -+ const game = activeGameFromWorkspace(options.gameWorkspaceRepository, gameId); -+ const tables = await readTables(); -+ return objectsFromTables(tables, game.key); -+ } ++- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` + -+ function listCachedObjects(gameId = "") { -+ const game = activeGameFromWorkspace(options.gameWorkspaceRepository, gameId); -+ return objectsFromTables(lastTables, game.key); -+ } ++## Coverage + -+ async function replaceObjects(objects = [], gameId = "") { -+ const action = "Saving Objects"; -+ try { -+ const adapter = databaseAdapter(options, action); -+ const game = activeGameFromWorkspace(options.gameWorkspaceRepository, gameId); -+ const userKey = activeUserKey(options); -+ await ensureGameRecord(adapter, game, userKey); -+ const existingRows = await adapter.requestTable("object_definition_records", { -+ query: `gameId=eq.${encodeURIComponent(game.key)}`, -+ }); -+ const existingById = new Map(existingRows.map((record) => [objectKeyFromText(record.id || record.name), record])); -+ for (const record of existingRows) { -+ await adapter.requestTable("object_definition_records", { -+ method: "DELETE", -+ prefer: "return=representation", -+ query: `key=eq.${encodeURIComponent(record.key)}`, -+ }); -+ } -+ const nextRows = (Array.isArray(objects) ? objects : []) -+ .map((object, index) => objectRecordFromInput(object, { -+ adapter, -+ existing: existingById.get(objectKeyFromText(object.id || object.name)) || null, -+ gameKey: game.key, -+ index, -+ userKey, -+ })) -+ .filter((row) => row.id && row.name); -+ if (nextRows.length) { -+ await adapter.upsertProductTable("object_definition_records", nextRows); -+ } -+ await readTables(); -+ return { -+ objects: listCachedObjects(game.id), -+ saved: true, -+ snapshot: await getSnapshot(), -+ }; -+ } catch (error) { -+ throw objectsApiSetupError(action, error); -+ } -+ } ++- DEV identity sync reads existing database `users.key` values by email. ++- DEV identity sync writes real Supabase Auth ids to `users.authProviderUserId`. ++- Session resolution accepts only `auth.users.id` matched to `users.authProviderUserId`. ++- Email-only session resolution is rejected with the existing Creator-safe setup message. + -+ async function resetObjects(gameId = "") { -+ return replaceObjects([], gameId); -+ } +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md +new file mode 100644 +index 000000000..5d5f107cc +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md +@@ -0,0 +1,24 @@ ++# Validation Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority + -+ async function getSnapshot() { -+ const tables = await readTables(); -+ const game = activeGame(); -+ const objects = objectsFromTables(tables, game.key); -+ return { -+ activeGame: game, -+ activeProject: game, -+ objects, -+ tableCounts: OBJECTS_TOOL_TABLES.map((table) => ({ rows: tables[table].length, table })), -+ tables: cloneTables(OBJECTS_TOOL_TABLES, tables), -+ }; -+ } ++## Result + -+ return { -+ CAPABILITY_LABELS, -+ OBJECTS_TOOL_TABLES, -+ OBJECT_TYPE_TEMPLATES, -+ STARTER_OBJECTS, -+ authenticationRequiredMessage: "Sign in required to save Objects through the API.", -+ getSnapshot, -+ getTables: () => cloneTables(OBJECTS_TOOL_TABLES, lastTables), -+ listCachedObjects, -+ listObjects, -+ replaceObjects, -+ requiresAuthenticatedWrites: true, -+ resetObjects, -+ usesDatabasePersistence: true, -+ writeMethods: new Set(["replaceObjects", "resetObjects"]), -+ }; -+} ++PASS ++ ++## Targeted Validation ++ ++- `node --check src/dev-runtime/server/local-api-router.mjs`: PASS ++- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs`: PASS ++- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS ++- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS ++- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS, 2 tests ++- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS, 2 tests ++ ++## Repository Guardrails ++ ++- `git diff --check`: PASS ++- `npm run validate:canonical-structure`: PASS ++ ++## Notes ++ ++The targeted tests use a server-side Postgres client fixture to exercise the Local API route without opening a real database connection. Default runtime behavior remains unchanged because the injected client defaults to `null`. + - function tagLabel(tag) { - return tag?.label || tag?.name || ""; - } -diff --git a/toolbox/game-journey/index.html b/toolbox/game-journey/index.html -index 2965e2b19..ad48c27f3 100644 ---- a/toolbox/game-journey/index.html -+++ b/toolbox/game-journey/index.html -@@ -161,6 +161,27 @@ -
    -
    -
    -+
    -+ Objects Stage Readiness -+
    -+
    Objects readiness is waiting for API data.
    -+
    -+ -+ -+ -+ -+ -+ -+ -+ -+
    CriterionStatus
    -+
    -+
    -+

    Product Owner Review Checklist

    -+
      -+
      -+
      -+
      -
      - Suggested Tools -
      -diff --git a/toolbox/objects/index.html b/toolbox/objects/index.html -index 117409b70..ab076292b 100644 ---- a/toolbox/objects/index.html -+++ b/toolbox/objects/index.html -@@ -122,6 +122,59 @@ -

      -
      -
      -+
      -+ Object Details -+
      -+

      Add or select an object to review details.

      -+ -+
      -+
      -+
      -+ Asset Links -+
      -+

      Review sprite, audio, and message links for the selected object.

      -+
        -+
        -+
        -
        - Validation -
        diff --git a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs index 39ba2d5c7..f3411521d 100644 --- a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs +++ b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs @@ -11,11 +11,19 @@ import { } from "../../../src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs"; const syncEnv = Object.freeze({ + GAMEFOUNDRY_DATABASE_SSL: "disable", + GAMEFOUNDRY_DATABASE_URL: "postgres://sync_user:sync_password@dev-sync.example.test/gamefoundry", GAMEFOUNDRY_ENV: "dev", GAMEFOUNDRY_SUPABASE_ANON_KEY: "test-anon-key", GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", GAMEFOUNDRY_SUPABASE_URL: "https://supabase-sync.example.test", }); +const DEV_DB_USER_KEYS = Object.freeze({ + davidq: "01J00000000000000000000014", + user1: "01J00000000000000000000011", + user2: "01J00000000000000000000012", + user3: "01J00000000000000000000013", +}); function decodeEqFilter(searchParams, key) { const value = searchParams.get(key) || ""; @@ -43,10 +51,10 @@ function createFakeSupabaseSyncState() { { key: "role-user", roleSlug: "user", name: "User", isActive: true, isSystemRole: false, ...audit }, ], user_roles: [ - { key: "old-user-role", userKey: SEED_DB_KEYS.users.user1, roleKey: "role-user", ...audit }, - { key: "missing-role-reference", userKey: SEED_DB_KEYS.users.admin, roleKey: "01KV6FVP0ASR2RRR9WXCBKTV6C", ...audit }, + { key: "old-user-role", userKey: DEV_DB_USER_KEYS.user1, roleKey: "role-user", ...audit }, + { key: "missing-role-reference", userKey: DEV_DB_USER_KEYS.davidq, roleKey: "01KV6FVP0ASR2RRR9WXCBKTV6C", ...audit }, { key: "extra-user-role", userKey: "user-extra-codex", roleKey: "role-creator", ...audit }, - { key: "davidq-admin-role", userKey: SEED_DB_KEYS.users.admin, roleKey: "role-admin", ...audit }, + { key: "davidq-admin-role", userKey: DEV_DB_USER_KEYS.davidq, roleKey: "role-admin", ...audit }, ], users: [ { @@ -55,7 +63,34 @@ function createFakeSupabaseSyncState() { displayName: "User 1", email: "user1@example.invalid", isActive: true, - key: SEED_DB_KEYS.users.user1, + key: DEV_DB_USER_KEYS.user1, + ...audit, + }, + { + authProvider: "dev-static-seed", + authProviderUserId: "user-2", + displayName: "User 2", + email: "user2@example.invalid", + isActive: true, + key: DEV_DB_USER_KEYS.user2, + ...audit, + }, + { + authProvider: "dev-static-seed", + authProviderUserId: "user-3", + displayName: "User 3", + email: "user3@example.invalid", + isActive: true, + key: DEV_DB_USER_KEYS.user3, + ...audit, + }, + { + authProvider: "dev-static-seed", + authProviderUserId: "davidq", + displayName: "DavidQ", + email: "qbytes.dq@gmail.com", + isActive: true, + key: DEV_DB_USER_KEYS.davidq, ...audit, }, { @@ -72,6 +107,65 @@ function createFakeSupabaseSyncState() { const calls = []; let generatedKey = 1; + function mutateTableRequest(tableName, { body = {}, method = "GET", query = "select=*" } = {}) { + calls.push({ + body, + method, + path: `/rest/v1/${tableName}?${query}`, + }); + + state[tableName] = state[tableName] || []; + if (method === "POST") { + const rows = Array.isArray(body) ? body : [body]; + rows.forEach((row) => { + const rowWithKey = { + ...row, + key: row.key || `generated-${generatedKey++}`, + }; + const index = state[tableName].findIndex((existing) => existing.key === rowWithKey.key); + if (index === -1) { + state[tableName].push(rowWithKey); + } else { + state[tableName][index] = { + ...state[tableName][index], + ...rowWithKey, + }; + } + }); + return rows; + } + if (method === "DELETE") { + const searchParams = new URLSearchParams(query); + const filterField = searchParams.has("key") + ? "key" + : searchParams.has("roleKey") + ? "roleKey" + : tableName === "users" + ? "key" + : "userKey"; + const filterValue = decodeEqFilter(searchParams, filterField); + const deleted = state[tableName].filter((row) => row[filterField] === filterValue); + state[tableName] = state[tableName].filter((row) => row[filterField] !== filterValue); + return deleted; + } + if (method === "PATCH") { + const searchParams = new URLSearchParams(query); + const filterField = searchParams.has("createdBy") ? "createdBy" : "updatedBy"; + const filterValue = decodeEqFilter(searchParams, filterField); + const changed = []; + state[tableName] = state[tableName].map((row) => { + if (row[filterField] !== filterValue) { + return row; + } + const nextRow = { ...row, ...body }; + changed.push(nextRow); + return nextRow; + }); + return changed; + } + return state[tableName].map((row) => ({ ...row })); + } + async function fetchImpl(url, options = {}) { const requestUrl = new URL(url); const method = options.method || "GET"; @@ -130,57 +224,6 @@ function createFakeSupabaseSyncState() { }; } - if (requestUrl.pathname.startsWith("/rest/v1/")) { - const tableName = decodeURIComponent(requestUrl.pathname.split("/").pop() || ""); - state[tableName] = state[tableName] || []; - if (method === "POST") { - const rows = Array.isArray(body) ? body : [body]; - rows.forEach((row) => { - const rowWithKey = { - ...row, - key: row.key || `generated-${generatedKey++}`, - }; - const index = state[tableName].findIndex((existing) => existing.key === rowWithKey.key); - if (index === -1) { - state[tableName].push(rowWithKey); - } else { - state[tableName][index] = { - ...state[tableName][index], - ...rowWithKey, - }; - } - }); - return { - json: async () => rows, - ok: true, - status: 200, - }; - } - if (method === "DELETE") { - const filterField = requestUrl.searchParams.has("key") ? "key" : tableName === "users" ? "key" : "userKey"; - const filterValue = decodeEqFilter(requestUrl.searchParams, filterField); - const deleted = state[tableName].filter((row) => row[filterField] === filterValue); - state[tableName] = state[tableName].filter((row) => row[filterField] !== filterValue); - return { - json: async () => deleted, - ok: true, - status: 200, - }; - } - if (method === "PATCH") { - return { - json: async () => [], - ok: true, - status: 200, - }; - } - return { - json: async () => state[tableName].map((row) => ({ ...row })), - ok: true, - status: 200, - }; - } - return { json: async () => ({ message: "Unhandled fake route" }), ok: false, @@ -188,7 +231,14 @@ function createFakeSupabaseSyncState() { }; } - return { calls, fetchImpl, state }; + return { + calls, + fetchImpl, + postgresClient: { + requestTable: async (tableName, options = {}) => mutateTableRequest(tableName, options), + }, + state, + }; } test("Supabase DEV creator identity sync upserts canonical users and deletes extra managed accounts", async () => { @@ -201,8 +251,8 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext }), databaseProvider: new SupabasePostgresProviderAdapter({ env: syncEnv, - fetchImpl: fake.fetchImpl, keyFactory: () => `provider-generated-key-${providerGeneratedKey++}`, + postgresClient: fake.postgresClient, }), env: syncEnv, }); @@ -239,6 +289,11 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext assert.deepEqual(fake.state.authUsers.map((user) => user.email).sort(), canonicalEmails); assert.deepEqual(fake.state.users.map((user) => user.email).sort(), canonicalEmails); assert.equal(fake.state.users.find((user) => user.email === "qbytes.dq@gmail.com").displayName, "DavidQ"); + assert.equal(fake.state.users.find((user) => user.email === "qbytes.dq@gmail.com").key, DEV_DB_USER_KEYS.davidq); + assert.equal(fake.state.users.find((user) => user.email === "user1@example.invalid").key, DEV_DB_USER_KEYS.user1); + assert.equal(fake.state.users.find((user) => user.email === "user2@example.invalid").key, DEV_DB_USER_KEYS.user2); + assert.equal(fake.state.users.find((user) => user.email === "user3@example.invalid").key, DEV_DB_USER_KEYS.user3); + assert.equal(fake.state.users.some((user) => Object.values(SEED_DB_KEYS.users).includes(user.key)), false); assert.equal(fake.state.users.every((user) => user.authProvider === "supabase-auth"), true); assert.equal(fake.state.roles.some((role) => role.roleSlug === "user"), false); assert.equal(fake.state.roles.find((role) => role.roleSlug === "owner").isActive, true); @@ -247,3 +302,22 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext assert.equal(fake.state.user_roles.some((row) => row.userKey === davidq.key && row.roleKey === ownerRole.key), true); assert.equal(fake.calls.some((call) => call.path === "/auth/v1/admin/users?page=1&per_page=100" && call.method === "GET"), true); }); + +test("Supabase DEV creator identity sync requires existing database users rows by email", async () => { + const fake = createFakeSupabaseSyncState(); + fake.state.users = fake.state.users.filter((user) => user.email !== "qbytes.dq@gmail.com"); + await assert.rejects( + () => syncSupabaseDevCreatorIdentities({ + authProvider: new SupabaseAuthProviderAdapter({ + env: syncEnv, + fetchImpl: fake.fetchImpl, + }), + databaseProvider: new SupabasePostgresProviderAdapter({ + env: syncEnv, + postgresClient: fake.postgresClient, + }), + env: syncEnv, + }), + /requires existing database users rows for: qbytes\.dq@gmail\.com/, + ); +}); diff --git a/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs b/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs index 3f5d81a00..b86a95014 100644 --- a/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs +++ b/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs @@ -38,8 +38,8 @@ function withEnv(nextEnv, callback) { }); } -function startApiServer() { - const handleRequest = createLocalApiRouter(); +function startApiServer(routerOptions = {}) { + const handleRequest = createLocalApiRouter(routerOptions); const server = http.createServer((request, response) => { const address = server.address(); const port = address && typeof address !== "string" ? address.port : 0; @@ -313,6 +313,12 @@ function fakeSupabaseIdentityTables(overrides = {}) { }; } +function fakePostgresIdentityClient(identityTables = {}) { + return { + requestTable: async (tableName) => (identityTables[tableName] || []).map((row) => ({ ...row })), + }; +} + test("Supabase provider contract does not require provider selector variables", () => { const snapshot = createProviderContractSnapshot({}); assert.equal(snapshot.activeProviders.authProviderId, "supabase-auth"); @@ -881,6 +887,134 @@ test("Create account provisions Supabase identity user default role and user_rol await fakeSupabase.close(); }); +test("Supabase sign-in resolves the session from database users.key matched by Auth id", async () => { + const databaseOwnedUserKey = "01J00000000000000000000001"; + const timestamp = "2026-06-15T00:00:00.000Z"; + const audit = { + createdAt: timestamp, + createdBy: SEED_DB_KEYS.users.admin, + updatedAt: timestamp, + updatedBy: SEED_DB_KEYS.users.admin, + }; + const identityTables = fakeSupabaseIdentityTables({ + user_roles: [ + { + key: SEED_DB_KEYS.userRoles.user1User, + userKey: databaseOwnedUserKey, + roleKey: SEED_DB_KEYS.roles.creator, + ...audit, + }, + ], + users: [ + { + key: databaseOwnedUserKey, + displayName: "Database Creator", + email: "user1@example.invalid", + authProvider: "supabase-auth", + authProviderUserId: "supabase-user-1", + isActive: true, + ...audit, + }, + ], + }); + const fakeSupabase = await startFakeSupabaseAuthServer({ identityTables }); + await withEnv({ + GAMEFOUNDRY_DATABASE_SSL: "disable", + GAMEFOUNDRY_DATABASE_URL: "postgres://contract_user:contract_password@supabase-provider.example.test/gamefoundry", + GAMEFOUNDRY_AUTH_PROVIDER: "supabase-auth", + GAMEFOUNDRY_DB_PROVIDER: "supabase-postgres", + GAMEFOUNDRY_SUPABASE_ANON_KEY: "test-anon-key", + GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + GAMEFOUNDRY_SUPABASE_URL: fakeSupabase.baseUrl, + }, async () => { + const server = await startApiServer({ + supabasePostgresClient: fakePostgresIdentityClient(identityTables), + }); + try { + const signIn = await postApiPayload(server.baseUrl, "/api/auth/sign-in", { + email: "user1@example.invalid", + password: "not-stored-locally", + }); + assert.equal(signIn.status, 200); + assert.equal(signIn.payload.data.sessionResolved, true); + assert.equal(signIn.payload.data.userKey, databaseOwnedUserKey); + assert.equal(Object.values(SEED_DB_KEYS.users).includes(signIn.payload.data.userKey), false); + + const current = await apiJson(server.baseUrl, "/api/session/current"); + assert.equal(current.authenticated, true); + assert.equal(current.userKey, databaseOwnedUserKey); + assert.equal(current.displayName, "Database Creator"); + } finally { + await server.close(); + } + }); + await fakeSupabase.close(); +}); + +test("Supabase sign-in does not resolve a session from email when authProviderUserId is stale", async () => { + const databaseOwnedUserKey = "01J00000000000000000000002"; + const timestamp = "2026-06-15T00:00:00.000Z"; + const audit = { + createdAt: timestamp, + createdBy: SEED_DB_KEYS.users.admin, + updatedAt: timestamp, + updatedBy: SEED_DB_KEYS.users.admin, + }; + const identityTables = fakeSupabaseIdentityTables({ + user_roles: [ + { + key: SEED_DB_KEYS.userRoles.user1User, + userKey: databaseOwnedUserKey, + roleKey: SEED_DB_KEYS.roles.creator, + ...audit, + }, + ], + users: [ + { + key: databaseOwnedUserKey, + displayName: "Database Creator", + email: "user1@example.invalid", + authProvider: "supabase-auth", + authProviderUserId: "stale-supabase-user-1", + isActive: true, + ...audit, + }, + ], + }); + const fakeSupabase = await startFakeSupabaseAuthServer({ identityTables }); + await withEnv({ + GAMEFOUNDRY_DATABASE_SSL: "disable", + GAMEFOUNDRY_DATABASE_URL: "postgres://contract_user:contract_password@supabase-provider.example.test/gamefoundry", + GAMEFOUNDRY_AUTH_PROVIDER: "supabase-auth", + GAMEFOUNDRY_DB_PROVIDER: "supabase-postgres", + GAMEFOUNDRY_SUPABASE_ANON_KEY: "test-anon-key", + GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + GAMEFOUNDRY_SUPABASE_URL: fakeSupabase.baseUrl, + }, async () => { + const server = await startApiServer({ + supabasePostgresClient: fakePostgresIdentityClient(identityTables), + }); + try { + const signIn = await postApiPayload(server.baseUrl, "/api/auth/sign-in", { + email: "user1@example.invalid", + password: "not-stored-locally", + }); + assert.equal(signIn.status, 503); + assert.equal(signIn.payload.ok, false); + assert.equal(signIn.payload.error, "Account identity setup is incomplete. Please contact support."); + assert.equal(JSON.stringify(signIn.payload).includes("stale-supabase-user-1"), false); + assert.equal(JSON.stringify(signIn.payload).includes(databaseOwnedUserKey), false); + + const current = await apiJson(server.baseUrl, "/api/session/current"); + assert.equal(current.authenticated, false); + assert.equal(current.userKey, null); + } finally { + await server.close(); + } + }); + await fakeSupabase.close(); +}); + test("Create account provider failure returns generic browser error and logs safe operator diagnostics", async () => { const fakeSupabase = await startFakeSupabaseAuthServer({ failAdminCreateAccount: { diff --git a/src/dev-runtime/server/local-api-router.mjs b/src/dev-runtime/server/local-api-router.mjs index bfe82e4e8..ae2b7bf72 100644 --- a/src/dev-runtime/server/local-api-router.mjs +++ b/src/dev-runtime/server/local-api-router.mjs @@ -3785,9 +3785,11 @@ class ApiRuntimeDataSource { messagesPostgresClient = null, messagesService = null, repoRoot = process.cwd(), + supabasePostgresClient = null, } = {}) { this.messagesService = messagesService || createMessagesPostgresService({ postgresClient: messagesPostgresClient }); this.gameJourneyCompletionMetricsPostgresClient = gameJourneyCompletionMetricsPostgresClient; + this.supabasePostgresClient = supabasePostgresClient; this.repositoryCounter = 1; this.repositoryById = new Map(); this.sessionModeId = FIXED_ACCOUNT_SESSION_MODE.id; @@ -3824,7 +3826,7 @@ class ApiRuntimeDataSource { supabaseDatabaseAdapter(action) { this.assertSupabaseDatabaseProvider(action); - const adapter = new SupabasePostgresProviderAdapter(); + const adapter = new SupabasePostgresProviderAdapter({ postgresClient: this.supabasePostgresClient }); adapter.connect(); return adapter; } @@ -3835,8 +3837,7 @@ class ApiRuntimeDataSource { } async readSupabaseIdentityTablesUnchecked(action) { - this.assertSupabaseDatabaseProvider(action); - const adapter = new SupabasePostgresProviderAdapter(); + const adapter = this.supabaseDatabaseAdapter(action); const [users, roles, userRoles] = await Promise.all([ adapter.getUsers(), adapter.getRoles(), @@ -6202,15 +6203,20 @@ SELECT pg_database_size(current_database()) AS database_size_bytes, throw authUnavailableError(action, "Supabase Auth did not return an account identity to resolve."); } const tables = await this.readSupabaseIdentityTables(`${action} session resolution`); - const matchingUser = (tables.users || []).find((user) => { + const activeSupabaseUsers = (tables.users || []).filter((user) => { if (user.isActive === false || user.authProvider !== SUPABASE_AUTH_PROVIDER_ID) { return false; } - return (authUserId && String(user.authProviderUserId || "") === authUserId) || - (authEmail && normalizedAuthEmail(user.email).toLowerCase() === authEmail.toLowerCase()); + return true; }); + const matchingUser = activeSupabaseUsers.find((user) => authUserId && String(user.authProviderUserId || "") === authUserId); if (!matchingUser) { - throw authIdentitySetupError(action, "Account authentication succeeded, but no matching users record exists for this account."); + const matchingEmailUser = activeSupabaseUsers.find((user) => + authEmail && normalizedAuthEmail(user.email).toLowerCase() === authEmail.toLowerCase()); + const diagnostic = matchingEmailUser + ? "Account authentication succeeded, but the users record is not linked to this account identity. Run the approved DEV identity sync before signing in." + : "Account authentication succeeded, but no matching users record exists for this account."; + throw authIdentitySetupError(action, diagnostic); } const session = sessionUserFromIdentityTables(tables, matchingUser.key, this.sessionModeId, "Supabase identity"); if (!session.authenticated) { @@ -6230,7 +6236,7 @@ SELECT pg_database_size(current_database()) AS database_size_bytes, throw authIdentitySetupError(action, "Supabase Auth did not return the user id and email required for identity provisioning."); } - const adapter = new SupabasePostgresProviderAdapter(); + const adapter = this.supabaseDatabaseAdapter(`${action} identity provisioning`); const tables = await this.readSupabaseIdentityTablesUnchecked(`${action} identity provisioning`); const users = tables.users || []; const roles = tables.roles || []; @@ -7415,12 +7421,14 @@ export function createLocalApiRouter({ messagesPostgresClient = null, messagesService = null, repoRoot = process.cwd(), + supabasePostgresClient = null, } = {}) { const dataSource = new ApiRuntimeDataSource({ gameJourneyCompletionMetricsPostgresClient, messagesPostgresClient, messagesService, repoRoot, + supabasePostgresClient, }); async function handleApiRuntimeRequest(request, response, requestUrl) { diff --git a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs index 6f214f7a2..0eeb0cd0b 100644 --- a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs +++ b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs @@ -1,4 +1,3 @@ -import { SEED_DB_KEYS } from "../seed/seed-db-keys.mjs"; import { SUPABASE_AUTH_PROVIDER_ID, SupabaseAuthProviderAdapter, @@ -10,31 +9,27 @@ export const DEV_CREATOR_IDENTITIES = Object.freeze([ Object.freeze({ displayName: "User 1", email: "user1@example.invalid", - key: SEED_DB_KEYS.users.user1, passwordSuffix: "1!!", }), Object.freeze({ displayName: "User 2", email: "user2@example.invalid", - key: SEED_DB_KEYS.users.user2, passwordSuffix: "2!!", }), Object.freeze({ displayName: "User 3", email: "user3@example.invalid", - key: SEED_DB_KEYS.users.user3, passwordSuffix: "3!!", }), Object.freeze({ displayName: "DavidQ", email: "qbytes.dq@gmail.com", - key: SEED_DB_KEYS.users.admin, passwordSuffix: "$2026", }), ]); const CANONICAL_EMAILS = new Set(DEV_CREATOR_IDENTITIES.map((identity) => identity.email.toLowerCase())); -const CANONICAL_KEYS = new Set(DEV_CREATOR_IDENTITIES.map((identity) => identity.key)); +const DAVIDQ_DEV_EMAIL = "qbytes.dq@gmail.com"; const DEV_PASSWORD_PREFIX = ["GF", "S"].join(""); const LEGACY_DEV_AUTH_USER_IDS = new Set(["user-1", "user-2", "user-3", "davidq", "davidq-admin"]); const KNOWN_STALE_ROLE_KEYS = Object.freeze(["01KV6FVP0ASR2RRR9WXCBKTV6C"]); @@ -125,8 +120,7 @@ export function isManagedExtraDevIdentityEmail(email) { function isManagedExtraPublicUser(user = {}) { const email = normalizedEmail(user.email); - const key = normalizedId(user.key); - if (CANONICAL_KEYS.has(key) || isCanonicalDevIdentityEmail(email)) { + if (isCanonicalDevIdentityEmail(email)) { return false; } if (isManagedExtraDevIdentityEmail(email)) { @@ -191,25 +185,55 @@ function roleBySlug(roles, roleSlug) { return roles.find((role) => normalizedId(role.roleSlug) === roleSlug && role.isActive !== false) || null; } -function canonicalDesiredUserRolePairs(roles) { +function canonicalUserKeys(canonicalUsers = []) { + return new Set(canonicalUsers.map((user) => normalizedId(user.key)).filter(Boolean)); +} + +function davidqUserFromCanonicalUsers(canonicalUsers = []) { + return canonicalUsers.find((user) => normalizedEmail(user.email) === DAVIDQ_DEV_EMAIL) || null; +} + +function canonicalUserRoleRequests(canonicalUsers = []) { + const davidq = davidqUserFromCanonicalUsers(canonicalUsers); + return [ + ...canonicalUsers + .filter((user) => normalizedId(user.key)) + .map((user) => ({ + roleSlug: "creator", + userKey: normalizedId(user.key), + })), + davidq ? { + roleSlug: "admin", + userKey: normalizedId(davidq.key), + } : null, + davidq ? { + roleSlug: "owner", + userKey: normalizedId(davidq.key), + } : null, + ].filter(Boolean); +} + +function canonicalDesiredUserRolePairs(roles, canonicalUsers = []) { const creatorRole = roleBySlug(roles, "creator"); const adminRole = roleBySlug(roles, "admin"); const ownerRole = roleBySlug(roles, "owner"); + const davidq = davidqUserFromCanonicalUsers(canonicalUsers); return new Set([ - ...DEV_CREATOR_IDENTITIES + ...canonicalUsers .filter(() => creatorRole) - .map((identity) => `${identity.key}\u0000${creatorRole.key}`), - adminRole ? `${SEED_DB_KEYS.users.admin}\u0000${adminRole.key}` : "", - ownerRole ? `${SEED_DB_KEYS.users.admin}\u0000${ownerRole.key}` : "", + .map((user) => `${normalizedId(user.key)}\u0000${creatorRole.key}`), + adminRole && davidq ? `${normalizedId(davidq.key)}\u0000${adminRole.key}` : "", + ownerRole && davidq ? `${normalizedId(davidq.key)}\u0000${ownerRole.key}` : "", ].filter(Boolean)); } -function staleCanonicalUserRoleRecords({ roles, userRoles }) { +function staleCanonicalUserRoleRecords({ canonicalUsers, roles, userRoles }) { const validRoleKeys = new Set(roles.map((role) => normalizedId(role.key)).filter(Boolean)); - const desiredPairs = canonicalDesiredUserRolePairs(roles); + const desiredPairs = canonicalDesiredUserRolePairs(roles, canonicalUsers); + const canonicalKeys = canonicalUserKeys(canonicalUsers); const staleRoleKeys = new Set(KNOWN_STALE_ROLE_KEYS); return userRoles - .filter((row) => CANONICAL_KEYS.has(normalizedId(row.userKey))) + .filter((row) => canonicalKeys.has(normalizedId(row.userKey))) .filter((row) => { const roleKey = normalizedId(row.roleKey); return staleRoleKeys.has(roleKey) || @@ -229,8 +253,8 @@ function staleCanonicalUserRoleRecords({ roles, userRoles }) { .filter((row) => row.key); } -async function repairCanonicalUserRoles({ databaseProvider, dryRun, roles, userRoles }) { - const staleRecords = staleCanonicalUserRoleRecords({ roles, userRoles }); +async function repairCanonicalUserRoles({ canonicalUsers, databaseProvider, dryRun, roles, userRoles }) { + const staleRecords = staleCanonicalUserRoleRecords({ canonicalUsers, roles, userRoles }); const deletedRecords = []; for (const record of staleRecords) { if (dryRun) { @@ -352,20 +376,57 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu }; } -function canonicalUserRows(authUsers) { +function canonicalPublicUsersByEmail(publicUsers = []) { + const byEmail = new Map(); + const duplicates = []; + publicUsers.forEach((user) => { + const email = normalizedEmail(user.email); + if (!isCanonicalDevIdentityEmail(email)) { + return; + } + if (byEmail.has(email)) { + duplicates.push(email); + return; + } + byEmail.set(email, user); + }); + if (duplicates.length) { + throw new Error(`Supabase DEV identity sync found duplicate database users rows for: ${Array.from(new Set(duplicates)).join(", ")}.`); + } + const missing = DEV_CREATOR_IDENTITIES + .map((identity) => identity.email) + .filter((email) => !byEmail.has(normalizedEmail(email))); + if (missing.length) { + throw new Error(`Supabase DEV identity sync requires existing database users rows for: ${missing.join(", ")}.`); + } + return byEmail; +} + +function canonicalUsersFromPublicRows(publicUsersByEmail) { + return DEV_CREATOR_IDENTITIES.map((identity) => publicUsersByEmail.get(normalizedEmail(identity.email))); +} + +function canonicalUserRows(authUsers, publicUsersByEmail) { const byEmail = authUsersByEmail(authUsers); return DEV_CREATOR_IDENTITIES.map((identity) => { - const authId = authUserId(byEmail.get(identity.email.toLowerCase())); + const email = normalizedEmail(identity.email); + const publicUser = publicUsersByEmail.get(email); + const authId = authUserId(byEmail.get(email)); + const userKey = normalizedId(publicUser?.key); + if (!userKey) { + throw new Error(`Supabase DEV identity sync could not resolve database users.key for ${identity.email}.`); + } if (!authId) { throw new Error(`Supabase DEV identity sync could not resolve Auth user id for ${identity.email}.`); } return { + ...publicUser, authProvider: SUPABASE_AUTH_PROVIDER_ID, authProviderUserId: authId, - displayName: identity.displayName, - email: identity.email, - isActive: true, - key: identity.key, + displayName: publicUser.displayName || identity.displayName, + email: publicUser.email || identity.email, + isActive: publicUser.isActive !== false, + key: userKey, }; }); } @@ -479,7 +540,6 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU .filter((role) => String(role.roleSlug || "") === "user") .map((role) => normalizedId(role.key)) .filter(Boolean)); - const desiredPairs = canonicalDesiredUserRolePairs(afterRoles); const identityEvidence = DEV_CREATOR_IDENTITIES.map((identity) => { const auth = authByEmail.get(identity.email.toLowerCase()); const appUser = publicByEmail.get(identity.email.toLowerCase()); @@ -496,6 +556,11 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU appUser.authProviderUserId === authUserId(auth), }; }); + const canonicalUsers = identityEvidence + .map((record) => publicByEmail.get(normalizedEmail(record.email))) + .filter(Boolean); + const canonicalKeys = canonicalUserKeys(canonicalUsers); + const desiredPairs = canonicalDesiredUserRolePairs(afterRoles, canonicalUsers); const creatorAssignments = DEV_CREATOR_IDENTITIES.map((identity) => { const appUser = publicByEmail.get(identity.email.toLowerCase()); return { @@ -504,12 +569,12 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU roleSlug: "creator", }; }); - const davidq = publicByEmail.get("qbytes.dq@gmail.com"); + const davidq = publicByEmail.get(DAVIDQ_DEV_EMAIL); const davidqAdminAssignment = Boolean(davidq && adminRole && userRolePairs.has(`${davidq.key}\u0000${adminRole.key}`)); const davidqOwnerAssignment = Boolean(davidq && ownerRole && userRolePairs.has(`${davidq.key}\u0000${ownerRole.key}`)); const roleEvidence = { davidqAdmin: davidqAdminAssignment, - davidqCreator: creatorAssignments.find((record) => record.email === "qbytes.dq@gmail.com")?.assigned === true, + davidqCreator: creatorAssignments.find((record) => record.email === DAVIDQ_DEV_EMAIL)?.assigned === true, davidqOwner: davidqOwnerAssignment, user1Creator: creatorAssignments.find((record) => record.email === "user1@example.invalid")?.assigned === true, user2Creator: creatorAssignments.find((record) => record.email === "user2@example.invalid")?.assigned === true, @@ -539,7 +604,7 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU userRoleKey: normalizedId(row.key), })); const unexpectedCanonicalAssignments = afterUserRoles - .filter((row) => CANONICAL_KEYS.has(normalizedId(row.userKey))) + .filter((row) => canonicalKeys.has(normalizedId(row.userKey))) .filter((row) => !desiredPairs.has(`${normalizedId(row.userKey)}\u0000${normalizedId(row.roleKey)}`)) .map((row) => ({ roleKey: normalizedId(row.roleKey), @@ -621,6 +686,12 @@ export async function syncSupabaseDevCreatorIdentities({ assertDevOnlyAuthTestCleanupEnvironment(env); const beforeAuthUsers = await authProvider.listAllAdminUsers(); const beforePublicUsers = await databaseProvider.getUsers(); + const publicUsersByEmail = canonicalPublicUsersByEmail(beforePublicUsers); + const canonicalPublicUsers = canonicalUsersFromPublicRows(publicUsersByEmail); + const davidqPublicUser = davidqUserFromCanonicalUsers(canonicalPublicUsers); + if (!davidqPublicUser) { + throw new Error(`Supabase DEV identity sync requires existing database users row for ${DAVIDQ_DEV_EMAIL}.`); + } const beforeCounts = identityCounts({ authUsers: beforeAuthUsers, publicUsers: beforePublicUsers }); const existingByEmail = authUsersByEmail(beforeAuthUsers); @@ -630,13 +701,15 @@ export async function syncSupabaseDevCreatorIdentities({ } const authUsersForIdentity = dryRun ? beforeAuthUsers : await authProvider.listAllAdminUsers(); + const canonicalUsers = canonicalUserRows(authUsersForIdentity, publicUsersByEmail); + const userRoleRequests = canonicalUserRoleRequests(canonicalUsers); const initialized = dryRun ? { dryRun: true, initialized: { roles: DEV_ROLE_DEFINITIONS.length, - user_roles: DEV_CREATOR_IDENTITIES.length + 2, - users: DEV_CREATOR_IDENTITIES.length, + user_roles: userRoleRequests.length, + users: canonicalUsers.length, }, written: { roles: 0, @@ -645,29 +718,17 @@ export async function syncSupabaseDevCreatorIdentities({ }, } : await databaseProvider.initializeIdentity({ - actorKey: SEED_DB_KEYS.users.admin, + actorKey: davidqPublicUser.key, roles: DEV_ROLE_DEFINITIONS, - userRoles: [ - ...DEV_CREATOR_IDENTITIES.map((identity) => ({ - roleSlug: "creator", - userKey: identity.key, - })), - { - roleSlug: "admin", - userKey: SEED_DB_KEYS.users.admin, - }, - { - roleSlug: "owner", - userKey: SEED_DB_KEYS.users.admin, - }, - ], - users: canonicalUserRows(authUsersForIdentity), + userRoles: userRoleRequests, + users: canonicalUsers, }); const usersForCleanup = await databaseProvider.getUsers(); const rolesForCleanup = await databaseProvider.getRoles(); const userRolesForCleanup = await databaseProvider.getUserRoles(); const userRoleRepair = await repairCanonicalUserRoles({ + canonicalUsers, databaseProvider, dryRun, roles: rolesForCleanup, @@ -685,7 +746,7 @@ export async function syncSupabaseDevCreatorIdentities({ const userRolesForPublicCleanup = dryRun ? userRolesForCleanup : await databaseProvider.getUserRoles(); const publicCleanupCandidates = usersForCleanup.filter(isManagedExtraPublicUser); const publicCleanup = await deleteManagedPublicUsers({ - auditUserKey: SEED_DB_KEYS.users.admin, + auditUserKey: davidqPublicUser.key, authProvider, candidates: publicCleanupCandidates, databaseProvider, From cd6c4e2aa496f30734b9683e4f5d1666e7e320a1 Mon Sep 17 00:00:00 2001 From: Alfa Team Date: Sun, 28 Jun 2026 11:45:52 -0400 Subject: [PATCH 2/3] Verify DEV auth identity link --- ...user-key-db-authority_branch_validation.md | 11 + ...ey-db-authority_manual_validation_notes.md | 14 +- ...5-dev-auth-user-key-db-authority_report.md | 34 +- ...-key-db-authority_requirement_checklist.md | 9 +- ...key-db-authority_validation_lane_report.md | 5 +- ...user-key-db-authority_validation_report.md | 14 +- dev/reports/codex_changed_files.txt | 68 +- dev/reports/codex_review.diff | 1180 +++++++++++++---- .../sync-supabase-dev-creator-identities.mjs | 5 +- ...upabaseDevCreatorIdentitySeedSync.test.mjs | 4 + dev/tests/dev-runtime/TagsApiService.test.mjs | 1 + ...upabase-dev-creator-identity-seed-sync.mjs | 12 +- .../toolbox-api/alfa-tool-services.mjs | 31 - 13 files changed, 1091 insertions(+), 297 deletions(-) diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md index c807ea892..50c821aaa 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md @@ -16,8 +16,19 @@ PASS - PASS `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` - PASS `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` - PASS `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +- PASS `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` +- PASS `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs` +- PASS `node --check dev/tests/dev-runtime/TagsApiService.test.mjs` - PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` - PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +- PASS `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` - PASS `git diff --check` - PASS `npm run validate:canonical-structure` +## DEV Identity Verification + +- PASS `qbytes.dq@gmail.com` exists in Supabase Auth. +- PASS `qbytes.dq@gmail.com` exists in product `users`. +- PASS `users.authProviderUserId` matches Supabase Auth id. +- PASS DEV identity sync ran with password updates disabled. +- PASS `/api/auth/sign-in` resolves qbytes to the database `users.key`. diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md index 005ca851f..363e4ccda 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md @@ -1,14 +1,16 @@ # Manual Validation Notes: PR_26179_OWNER_035-dev-auth-user-key-db-authority -Manual browser validation was not run because this PR changes server-side auth/session resolution and DEV identity sync behavior. The targeted API-level tests cover the affected user-visible outcomes: +Manual browser validation was not run because this PR changes server-side auth/session resolution and DEV identity sync behavior. The targeted API-level tests and live API sign-in smoke cover the affected user-visible outcomes: - A valid Supabase Auth id linked to `users.authProviderUserId` signs in and returns the database-owned `users.key`. - A matching email with stale `users.authProviderUserId` does not sign in and returns the existing Creator-safe identity setup message. - The browser response does not expose raw Auth ids or database user keys on the failure path. -Recommended manual follow-up in DEV after merge: - -- Run the approved DEV identity sync. -- Sign in as `qbytes.dq@gmail.com`. -- Confirm the session resolves to the existing database `users.key` row whose `authProviderUserId` equals the Supabase Auth user id. +Live DEV notes: +- Initial audit found Supabase Auth present but product `users` missing for `qbytes.dq@gmail.com`. +- The missing database row was caused by a toolbox helper upserting seed users over the reserved account keys. +- The helper was fixed so toolbox seeding no longer upserts `users`. +- The approved DEV account DML restored the missing rows. +- DEV identity sync ran with password updates disabled. +- Final `/api/auth/sign-in` smoke for `qbytes.dq@gmail.com` passed and resolved to the database `users.key`. diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md index 94dfcbd73..fb83e9015 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md @@ -33,11 +33,40 @@ Runtime sign-in now resolves a session only when the Supabase Auth user id match - Added a narrow server-side Postgres client injection hook for tests, with default runtime behavior unchanged. - Refactored DEV creator identity sync so canonical identity definitions are email-based and database `users.key` is read from existing rows. - Changed role assignment and cleanup repair logic to derive canonical user keys from database rows. +- Removed the Alfa toolbox tag/design/configuration helper behavior that upserted seed `users` rows over the static DEV account keys. +- Changed the DEV identity sync default so existing Supabase Auth users are updated without sending a password. Password updates now require an explicit `--update-passwords` flag. - Added regression tests proving: - Sign-in uses the database-owned `users.key` matched by `authProviderUserId`. - Sign-in does not create a session from matching email when `authProviderUserId` is stale. - DEV sync preserves non-seed database user keys. - DEV sync fails if an expected database `users` row is missing. + - Tags API seeding does not upsert rows into `users`. + - Existing Auth user sync does not send a password by default. + +## DEV Database Verification + +Performed against the current `.env` DEV database target. + +Initial audit: + +- `qbytes.dq@gmail.com` existed in Supabase Auth. +- `qbytes.dq@gmail.com` was missing from the product `users` table. +- The reserved static DEV account keys for User 1 and DavidQ had been overwritten by seed `Creator` / `Forge Bot` rows. + +Repair and sync: + +- Re-applied the approved DEV account DML to restore the missing database identity rows. +- Ran the DEV identity sync with `updatePasswords: false`. +- qbytes sync action: `updated`. +- qbytes password update: `false`. + +Final audit: + +- Supabase Auth match count: `1`. +- Product `users` match count: `1`. +- `users.authProviderUserId` equals Supabase Auth `auth.users.id`: PASS. +- Sign-in smoke through `/api/auth/sign-in`: PASS. +- Session resolved to the database `users.key`: PASS. ## Files Changed @@ -54,8 +83,11 @@ PASS: - `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` - `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` - `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` +- `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs` +- `node --check dev/tests/dev-runtime/TagsApiService.test.mjs` - `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` - `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +- `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` - `git diff --check` - `npm run validate:canonical-structure` - diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md index 180e988c1..0b53b8e3a 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md @@ -11,8 +11,15 @@ - PASS Do not add password tables. - PASS Do not use `displayName` as an identity key. - PASS Do not hardcode DavidQ/user keys in runtime login resolution. +- PASS Verify qbytes exists in Supabase Auth. +- PASS Verify qbytes exists in the product `users` table. +- PASS Verify `users.authProviderUserId` equals Supabase Auth id. +- PASS If the database row/link is stale, run DEV identity sync. +- PASS Do not change password during existing Auth user sync. +- PASS Do not recreate qbytes Auth user because it already exists. +- PASS Do not use email fallback for session resolution. +- PASS Re-test sign-in after sync. - PASS Report where `users.key` currently comes from. - PASS Report where `authProviderUserId` is populated. - PASS Report whether seed constants are setup-only or runtime identity authority. - PASS Report exact fix. - diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md index 634164f94..d3aa670c1 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md @@ -8,11 +8,14 @@ Targeted auth and DEV identity sync validation. - PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` - PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +- PASS `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` +- PASS live DEV identity audit and sign-in smoke for `qbytes.dq@gmail.com` ## Coverage - DEV identity sync reads existing database `users.key` values by email. - DEV identity sync writes real Supabase Auth ids to `users.authProviderUserId`. +- Existing Auth user sync does not update passwords by default. - Session resolution accepts only `auth.users.id` matched to `users.authProviderUserId`. - Email-only session resolution is rejected with the existing Creator-safe setup message. - +- Tags API setup no longer writes seed rows to the account `users` table. diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md index 5d5f107cc..bc901b4c1 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md @@ -10,8 +10,21 @@ PASS - `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs`: PASS - `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS - `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS +- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs`: PASS +- `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs`: PASS +- `node --check dev/tests/dev-runtime/TagsApiService.test.mjs`: PASS - `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS, 2 tests - `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS, 2 tests +- `node --test dev/tests/dev-runtime/TagsApiService.test.mjs`: PASS, 3 tests + +## Live DEV Validation + +- PASS Initial audit detected Supabase Auth user for `qbytes.dq@gmail.com`. +- PASS Initial audit detected missing/stale product `users` row and halted sign-in retest. +- PASS Approved DEV account DML restored the missing identity rows. +- PASS DEV identity sync completed with `updatePasswords: false`. +- PASS Final audit verified `users.authProviderUserId` equals Supabase Auth id. +- PASS `/api/auth/sign-in` returned HTTP 200 and resolved the session to the database `users.key`. ## Repository Guardrails @@ -21,4 +34,3 @@ PASS ## Notes The targeted tests use a server-side Postgres client fixture to exercise the Local API route without opening a real database connection. Default runtime behavior remains unchanged because the injected client defaults to `null`. - diff --git a/dev/reports/codex_changed_files.txt b/dev/reports/codex_changed_files.txt index a1f9ea8e3..451d447a3 100644 --- a/dev/reports/codex_changed_files.txt +++ b/dev/reports/codex_changed_files.txt @@ -1,26 +1,52 @@ # git status --short -M dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs - M dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs - M src/dev-runtime/server/local-api-router.mjs +M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md + M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md + M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md + M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md + M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md + M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md + M dev/reports/codex_changed_files.txt + M dev/reports/codex_review.diff + M dev/scripts/sync-supabase-dev-creator-identities.mjs + M dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs + M dev/tests/dev-runtime/TagsApiService.test.mjs M src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs -?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md -?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md -?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md -?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md -?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md -?? dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md + M src/dev-runtime/toolbox-api/alfa-tool-services.mjs + +# git diff --name-status main +A dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +A dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +A dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +A dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +A dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +A dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md +M dev/reports/codex_changed_files.txt +M dev/reports/codex_review.diff +M dev/scripts/sync-supabase-dev-creator-identities.mjs +M dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs +M dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs +M dev/tests/dev-runtime/TagsApiService.test.mjs +M src/dev-runtime/server/local-api-router.mjs +M src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs +M src/dev-runtime/toolbox-api/alfa-tool-services.mjs # git ls-files --others --exclude-standard -dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md -dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md -dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md -dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md -dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md -dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md +(no output) -# git diff --stat -.../SupabaseDevCreatorIdentitySeedSync.test.mjs | 188 ++++++++++++++------- - .../SupabaseProviderContractStub.test.mjs | 138 ++++++++++++++- - src/dev-runtime/server/local-api-router.mjs | 24 ++- - .../supabase-dev-creator-identity-seed-sync.mjs | 155 +++++++++++------ - 4 files changed, 391 insertions(+), 114 deletions(-) \ No newline at end of file +# git diff --stat main +...auth-user-key-db-authority_branch_validation.md | 34 ++ + ...ser-key-db-authority_manual_validation_notes.md | 16 + + ...ER_035-dev-auth-user-key-db-authority_report.md | 93 +++++ + ...-user-key-db-authority_requirement_checklist.md | 25 ++ + ...user-key-db-authority_validation_lane_report.md | 21 + + ...auth-user-key-db-authority_validation_report.md | 36 ++ + dev/reports/codex_changed_files.txt | 33 +- + dev/reports/codex_review.diff | 448 ++++++++++++++++----- + .../sync-supabase-dev-creator-identities.mjs | 5 +- + .../SupabaseDevCreatorIdentitySeedSync.test.mjs | 192 ++++++--- + .../SupabaseProviderContractStub.test.mjs | 138 ++++++- + dev/tests/dev-runtime/TagsApiService.test.mjs | 1 + + src/dev-runtime/server/local-api-router.mjs | 24 +- + .../supabase-dev-creator-identity-seed-sync.mjs | 167 +++++--- + src/dev-runtime/toolbox-api/alfa-tool-services.mjs | 31 -- + 15 files changed, 996 insertions(+), 268 deletions(-) diff --git a/dev/reports/codex_review.diff b/dev/reports/codex_review.diff index 979b1be18..91372cfbd 100644 --- a/dev/reports/codex_review.diff +++ b/dev/reports/codex_review.diff @@ -1,10 +1,811 @@ +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +new file mode 100644 +index 000000000..50c821aaa +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +@@ -0,0 +1,34 @@ ++# Branch Validation: PR_26179_OWNER_035-dev-auth-user-key-db-authority ++ ++## Status ++ ++PASS ++ ++## Branch ++ ++- Current branch: `PR_26179_OWNER_035-dev-auth-user-key-db-authority` ++- Source branch: `main` ++- Worktree at report time: modified files only for this PR plus generated report artifacts ++ ++## Validation Commands ++ ++- PASS `node --check src/dev-runtime/server/local-api-router.mjs` ++- PASS `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` ++- PASS `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- PASS `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- PASS `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` ++- PASS `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs` ++- PASS `node --check dev/tests/dev-runtime/TagsApiService.test.mjs` ++- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- PASS `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` ++- PASS `git diff --check` ++- PASS `npm run validate:canonical-structure` ++ ++## DEV Identity Verification ++ ++- PASS `qbytes.dq@gmail.com` exists in Supabase Auth. ++- PASS `qbytes.dq@gmail.com` exists in product `users`. ++- PASS `users.authProviderUserId` matches Supabase Auth id. ++- PASS DEV identity sync ran with password updates disabled. ++- PASS `/api/auth/sign-in` resolves qbytes to the database `users.key`. +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +new file mode 100644 +index 000000000..363e4ccda +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +@@ -0,0 +1,16 @@ ++# Manual Validation Notes: PR_26179_OWNER_035-dev-auth-user-key-db-authority ++ ++Manual browser validation was not run because this PR changes server-side auth/session resolution and DEV identity sync behavior. The targeted API-level tests and live API sign-in smoke cover the affected user-visible outcomes: ++ ++- A valid Supabase Auth id linked to `users.authProviderUserId` signs in and returns the database-owned `users.key`. ++- A matching email with stale `users.authProviderUserId` does not sign in and returns the existing Creator-safe identity setup message. ++- The browser response does not expose raw Auth ids or database user keys on the failure path. ++ ++Live DEV notes: ++ ++- Initial audit found Supabase Auth present but product `users` missing for `qbytes.dq@gmail.com`. ++- The missing database row was caused by a toolbox helper upserting seed users over the reserved account keys. ++- The helper was fixed so toolbox seeding no longer upserts `users`. ++- The approved DEV account DML restored the missing rows. ++- DEV identity sync ran with password updates disabled. ++- Final `/api/auth/sign-in` smoke for `qbytes.dq@gmail.com` passed and resolved to the database `users.key`. +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +new file mode 100644 +index 000000000..fb83e9015 +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +@@ -0,0 +1,93 @@ ++# PR_26179_OWNER_035-dev-auth-user-key-db-authority ++ ++## Summary ++ ++This PR makes DEV account session resolution treat the database `users` row as authoritative. ++ ++Runtime sign-in now resolves a session only when the Supabase Auth user id matches `users.authProviderUserId`. Matching by email alone is no longer accepted for login/session resolution. Email remains valid only for the DEV identity sync step that locates the existing database `users` row before writing the real Supabase Auth id back to `users.authProviderUserId`. ++ ++## Diagnostic Findings ++ ++### Where `users.key` currently comes from ++ ++- DEV seed setup defines static user keys in `src/dev-runtime/seed/seed-db-keys.mjs`. ++- Current database seed DML also contains DEV account rows under `dev/build/database/dml/account.sql`. ++- Before this PR, `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` imported those seed keys and used them as the canonical keys during DEV Supabase identity sync. ++- After this PR, DEV sync locates existing `users` rows by email and reads `users.key` from the database row. The sync no longer imports `SEED_DB_KEYS`. ++ ++### Where `authProviderUserId` is populated ++ ++- Account creation provisioning still writes `authProviderUserId` in `src/dev-runtime/server/local-api-router.mjs` through `provisionSupabaseIdentityForAuthPayload()`. ++- DEV identity sync now reads `auth.users.id` from Supabase Auth, reads `users.key` from the database row found by email, and sends that database-owned key plus the real Auth id through `databaseProvider.initializeIdentity()`. ++ ++### Seed constant use ++ ++- Seed constants remain valid for DEV seed setup, seed fixtures, and existing test/demo data. ++- They are no longer used by the DEV identity sync helper as the authoritative app user key source. ++- Runtime sign-in/session resolution does not hardcode DavidQ or other user keys. It reads identity tables and resolves `matchingUser.key` after matching Supabase Auth id to `users.authProviderUserId`. ++ ++## Exact Fix ++ ++- Removed email fallback from Supabase sign-in session resolution. ++- Added a Creator-safe setup failure when Auth succeeds but the matching email row has not been linked to the Auth id. ++- Added a narrow server-side Postgres client injection hook for tests, with default runtime behavior unchanged. ++- Refactored DEV creator identity sync so canonical identity definitions are email-based and database `users.key` is read from existing rows. ++- Changed role assignment and cleanup repair logic to derive canonical user keys from database rows. ++- Removed the Alfa toolbox tag/design/configuration helper behavior that upserted seed `users` rows over the static DEV account keys. ++- Changed the DEV identity sync default so existing Supabase Auth users are updated without sending a password. Password updates now require an explicit `--update-passwords` flag. ++- Added regression tests proving: ++ - Sign-in uses the database-owned `users.key` matched by `authProviderUserId`. ++ - Sign-in does not create a session from matching email when `authProviderUserId` is stale. ++ - DEV sync preserves non-seed database user keys. ++ - DEV sync fails if an expected database `users` row is missing. ++ - Tags API seeding does not upsert rows into `users`. ++ - Existing Auth user sync does not send a password by default. ++ ++## DEV Database Verification ++ ++Performed against the current `.env` DEV database target. ++ ++Initial audit: ++ ++- `qbytes.dq@gmail.com` existed in Supabase Auth. ++- `qbytes.dq@gmail.com` was missing from the product `users` table. ++- The reserved static DEV account keys for User 1 and DavidQ had been overwritten by seed `Creator` / `Forge Bot` rows. ++ ++Repair and sync: ++ ++- Re-applied the approved DEV account DML to restore the missing database identity rows. ++- Ran the DEV identity sync with `updatePasswords: false`. ++- qbytes sync action: `updated`. ++- qbytes password update: `false`. ++ ++Final audit: ++ ++- Supabase Auth match count: `1`. ++- Product `users` match count: `1`. ++- `users.authProviderUserId` equals Supabase Auth `auth.users.id`: PASS. ++- Sign-in smoke through `/api/auth/sign-in`: PASS. ++- Session resolved to the database `users.key`: PASS. ++ ++## Files Changed ++ ++- `src/dev-runtime/server/local-api-router.mjs` ++- `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` ++- `dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- `dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++ ++## Validation ++ ++PASS: ++ ++- `node --check src/dev-runtime/server/local-api-router.mjs` ++- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` ++- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` ++- `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs` ++- `node --check dev/tests/dev-runtime/TagsApiService.test.mjs` ++- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` ++- `git diff --check` ++- `npm run validate:canonical-structure` +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +new file mode 100644 +index 000000000..0b53b8e3a +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +@@ -0,0 +1,25 @@ ++# Requirement Checklist: PR_26179_OWNER_035-dev-auth-user-key-db-authority ++ ++- PASS Do not use hardcoded user keys as runtime identity source. ++- PASS Use email only to locate the existing DEV `users` row during DEV identity sync. ++- PASS Read `users.key` from the database row during DEV sync. ++- PASS Read `auth.users.id` from Supabase Auth during DEV sync. ++- PASS Write `auth.users.id` into `users.authProviderUserId`. ++- PASS Login/session resolution matches Supabase Auth id to `users.authProviderUserId`. ++- PASS Do not create browser-owned auth. ++- PASS Do not create fake login. ++- PASS Do not add password tables. ++- PASS Do not use `displayName` as an identity key. ++- PASS Do not hardcode DavidQ/user keys in runtime login resolution. ++- PASS Verify qbytes exists in Supabase Auth. ++- PASS Verify qbytes exists in the product `users` table. ++- PASS Verify `users.authProviderUserId` equals Supabase Auth id. ++- PASS If the database row/link is stale, run DEV identity sync. ++- PASS Do not change password during existing Auth user sync. ++- PASS Do not recreate qbytes Auth user because it already exists. ++- PASS Do not use email fallback for session resolution. ++- PASS Re-test sign-in after sync. ++- PASS Report where `users.key` currently comes from. ++- PASS Report where `authProviderUserId` is populated. ++- PASS Report whether seed constants are setup-only or runtime identity authority. ++- PASS Report exact fix. +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +new file mode 100644 +index 000000000..d3aa670c1 +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +@@ -0,0 +1,21 @@ ++# Validation Lane Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority ++ ++## Lane ++ ++Targeted auth and DEV identity sync validation. ++ ++## Commands ++ ++- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++- PASS `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` ++- PASS live DEV identity audit and sign-in smoke for `qbytes.dq@gmail.com` ++ ++## Coverage ++ ++- DEV identity sync reads existing database `users.key` values by email. ++- DEV identity sync writes real Supabase Auth ids to `users.authProviderUserId`. ++- Existing Auth user sync does not update passwords by default. ++- Session resolution accepts only `auth.users.id` matched to `users.authProviderUserId`. ++- Email-only session resolution is rejected with the existing Creator-safe setup message. ++- Tags API setup no longer writes seed rows to the account `users` table. +diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md +new file mode 100644 +index 000000000..bc901b4c1 +--- /dev/null ++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md +@@ -0,0 +1,36 @@ ++# Validation Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority ++ ++## Result ++ ++PASS ++ ++## Targeted Validation ++ ++- `node --check src/dev-runtime/server/local-api-router.mjs`: PASS ++- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs`: PASS ++- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS ++- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS ++- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs`: PASS ++- `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs`: PASS ++- `node --check dev/tests/dev-runtime/TagsApiService.test.mjs`: PASS ++- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS, 2 tests ++- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS, 2 tests ++- `node --test dev/tests/dev-runtime/TagsApiService.test.mjs`: PASS, 3 tests ++ ++## Live DEV Validation ++ ++- PASS Initial audit detected Supabase Auth user for `qbytes.dq@gmail.com`. ++- PASS Initial audit detected missing/stale product `users` row and halted sign-in retest. ++- PASS Approved DEV account DML restored the missing identity rows. ++- PASS DEV identity sync completed with `updatePasswords: false`. ++- PASS Final audit verified `users.authProviderUserId` equals Supabase Auth id. ++- PASS `/api/auth/sign-in` returned HTTP 200 and resolved the session to the database `users.key`. ++ ++## Repository Guardrails ++ ++- `git diff --check`: PASS ++- `npm run validate:canonical-structure`: PASS ++ ++## Notes ++ ++The targeted tests use a server-side Postgres client fixture to exercise the Local API route without opening a real database connection. Default runtime behavior remains unchanged because the injected client defaults to `null`. +diff --git a/dev/reports/codex_changed_files.txt b/dev/reports/codex_changed_files.txt +index e893c3f03..d406f0575 100644 +--- a/dev/reports/codex_changed_files.txt ++++ b/dev/reports/codex_changed_files.txt +@@ -1,16 +1,29 @@ + # git status --short +-A dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-branch-validation.md +-A dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-closeout-report.md +-A dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-manual-validation-notes.md +-A dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-requirement-checklist.md +-A dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-validation-report.md +- M dev/reports/codex_changed_files.txt +- M dev/reports/codex_review.diff ++M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md ++ M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md ++ M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md ++ M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md ++ M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md ++ M dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md ++ M dev/scripts/sync-supabase-dev-creator-identities.mjs ++ M dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs ++ M dev/tests/dev-runtime/TagsApiService.test.mjs ++ M src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs ++ M src/dev-runtime/toolbox-api/alfa-tool-services.mjs + + # git ls-files --others --exclude-standard + (no output) + + # git diff --stat +-dev/reports/codex_changed_files.txt | 55 +- +- dev/reports/codex_review.diff | 2819 +---------------------------------- +- 2 files changed, 88 insertions(+), 2786 deletions(-) +\ No newline at end of file ++...auth-user-key-db-authority_branch_validation.md | 11 +++++++ ++ ...ser-key-db-authority_manual_validation_notes.md | 14 +++++---- ++ ...ER_035-dev-auth-user-key-db-authority_report.md | 34 +++++++++++++++++++++- ++ ...-user-key-db-authority_requirement_checklist.md | 9 +++++- ++ ...user-key-db-authority_validation_lane_report.md | 5 +++- ++ ...auth-user-key-db-authority_validation_report.md | 14 ++++++++- ++ .../sync-supabase-dev-creator-identities.mjs | 5 +++- ++ .../SupabaseDevCreatorIdentitySeedSync.test.mjs | 4 +++ ++ dev/tests/dev-runtime/TagsApiService.test.mjs | 1 + ++ .../supabase-dev-creator-identity-seed-sync.mjs | 12 +++++--- ++ src/dev-runtime/toolbox-api/alfa-tool-services.mjs | 31 -------------------- ++ 11 files changed, 94 insertions(+), 46 deletions(-) +\ No newline at end of file +diff --git a/dev/reports/codex_review.diff b/dev/reports/codex_review.diff +index 2d6bfa64b..91ce989af 100644 +--- a/dev/reports/codex_review.diff ++++ b/dev/reports/codex_review.diff +@@ -1,119 +1,351 @@ +-diff --git a/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-branch-validation.md b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-branch-validation.md +-new file mode 100644 +-index 000000000..16f7682d6 +---- /dev/null +-+++ b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-branch-validation.md +-@@ -0,0 +1,16 @@ +-+# PR_26179_ALFA_010-tool-display-single-line-summary EOD Branch Validation ++diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md ++index c807ea892..50c821aaa 100644 ++--- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md ++@@ -16,8 +16,19 @@ PASS ++ - PASS `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` ++ - PASS `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++ - PASS `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +++- PASS `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` +++- PASS `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs` +++- PASS `node --check dev/tests/dev-runtime/TagsApiService.test.mjs` ++ - PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++ - PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +++- PASS `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` ++ - PASS `git diff --check` ++ - PASS `npm run validate:canonical-structure` ++ +++## DEV Identity Verification + + +-+## Result +++- PASS `qbytes.dq@gmail.com` exists in Supabase Auth. +++- PASS `qbytes.dq@gmail.com` exists in product `users`. +++- PASS `users.authProviderUserId` matches Supabase Auth id. +++- PASS DEV identity sync ran with password updates disabled. +++- PASS `/api/auth/sign-in` resolves qbytes to the database `users.key`. ++diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md ++index 005ca851f..363e4ccda 100644 ++--- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md ++@@ -1,14 +1,16 @@ ++ # Manual Validation Notes: PR_26179_OWNER_035-dev-auth-user-key-db-authority ++ ++-Manual browser validation was not run because this PR changes server-side auth/session resolution and DEV identity sync behavior. The targeted API-level tests cover the affected user-visible outcomes: +++Manual browser validation was not run because this PR changes server-side auth/session resolution and DEV identity sync behavior. The targeted API-level tests and live API sign-in smoke cover the affected user-visible outcomes: ++ ++ - A valid Supabase Auth id linked to `users.authProviderUserId` signs in and returns the database-owned `users.key`. ++ - A matching email with stale `users.authProviderUserId` does not sign in and returns the existing Creator-safe identity setup message. ++ - The browser response does not expose raw Auth ids or database user keys on the failure path. ++ ++-Recommended manual follow-up in DEV after merge: ++- ++-- Run the approved DEV identity sync. ++-- Sign in as `qbytes.dq@gmail.com`. ++-- Confirm the session resolves to the existing database `users.key` row whose `authProviderUserId` equals the Supabase Auth user id. +++Live DEV notes: ++ +++- Initial audit found Supabase Auth present but product `users` missing for `qbytes.dq@gmail.com`. +++- The missing database row was caused by a toolbox helper upserting seed users over the reserved account keys. +++- The helper was fixed so toolbox seeding no longer upserts `users`. +++- The approved DEV account DML restored the missing rows. +++- DEV identity sync ran with password updates disabled. +++- Final `/api/auth/sign-in` smoke for `qbytes.dq@gmail.com` passed and resolved to the database `users.key`. ++diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md ++index 94dfcbd73..fb83e9015 100644 ++--- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md ++@@ -33,11 +33,40 @@ Runtime sign-in now resolves a session only when the Supabase Auth user id match ++ - Added a narrow server-side Postgres client injection hook for tests, with default runtime behavior unchanged. ++ - Refactored DEV creator identity sync so canonical identity definitions are email-based and database `users.key` is read from existing rows. ++ - Changed role assignment and cleanup repair logic to derive canonical user keys from database rows. +++- Removed the Alfa toolbox tag/design/configuration helper behavior that upserted seed `users` rows over the static DEV account keys. +++- Changed the DEV identity sync default so existing Supabase Auth users are updated without sending a password. Password updates now require an explicit `--update-passwords` flag. ++ - Added regression tests proving: ++ - Sign-in uses the database-owned `users.key` matched by `authProviderUserId`. ++ - Sign-in does not create a session from matching email when `authProviderUserId` is stale. ++ - DEV sync preserves non-seed database user keys. ++ - DEV sync fails if an expected database `users` row is missing. +++ - Tags API seeding does not upsert rows into `users`. +++ - Existing Auth user sync does not send a password by default. + + +-+PASS +++## DEV Database Verification + + +-+## Checks +++Performed against the current `.env` DEV database target. + + +-+| Check | Result | Notes | +-+| --- | --- | --- | +-+| Started EOD from `main` | PASS | Worktree was clean before merge. | +-+| PR #254 merged into `main` | PASS | Merge commit `283e2247625fea0916b55119e4072342b207c317`. | +-+| Local `main` synced after merge | PASS | `main...origin/main` was `0 0` after fast-forward. | +-+| Stale PR #196 closed | PASS | Closed as replaced by PR #254. | +-+| Stale PR #198 closed | PASS | Closed as replaced by PR #254. | +-+| Feature branch cleanup | PASS | Remote branch deletion was requested during merge; local branch deletion handled after syncing `main`. | +-diff --git a/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-closeout-report.md b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-closeout-report.md +-new file mode 100644 +-index 000000000..43d16f559 +---- /dev/null +-+++ b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-closeout-report.md +-@@ -0,0 +1,31 @@ +-+# PR_26179_ALFA_010-tool-display-single-line-summary EOD Closeout Report +++Initial audit: + + +-+## Summary +++- `qbytes.dq@gmail.com` existed in Supabase Auth. +++- `qbytes.dq@gmail.com` was missing from the product `users` table. +++- The reserved static DEV account keys for User 1 and DavidQ had been overwritten by seed `Creator` / `Forge Bot` rows. + + +-+PR #254 `PR_26179_ALFA_010-tool-display-single-line-summary` was merged into `main`. +++Repair and sync: + + +-+## Merge +++- Re-applied the approved DEV account DML to restore the missing database identity rows. +++- Ran the DEV identity sync with `updatePasswords: false`. +++- qbytes sync action: `updated`. +++- qbytes password update: `false`. + + +-+- PR: https://github.com/ToolboxAid/HTML-JavaScript-Gaming/pull/254 +-+- Merge commit: `283e2247625fea0916b55119e4072342b207c317` +-+- Merged at: `2026-06-28T14:28:04Z` +-+- Base branch: `main` +-+- Source branch: `PR_26179_ALFA_010-tool-display-single-line-summary` +++Final audit: + + +-+## Replaced Stale PRs +++- Supabase Auth match count: `1`. +++- Product `users` match count: `1`. +++- `users.authProviderUserId` equals Supabase Auth `auth.users.id`: PASS. +++- Sign-in smoke through `/api/auth/sign-in`: PASS. +++- Session resolved to the database `users.key`: PASS. ++ ++ ## Files Changed ++ ++@@ -54,8 +83,11 @@ PASS: ++ - `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` ++ - `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` ++ - `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +++- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` +++- `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs` +++- `node --check dev/tests/dev-runtime/TagsApiService.test.mjs` ++ - `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++ - `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +++- `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` ++ - `git diff --check` ++ - `npm run validate:canonical-structure` ++- ++diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md ++index 180e988c1..0b53b8e3a 100644 ++--- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md ++@@ -11,8 +11,15 @@ ++ - PASS Do not add password tables. ++ - PASS Do not use `displayName` as an identity key. ++ - PASS Do not hardcode DavidQ/user keys in runtime login resolution. +++- PASS Verify qbytes exists in Supabase Auth. +++- PASS Verify qbytes exists in the product `users` table. +++- PASS Verify `users.authProviderUserId` equals Supabase Auth id. +++- PASS If the database row/link is stale, run DEV identity sync. +++- PASS Do not change password during existing Auth user sync. +++- PASS Do not recreate qbytes Auth user because it already exists. +++- PASS Do not use email fallback for session resolution. +++- PASS Re-test sign-in after sync. ++ - PASS Report where `users.key` currently comes from. ++ - PASS Report where `authProviderUserId` is populated. ++ - PASS Report whether seed constants are setup-only or runtime identity authority. ++ - PASS Report exact fix. ++- ++diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md ++index 634164f94..d3aa670c1 100644 ++--- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md ++@@ -8,11 +8,14 @@ Targeted auth and DEV identity sync validation. ++ ++ - PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` ++ - PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +++- PASS `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` +++- PASS live DEV identity audit and sign-in smoke for `qbytes.dq@gmail.com` ++ ++ ## Coverage ++ ++ - DEV identity sync reads existing database `users.key` values by email. ++ - DEV identity sync writes real Supabase Auth ids to `users.authProviderUserId`. +++- Existing Auth user sync does not update passwords by default. ++ - Session resolution accepts only `auth.users.id` matched to `users.authProviderUserId`. ++ - Email-only session resolution is rejected with the existing Creator-safe setup message. ++- +++- Tags API setup no longer writes seed rows to the account `users` table. ++diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md ++index 5d5f107cc..bc901b4c1 100644 ++--- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md +++++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md ++@@ -10,8 +10,21 @@ PASS ++ - `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs`: PASS ++ - `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS ++ - `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS +++- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs`: PASS +++- `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs`: PASS +++- `node --check dev/tests/dev-runtime/TagsApiService.test.mjs`: PASS ++ - `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS, 2 tests ++ - `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS, 2 tests +++- `node --test dev/tests/dev-runtime/TagsApiService.test.mjs`: PASS, 3 tests + + +-+- PR #196 closed: https://github.com/ToolboxAid/HTML-JavaScript-Gaming/pull/196 +-+- PR #198 closed: https://github.com/ToolboxAid/HTML-JavaScript-Gaming/pull/198 +++## Live DEV Validation + + +-+Both stale PRs were replaced by PR #254. PR #196 provided the obsolete Tool Display Mode single-line layout attempt, and PR #198 provided historical validation intent that was folded into PR #254. +-+ +-+## Validation +-+ +-+- GitHub platform-validation for PR #254: PASS +-+- Local `git diff --check`: PASS +-+- Local `npm run validate:canonical-structure`: PASS +-+- Local targeted Tool Display Mode Playwright lane: PASS, 3/3 +-+ +-+## Artifact +-+ +-+- EOD ZIP: `dev/workspace/zips/PR_26179_ALFA_010-tool-display-single-line-summary_eod-closeout_delta.zip` +-diff --git a/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-manual-validation-notes.md b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-manual-validation-notes.md +-new file mode 100644 +-index 000000000..fd3ca1ffb +---- /dev/null +-+++ b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-manual-validation-notes.md +-@@ -0,0 +1,8 @@ +-+# PR_26179_ALFA_010-tool-display-single-line-summary EOD Manual Validation Notes +-+ +-+## Notes +-+ +-+- Confirmed PR #254 was non-draft, mergeable, and had passing GitHub platform validation before merge. +-+- Confirmed PR #196 and PR #198 were stale drafts replaced by PR #254 before closing them. +-+- No additional runtime or UI edits were made during EOD closeout beyond tracked governance/report artifacts. +-+- The repo-structured EOD ZIP is generated under `dev/workspace/zips/`. +-diff --git a/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-requirement-checklist.md b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-requirement-checklist.md +-new file mode 100644 +-index 000000000..aaad0e510 +---- /dev/null +-+++ b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-requirement-checklist.md +-@@ -0,0 +1,16 @@ +-+# PR_26179_ALFA_010-tool-display-single-line-summary EOD Requirement Checklist +-+ +-+| Requirement | Result | Notes | +-+| --- | --- | --- | +-+| Verify current branch is `main` before beginning | PASS | Switched to clean synced `main` before merge operations. | +-+| Verify worktree is clean | PASS | Clean before merge and before EOD report generation. | +-+| Verify required validation passed | PASS | PR CI and local required validation passed. | +-+| Merge PR #254 | PASS | Merged into `main`. | +-+| Push/sync `main` to origin | PASS | Merge landed on origin and local `main` fast-forwarded. | +-+| Verify clean status and `main...origin/main` is `0 0` | PASS | Confirmed after merge sync. | +-+| Close stale PR #196 | PASS | Closed as replaced by #254. | +-+| Close stale PR #198 | PASS | Closed as replaced by #254. | +-+| Return repository to `main` | PASS | EOD closeout remains on `main`. | +-+| Delete local feature branch if appropriate | PASS | Local branch deletion handled after merge and sync. | +-+| Delete remote feature branch if policy allows | PASS | Remote branch deletion was requested through GitHub merge flow. | +-+| Produce EOD reports and ZIP | PASS | Reports under `dev/reports/`; ZIP under `dev/workspace/zips/`. | +-diff --git a/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-validation-report.md b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-validation-report.md +-new file mode 100644 +-index 000000000..8a78dd606 +---- /dev/null +-+++ b/dev/reports/PR_26179_ALFA_010-tool-display-single-line-summary_eod-validation-report.md +-@@ -0,0 +1,18 @@ +-+# PR_26179_ALFA_010-tool-display-single-line-summary EOD Validation Report +-+ +-+## GitHub Validation +-+ +-+- PR #254 platform-validation: PASS +-+- PR #254 mergeability before merge: MERGEABLE +-+- PR #254 draft status before merge: false +-+ +-+## Local Validation +-+ +-+- `git diff --check`: PASS +-+- `npm run validate:canonical-structure`: PASS +-+- `npm run test:lane:tool-display-mode`: PASS, 3/3 +-+ +-+## Stale PR Closure +-+ +-+- PR #196: CLOSED +-+- PR #198: CLOSED +++- PASS Initial audit detected Supabase Auth user for `qbytes.dq@gmail.com`. +++- PASS Initial audit detected missing/stale product `users` row and halted sign-in retest. +++- PASS Approved DEV account DML restored the missing identity rows. +++- PASS DEV identity sync completed with `updatePasswords: false`. +++- PASS Final audit verified `users.authProviderUserId` equals Supabase Auth id. +++- PASS `/api/auth/sign-in` returned HTTP 200 and resolved the session to the database `users.key`. ++ ++ ## Repository Guardrails ++ ++@@ -21,4 +34,3 @@ PASS ++ ## Notes ++ ++ The targeted tests use a server-side Postgres client fixture to exercise the Local API route without opening a real database connection. Default runtime behavior remains unchanged because the injected client defaults to `null`. ++- ++diff --git a/dev/scripts/sync-supabase-dev-creator-identities.mjs b/dev/scripts/sync-supabase-dev-creator-identities.mjs ++index 0363e1483..31bcc5f08 100644 ++--- a/dev/scripts/sync-supabase-dev-creator-identities.mjs +++++ b/dev/scripts/sync-supabase-dev-creator-identities.mjs ++@@ -66,14 +66,16 @@ function formatRecordList(label, records, selector) { ++ const args = new Set(process.argv.slice(2)); ++ const json = args.has("--json"); ++ const dryRun = args.has("--dry-run"); +++const updatePasswords = args.has("--update-passwords"); ++ const envLoad = loadEnvLocal(); ++ ++ try { ++- const result = await syncSupabaseDevCreatorIdentities({ dryRun }); +++ const result = await syncSupabaseDevCreatorIdentities({ dryRun, updatePasswords }); ++ if (json) { ++ console.log(JSON.stringify({ ++ envLocalLoaded: envLoad.loaded, ++ envLocalLoadedKeys: envLoad.loadedKeys, +++ updatePasswords, ++ ...result, ++ }, null, 2)); ++ } else { ++@@ -81,6 +83,7 @@ try { ++ console.log(envLoad.loaded ++ ? `.env.local loaded (${envLoad.loadedKeys} key(s) applied).` ++ : ".env.local was not found."); +++ console.log(`Password updates for existing Auth users: ${updatePasswords ? "enabled" : "disabled"}.`); ++ formatCounts("Before", result.beforeCounts); ++ formatCounts("After", result.afterCounts); ++ formatRecordList("Auth upserts", result.authUpsertRecords, (record) => `${record.email}: ${record.action}`); ++diff --git a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs ++index f3411521d..c3176b89c 100644 ++--- a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs +++++ b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs ++@@ -301,6 +301,10 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext ++ const davidq = fake.state.users.find((user) => user.email === "qbytes.dq@gmail.com"); ++ assert.equal(fake.state.user_roles.some((row) => row.userKey === davidq.key && row.roleKey === ownerRole.key), true); ++ assert.equal(fake.calls.some((call) => call.path === "/auth/v1/admin/users?page=1&per_page=100" && call.method === "GET"), true); +++ const existingUserUpdate = fake.calls.find((call) => call.method === "PUT" && call.path === "/auth/v1/admin/users/auth-user-1"); +++ assert.equal(Boolean(existingUserUpdate), true); +++ assert.equal(Object.hasOwn(existingUserUpdate.body, "password"), false); +++ assert.equal(result.authUpsertRecords.find((record) => record.email === "user1@example.invalid").passwordUpdated, false); ++ }); ++ ++ test("Supabase DEV creator identity sync requires existing database users rows by email", async () => { ++diff --git a/dev/tests/dev-runtime/TagsApiService.test.mjs b/dev/tests/dev-runtime/TagsApiService.test.mjs ++index bf9a6512a..8a9a3f6ef 100644 ++--- a/dev/tests/dev-runtime/TagsApiService.test.mjs +++++ b/dev/tests/dev-runtime/TagsApiService.test.mjs ++@@ -115,6 +115,7 @@ test("Tags list reads and seeds through the API database adapter", async () => { ++ assert.equal(adapter.calls.some(([action, table]) => action === "requestTable" && table === "project_tags"), true); ++ assert.equal(adapter.calls.some(([action, table]) => action === "upsertProductTable" && table === "project_tags"), true); ++ assert.equal(adapter.calls.some(([action, table]) => action === "upsertProductTable" && table === "project_tag_assignments"), true); +++ assert.equal(adapter.calls.some(([action, table]) => action === "upsertTable" && table === "users"), false); ++ }); ++ ++ test("Tags list reports an actionable setup error when readTables cannot read the schema", async () => { ++diff --git a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs ++index 0eeb0cd0b..9da3e7a87 100644 ++--- a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs +++++ b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs ++@@ -330,20 +330,21 @@ async function deleteDeprecatedRoleArtifacts({ databaseProvider, dryRun, roles, ++ }; ++ } ++ ++-async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun) { +++async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun, { updatePasswords = false } = {}) { ++ const existingUser = existingByEmail.get(identity.email.toLowerCase()); ++ if (dryRun) { ++ return { ++ action: existingUser ? "would-update" : "would-create", ++ authProviderUserId: maskId(authUserId(existingUser)), ++ email: identity.email, +++ passwordUpdated: false, ++ }; ++ } ++ if (existingUser) { ++ const payload = await authProvider.updateAccount({ ++ authProviderUserId: authUserId(existingUser), ++ email: identity.email, ++- password: requestedDevPassword(identity), +++ password: updatePasswords ? requestedDevPassword(identity) : "", ++ userMetadata: { ++ display_name: identity.displayName, ++ name: identity.displayName, ++@@ -353,6 +354,7 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu ++ action: "updated", ++ authProviderUserId: maskId(authUserId(payload) || authUserId(existingUser)), ++ email: identity.email, +++ passwordUpdated: updatePasswords, ++ }; ++ } ++ const created = await authProvider.createAccount({ ++@@ -363,7 +365,7 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu ++ const updated = await authProvider.updateAccount({ ++ authProviderUserId: createdUserId, ++ email: identity.email, ++- password: requestedDevPassword(identity), +++ password: updatePasswords ? requestedDevPassword(identity) : "", ++ userMetadata: { ++ display_name: identity.displayName, ++ name: identity.displayName, ++@@ -373,6 +375,7 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu ++ action: "created", ++ authProviderUserId: maskId(authUserId(updated) || createdUserId), ++ email: identity.email, +++ passwordUpdated: true, ++ }; ++ } ++ ++@@ -682,6 +685,7 @@ export async function syncSupabaseDevCreatorIdentities({ ++ databaseProvider = new SupabasePostgresProviderAdapter(), ++ dryRun = false, ++ env = process.env, +++ updatePasswords = false, ++ } = {}) { ++ assertDevOnlyAuthTestCleanupEnvironment(env); ++ const beforeAuthUsers = await authProvider.listAllAdminUsers(); ++@@ -697,7 +701,7 @@ export async function syncSupabaseDevCreatorIdentities({ ++ const existingByEmail = authUsersByEmail(beforeAuthUsers); ++ const authUpsertRecords = []; ++ for (const identity of DEV_CREATOR_IDENTITIES) { ++- authUpsertRecords.push(await upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun)); +++ authUpsertRecords.push(await upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun, { updatePasswords })); ++ } ++ ++ const authUsersForIdentity = dryRun ? beforeAuthUsers : await authProvider.listAllAdminUsers(); ++diff --git a/src/dev-runtime/toolbox-api/alfa-tool-services.mjs b/src/dev-runtime/toolbox-api/alfa-tool-services.mjs ++index 239c22a4d..6dc033e83 100644 ++--- a/src/dev-runtime/toolbox-api/alfa-tool-services.mjs +++++ b/src/dev-runtime/toolbox-api/alfa-tool-services.mjs ++@@ -295,38 +295,7 @@ function activeGameFromWorkspace(repository, overrideId = "") { ++ }; ++ } ++ ++-async function ensureUsers(adapter) { ++- const now = timestamp(); ++- await adapter.upsertTable("users", [ ++- { ++- key: SYSTEM_USER_KEY, ++- displayName: "Forge Bot", ++- email: null, ++- authProvider: "seed", ++- authProviderUserId: "forge-bot", ++- isActive: true, ++- createdAt: now, ++- updatedAt: now, ++- createdBy: null, ++- updatedBy: null, ++- }, ++- { ++- key: DEFAULT_USER_KEY, ++- displayName: "Creator", ++- email: "creator@example.test", ++- authProvider: "seed", ++- authProviderUserId: "creator", ++- isActive: true, ++- createdAt: now, ++- updatedAt: now, ++- createdBy: SYSTEM_USER_KEY, ++- updatedBy: SYSTEM_USER_KEY, ++- }, ++- ]); ++-} ++- ++ async function ensureGameRecord(adapter, game, userKey = DEFAULT_USER_KEY) { ++- await ensureUsers(adapter); ++ await adapter.upsertProductTable("game_workspace_games", [{ ++ key: game.key, ++ name: game.name, +diff --git a/dev/scripts/sync-supabase-dev-creator-identities.mjs b/dev/scripts/sync-supabase-dev-creator-identities.mjs +index 0363e1483..31bcc5f08 100644 +--- a/dev/scripts/sync-supabase-dev-creator-identities.mjs ++++ b/dev/scripts/sync-supabase-dev-creator-identities.mjs +@@ -66,14 +66,16 @@ function formatRecordList(label, records, selector) { + const args = new Set(process.argv.slice(2)); + const json = args.has("--json"); + const dryRun = args.has("--dry-run"); ++const updatePasswords = args.has("--update-passwords"); + const envLoad = loadEnvLocal(); + + try { +- const result = await syncSupabaseDevCreatorIdentities({ dryRun }); ++ const result = await syncSupabaseDevCreatorIdentities({ dryRun, updatePasswords }); + if (json) { + console.log(JSON.stringify({ + envLocalLoaded: envLoad.loaded, + envLocalLoadedKeys: envLoad.loadedKeys, ++ updatePasswords, + ...result, + }, null, 2)); + } else { +@@ -81,6 +83,7 @@ try { + console.log(envLoad.loaded + ? `.env.local loaded (${envLoad.loadedKeys} key(s) applied).` + : ".env.local was not found."); ++ console.log(`Password updates for existing Auth users: ${updatePasswords ? "enabled" : "disabled"}.`); + formatCounts("Before", result.beforeCounts); + formatCounts("After", result.afterCounts); + formatRecordList("Auth upserts", result.authUpsertRecords, (record) => `${record.email}: ${record.action}`); diff --git a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs -index 39ba2d5c7..f3411521d 100644 +index 39ba2d5c7..c3176b89c 100644 --- a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs +++ b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs @@ -11,11 +11,19 @@ import { } from "../../../src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs"; - + const syncEnv = Object.freeze({ + GAMEFOUNDRY_DATABASE_SSL: "disable", + GAMEFOUNDRY_DATABASE_URL: "postgres://sync_user:sync_password@dev-sync.example.test/gamefoundry", @@ -19,7 +820,7 @@ index 39ba2d5c7..f3411521d 100644 + user2: "01J00000000000000000000012", + user3: "01J00000000000000000000013", +}); - + function decodeEqFilter(searchParams, key) { const value = searchParams.get(key) || ""; @@ -43,10 +51,10 @@ function createFakeSupabaseSyncState() { @@ -75,7 +876,7 @@ index 39ba2d5c7..f3411521d 100644 @@ -72,6 +107,65 @@ function createFakeSupabaseSyncState() { const calls = []; let generatedKey = 1; - + + function mutateTableRequest(tableName, { body = {}, method = "GET", query = "select=*" } = {}) { + calls.push({ + body, @@ -141,7 +942,7 @@ index 39ba2d5c7..f3411521d 100644 @@ -130,57 +224,6 @@ function createFakeSupabaseSyncState() { }; } - + - if (requestUrl.pathname.startsWith("/rest/v1/")) { - const tableName = decodeURIComponent(requestUrl.pathname.split("/").pop() || ""); - state[tableName] = state[tableName] || []; @@ -199,7 +1000,7 @@ index 39ba2d5c7..f3411521d 100644 @@ -188,7 +231,14 @@ function createFakeSupabaseSyncState() { }; } - + - return { calls, fetchImpl, state }; + return { + calls, @@ -210,7 +1011,7 @@ index 39ba2d5c7..f3411521d 100644 + state, + }; } - + test("Supabase DEV creator identity sync upserts canonical users and deletes extra managed accounts", async () => { @@ -201,8 +251,8 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext }), @@ -234,10 +1035,15 @@ index 39ba2d5c7..f3411521d 100644 assert.equal(fake.state.users.every((user) => user.authProvider === "supabase-auth"), true); assert.equal(fake.state.roles.some((role) => role.roleSlug === "user"), false); assert.equal(fake.state.roles.find((role) => role.roleSlug === "owner").isActive, true); -@@ -247,3 +302,22 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext +@@ -246,4 +301,27 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext + const davidq = fake.state.users.find((user) => user.email === "qbytes.dq@gmail.com"); assert.equal(fake.state.user_roles.some((row) => row.userKey === davidq.key && row.roleKey === ownerRole.key), true); assert.equal(fake.calls.some((call) => call.path === "/auth/v1/admin/users?page=1&per_page=100" && call.method === "GET"), true); - }); ++ const existingUserUpdate = fake.calls.find((call) => call.method === "PUT" && call.path === "/auth/v1/admin/users/auth-user-1"); ++ assert.equal(Boolean(existingUserUpdate), true); ++ assert.equal(Object.hasOwn(existingUserUpdate.body, "password"), false); ++ assert.equal(result.authUpsertRecords.find((record) => record.email === "user1@example.invalid").passwordUpdated, false); ++}); + +test("Supabase DEV creator identity sync requires existing database users rows by email", async () => { + const fake = createFakeSupabaseSyncState(); @@ -256,7 +1062,7 @@ index 39ba2d5c7..f3411521d 100644 + }), + /requires existing database users rows for: qbytes\.dq@gmail\.com/, + ); -+}); + }); diff --git a/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs b/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs index 3f5d81a00..b86a95014 100644 --- a/dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs @@ -264,7 +1070,7 @@ index 3f5d81a00..b86a95014 100644 @@ -38,8 +38,8 @@ function withEnv(nextEnv, callback) { }); } - + -function startApiServer() { - const handleRequest = createLocalApiRouter(); +function startApiServer(routerOptions = {}) { @@ -275,7 +1081,7 @@ index 3f5d81a00..b86a95014 100644 @@ -313,6 +313,12 @@ function fakeSupabaseIdentityTables(overrides = {}) { }; } - + +function fakePostgresIdentityClient(identityTables = {}) { + return { + requestTable: async (tableName) => (identityTables[tableName] || []).map((row) => ({ ...row })), @@ -288,7 +1094,7 @@ index 3f5d81a00..b86a95014 100644 @@ -881,6 +887,134 @@ test("Create account provisions Supabase identity user default role and user_rol await fakeSupabase.close(); }); - + +test("Supabase sign-in resolves the session from database users.key matched by Auth id", async () => { + const databaseOwnedUserKey = "01J00000000000000000000001"; + const timestamp = "2026-06-15T00:00:00.000Z"; @@ -420,6 +1226,18 @@ index 3f5d81a00..b86a95014 100644 test("Create account provider failure returns generic browser error and logs safe operator diagnostics", async () => { const fakeSupabase = await startFakeSupabaseAuthServer({ failAdminCreateAccount: { +diff --git a/dev/tests/dev-runtime/TagsApiService.test.mjs b/dev/tests/dev-runtime/TagsApiService.test.mjs +index bf9a6512a..8a9a3f6ef 100644 +--- a/dev/tests/dev-runtime/TagsApiService.test.mjs ++++ b/dev/tests/dev-runtime/TagsApiService.test.mjs +@@ -115,6 +115,7 @@ test("Tags list reads and seeds through the API database adapter", async () => { + assert.equal(adapter.calls.some(([action, table]) => action === "requestTable" && table === "project_tags"), true); + assert.equal(adapter.calls.some(([action, table]) => action === "upsertProductTable" && table === "project_tags"), true); + assert.equal(adapter.calls.some(([action, table]) => action === "upsertProductTable" && table === "project_tag_assignments"), true); ++ assert.equal(adapter.calls.some(([action, table]) => action === "upsertTable" && table === "users"), false); + }); + + test("Tags list reports an actionable setup error when readTables cannot read the schema", async () => { diff --git a/src/dev-runtime/server/local-api-router.mjs b/src/dev-runtime/server/local-api-router.mjs index bb54b12b7..7d204e6fd 100644 --- a/src/dev-runtime/server/local-api-router.mjs @@ -437,7 +1255,7 @@ index bb54b12b7..7d204e6fd 100644 this.repositoryById = new Map(); this.sessionModeId = FIXED_ACCOUNT_SESSION_MODE.id; @@ -3826,7 +3828,7 @@ class ApiRuntimeDataSource { - + supabaseDatabaseAdapter(action) { this.assertSupabaseDatabaseProvider(action); - const adapter = new SupabasePostgresProviderAdapter(); @@ -447,7 +1265,7 @@ index bb54b12b7..7d204e6fd 100644 } @@ -3837,8 +3839,7 @@ class ApiRuntimeDataSource { } - + async readSupabaseIdentityTablesUnchecked(action) { - this.assertSupabaseDatabaseProvider(action); - const adapter = new SupabasePostgresProviderAdapter(); @@ -483,7 +1301,7 @@ index bb54b12b7..7d204e6fd 100644 @@ -6230,7 +6236,7 @@ SELECT pg_database_size(current_database()) AS database_size_bytes, throw authIdentitySetupError(action, "Supabase Auth did not return the user id and email required for identity provisioning."); } - + - const adapter = new SupabasePostgresProviderAdapter(); + const adapter = this.supabaseDatabaseAdapter(`${action} identity provisioning`); const tables = await this.readSupabaseIdentityTablesUnchecked(`${action} identity provisioning`); @@ -502,10 +1320,10 @@ index bb54b12b7..7d204e6fd 100644 repoRoot, + supabasePostgresClient, }); - + async function handleApiRuntimeRequest(request, response, requestUrl) { diff --git a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs -index 6f214f7a2..0eeb0cd0b 100644 +index 6f214f7a2..9da3e7a87 100644 --- a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs +++ b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs @@ -1,4 +1,3 @@ @@ -539,7 +1357,7 @@ index 6f214f7a2..0eeb0cd0b 100644 passwordSuffix: "$2026", }), ]); - + const CANONICAL_EMAILS = new Set(DEV_CREATOR_IDENTITIES.map((identity) => identity.email.toLowerCase())); -const CANONICAL_KEYS = new Set(DEV_CREATOR_IDENTITIES.map((identity) => identity.key)); +const DAVIDQ_DEV_EMAIL = "qbytes.dq@gmail.com"; @@ -547,7 +1365,7 @@ index 6f214f7a2..0eeb0cd0b 100644 const LEGACY_DEV_AUTH_USER_IDS = new Set(["user-1", "user-2", "user-3", "davidq", "davidq-admin"]); const KNOWN_STALE_ROLE_KEYS = Object.freeze(["01KV6FVP0ASR2RRR9WXCBKTV6C"]); @@ -125,8 +120,7 @@ export function isManagedExtraDevIdentityEmail(email) { - + function isManagedExtraPublicUser(user = {}) { const email = normalizedEmail(user.email); - const key = normalizedId(user.key); @@ -559,7 +1377,7 @@ index 6f214f7a2..0eeb0cd0b 100644 @@ -191,25 +185,55 @@ function roleBySlug(roles, roleSlug) { return roles.find((role) => normalizedId(role.roleSlug) === roleSlug && role.isActive !== false) || null; } - + -function canonicalDesiredUserRolePairs(roles) { +function canonicalUserKeys(canonicalUsers = []) { + return new Set(canonicalUsers.map((user) => normalizedId(user.key)).filter(Boolean)); @@ -606,7 +1424,7 @@ index 6f214f7a2..0eeb0cd0b 100644 + ownerRole && davidq ? `${normalizedId(davidq.key)}\u0000${ownerRole.key}` : "", ].filter(Boolean)); } - + -function staleCanonicalUserRoleRecords({ roles, userRoles }) { +function staleCanonicalUserRoleRecords({ canonicalUsers, roles, userRoles }) { const validRoleKeys = new Set(roles.map((role) => normalizedId(role.key)).filter(Boolean)); @@ -623,7 +1441,7 @@ index 6f214f7a2..0eeb0cd0b 100644 @@ -229,8 +253,8 @@ function staleCanonicalUserRoleRecords({ roles, userRoles }) { .filter((row) => row.key); } - + -async function repairCanonicalUserRoles({ databaseProvider, dryRun, roles, userRoles }) { - const staleRecords = staleCanonicalUserRoleRecords({ roles, userRoles }); +async function repairCanonicalUserRoles({ canonicalUsers, databaseProvider, dryRun, roles, userRoles }) { @@ -631,10 +1449,55 @@ index 6f214f7a2..0eeb0cd0b 100644 const deletedRecords = []; for (const record of staleRecords) { if (dryRun) { -@@ -352,20 +376,57 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu +@@ -306,20 +330,21 @@ async function deleteDeprecatedRoleArtifacts({ databaseProvider, dryRun, roles, }; } - + +-async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun) { ++async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun, { updatePasswords = false } = {}) { + const existingUser = existingByEmail.get(identity.email.toLowerCase()); + if (dryRun) { + return { + action: existingUser ? "would-update" : "would-create", + authProviderUserId: maskId(authUserId(existingUser)), + email: identity.email, ++ passwordUpdated: false, + }; + } + if (existingUser) { + const payload = await authProvider.updateAccount({ + authProviderUserId: authUserId(existingUser), + email: identity.email, +- password: requestedDevPassword(identity), ++ password: updatePasswords ? requestedDevPassword(identity) : "", + userMetadata: { + display_name: identity.displayName, + name: identity.displayName, +@@ -329,6 +354,7 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu + action: "updated", + authProviderUserId: maskId(authUserId(payload) || authUserId(existingUser)), + email: identity.email, ++ passwordUpdated: updatePasswords, + }; + } + const created = await authProvider.createAccount({ +@@ -339,7 +365,7 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu + const updated = await authProvider.updateAccount({ + authProviderUserId: createdUserId, + email: identity.email, +- password: requestedDevPassword(identity), ++ password: updatePasswords ? requestedDevPassword(identity) : "", + userMetadata: { + display_name: identity.displayName, + name: identity.displayName, +@@ -349,23 +375,61 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu + action: "created", + authProviderUserId: maskId(authUserId(updated) || createdUserId), + email: identity.email, ++ passwordUpdated: true, + }; + } + -function canonicalUserRows(authUsers) { +function canonicalPublicUsersByEmail(publicUsers = []) { + const byEmail = new Map(); @@ -695,7 +1558,7 @@ index 6f214f7a2..0eeb0cd0b 100644 }; }); } -@@ -479,7 +540,6 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU +@@ -479,7 +543,6 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU .filter((role) => String(role.roleSlug || "") === "user") .map((role) => normalizedId(role.key)) .filter(Boolean)); @@ -703,7 +1566,7 @@ index 6f214f7a2..0eeb0cd0b 100644 const identityEvidence = DEV_CREATOR_IDENTITIES.map((identity) => { const auth = authByEmail.get(identity.email.toLowerCase()); const appUser = publicByEmail.get(identity.email.toLowerCase()); -@@ -496,6 +556,11 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU +@@ -496,6 +559,11 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU appUser.authProviderUserId === authUserId(auth), }; }); @@ -715,7 +1578,7 @@ index 6f214f7a2..0eeb0cd0b 100644 const creatorAssignments = DEV_CREATOR_IDENTITIES.map((identity) => { const appUser = publicByEmail.get(identity.email.toLowerCase()); return { -@@ -504,12 +569,12 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU +@@ -504,12 +572,12 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU roleSlug: "creator", }; }); @@ -730,7 +1593,7 @@ index 6f214f7a2..0eeb0cd0b 100644 davidqOwner: davidqOwnerAssignment, user1Creator: creatorAssignments.find((record) => record.email === "user1@example.invalid")?.assigned === true, user2Creator: creatorAssignments.find((record) => record.email === "user2@example.invalid")?.assigned === true, -@@ -539,7 +604,7 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU +@@ -539,7 +607,7 @@ function syncVerification({ afterAuthUsers, afterPublicUsers, afterRoles, afterU userRoleKey: normalizedId(row.key), })); const unexpectedCanonicalAssignments = afterUserRoles @@ -739,7 +1602,12 @@ index 6f214f7a2..0eeb0cd0b 100644 .filter((row) => !desiredPairs.has(`${normalizedId(row.userKey)}\u0000${normalizedId(row.roleKey)}`)) .map((row) => ({ roleKey: normalizedId(row.roleKey), -@@ -621,6 +686,12 @@ export async function syncSupabaseDevCreatorIdentities({ +@@ -617,26 +685,35 @@ export async function syncSupabaseDevCreatorIdentities({ + databaseProvider = new SupabasePostgresProviderAdapter(), + dryRun = false, + env = process.env, ++ updatePasswords = false, + } = {}) { assertDevOnlyAuthTestCleanupEnvironment(env); const beforeAuthUsers = await authProvider.listAllAdminUsers(); const beforePublicUsers = await databaseProvider.getUsers(); @@ -750,11 +1618,14 @@ index 6f214f7a2..0eeb0cd0b 100644 + throw new Error(`Supabase DEV identity sync requires existing database users row for ${DAVIDQ_DEV_EMAIL}.`); + } const beforeCounts = identityCounts({ authUsers: beforeAuthUsers, publicUsers: beforePublicUsers }); - + const existingByEmail = authUsersByEmail(beforeAuthUsers); -@@ -630,13 +701,15 @@ export async function syncSupabaseDevCreatorIdentities({ + const authUpsertRecords = []; + for (const identity of DEV_CREATOR_IDENTITIES) { +- authUpsertRecords.push(await upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun)); ++ authUpsertRecords.push(await upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun, { updatePasswords })); } - + const authUsersForIdentity = dryRun ? beforeAuthUsers : await authProvider.listAllAdminUsers(); + const canonicalUsers = canonicalUserRows(authUsersForIdentity, publicUsersByEmail); + const userRoleRequests = canonicalUserRoleRequests(canonicalUsers); @@ -770,7 +1641,7 @@ index 6f214f7a2..0eeb0cd0b 100644 }, written: { roles: 0, -@@ -645,29 +718,17 @@ export async function syncSupabaseDevCreatorIdentities({ +@@ -645,29 +722,17 @@ export async function syncSupabaseDevCreatorIdentities({ }, } : await databaseProvider.initializeIdentity({ @@ -795,7 +1666,7 @@ index 6f214f7a2..0eeb0cd0b 100644 + userRoles: userRoleRequests, + users: canonicalUsers, }); - + const usersForCleanup = await databaseProvider.getUsers(); const rolesForCleanup = await databaseProvider.getRoles(); const userRolesForCleanup = await databaseProvider.getUserRoles(); @@ -804,7 +1675,7 @@ index 6f214f7a2..0eeb0cd0b 100644 databaseProvider, dryRun, roles: rolesForCleanup, -@@ -685,7 +746,7 @@ export async function syncSupabaseDevCreatorIdentities({ +@@ -685,7 +750,7 @@ export async function syncSupabaseDevCreatorIdentities({ const userRolesForPublicCleanup = dryRun ? userRolesForCleanup : await databaseProvider.getUserRoles(); const publicCleanupCandidates = usersForCleanup.filter(isManagedExtraPublicUser); const publicCleanup = await deleteManagedPublicUsers({ @@ -813,197 +1684,46 @@ index 6f214f7a2..0eeb0cd0b 100644 authProvider, candidates: publicCleanupCandidates, databaseProvider, -diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md -new file mode 100644 -index 000000000..c807ea892 ---- /dev/null -+++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md -@@ -0,0 +1,23 @@ -+# Branch Validation: PR_26179_OWNER_035-dev-auth-user-key-db-authority -+ -+## Status -+ -+PASS -+ -+## Branch -+ -+- Current branch: `PR_26179_OWNER_035-dev-auth-user-key-db-authority` -+- Source branch: `main` -+- Worktree at report time: modified files only for this PR plus generated report artifacts -+ -+## Validation Commands -+ -+- PASS `node --check src/dev-runtime/server/local-api-router.mjs` -+- PASS `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` -+- PASS `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -+- PASS `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` -+- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` -+- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -+- PASS `git diff --check` -+- PASS `npm run validate:canonical-structure` -+ -diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md -new file mode 100644 -index 000000000..005ca851f ---- /dev/null -+++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md -@@ -0,0 +1,14 @@ -+# Manual Validation Notes: PR_26179_OWNER_035-dev-auth-user-key-db-authority -+ -+Manual browser validation was not run because this PR changes server-side auth/session resolution and DEV identity sync behavior. The targeted API-level tests cover the affected user-visible outcomes: -+ -+- A valid Supabase Auth id linked to `users.authProviderUserId` signs in and returns the database-owned `users.key`. -+- A matching email with stale `users.authProviderUserId` does not sign in and returns the existing Creator-safe identity setup message. -+- The browser response does not expose raw Auth ids or database user keys on the failure path. -+ -+Recommended manual follow-up in DEV after merge: -+ -+- Run the approved DEV identity sync. -+- Sign in as `qbytes.dq@gmail.com`. -+- Confirm the session resolves to the existing database `users.key` row whose `authProviderUserId` equals the Supabase Auth user id. -+ -diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md -new file mode 100644 -index 000000000..94dfcbd73 ---- /dev/null -+++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md -@@ -0,0 +1,61 @@ -+# PR_26179_OWNER_035-dev-auth-user-key-db-authority -+ -+## Summary -+ -+This PR makes DEV account session resolution treat the database `users` row as authoritative. -+ -+Runtime sign-in now resolves a session only when the Supabase Auth user id matches `users.authProviderUserId`. Matching by email alone is no longer accepted for login/session resolution. Email remains valid only for the DEV identity sync step that locates the existing database `users` row before writing the real Supabase Auth id back to `users.authProviderUserId`. -+ -+## Diagnostic Findings -+ -+### Where `users.key` currently comes from -+ -+- DEV seed setup defines static user keys in `src/dev-runtime/seed/seed-db-keys.mjs`. -+- Current database seed DML also contains DEV account rows under `dev/build/database/dml/account.sql`. -+- Before this PR, `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` imported those seed keys and used them as the canonical keys during DEV Supabase identity sync. -+- After this PR, DEV sync locates existing `users` rows by email and reads `users.key` from the database row. The sync no longer imports `SEED_DB_KEYS`. -+ -+### Where `authProviderUserId` is populated -+ -+- Account creation provisioning still writes `authProviderUserId` in `src/dev-runtime/server/local-api-router.mjs` through `provisionSupabaseIdentityForAuthPayload()`. -+- DEV identity sync now reads `auth.users.id` from Supabase Auth, reads `users.key` from the database row found by email, and sends that database-owned key plus the real Auth id through `databaseProvider.initializeIdentity()`. -+ -+### Seed constant use -+ -+- Seed constants remain valid for DEV seed setup, seed fixtures, and existing test/demo data. -+- They are no longer used by the DEV identity sync helper as the authoritative app user key source. -+- Runtime sign-in/session resolution does not hardcode DavidQ or other user keys. It reads identity tables and resolves `matchingUser.key` after matching Supabase Auth id to `users.authProviderUserId`. -+ -+## Exact Fix -+ -+- Removed email fallback from Supabase sign-in session resolution. -+- Added a Creator-safe setup failure when Auth succeeds but the matching email row has not been linked to the Auth id. -+- Added a narrow server-side Postgres client injection hook for tests, with default runtime behavior unchanged. -+- Refactored DEV creator identity sync so canonical identity definitions are email-based and database `users.key` is read from existing rows. -+- Changed role assignment and cleanup repair logic to derive canonical user keys from database rows. -+- Added regression tests proving: -+ - Sign-in uses the database-owned `users.key` matched by `authProviderUserId`. -+ - Sign-in does not create a session from matching email when `authProviderUserId` is stale. -+ - DEV sync preserves non-seed database user keys. -+ - DEV sync fails if an expected database `users` row is missing. -+ -+## Files Changed -+ -+- `src/dev-runtime/server/local-api-router.mjs` -+- `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` -+- `dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -+- `dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` -+ -+## Validation -+ -+PASS: -+ -+- `node --check src/dev-runtime/server/local-api-router.mjs` -+- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` -+- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -+- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` -+- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` -+- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -+- `git diff --check` -+- `npm run validate:canonical-structure` -+ -diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md -new file mode 100644 -index 000000000..180e988c1 ---- /dev/null -+++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md -@@ -0,0 +1,18 @@ -+# Requirement Checklist: PR_26179_OWNER_035-dev-auth-user-key-db-authority -+ -+- PASS Do not use hardcoded user keys as runtime identity source. -+- PASS Use email only to locate the existing DEV `users` row during DEV identity sync. -+- PASS Read `users.key` from the database row during DEV sync. -+- PASS Read `auth.users.id` from Supabase Auth during DEV sync. -+- PASS Write `auth.users.id` into `users.authProviderUserId`. -+- PASS Login/session resolution matches Supabase Auth id to `users.authProviderUserId`. -+- PASS Do not create browser-owned auth. -+- PASS Do not create fake login. -+- PASS Do not add password tables. -+- PASS Do not use `displayName` as an identity key. -+- PASS Do not hardcode DavidQ/user keys in runtime login resolution. -+- PASS Report where `users.key` currently comes from. -+- PASS Report where `authProviderUserId` is populated. -+- PASS Report whether seed constants are setup-only or runtime identity authority. -+- PASS Report exact fix. -+ -diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md -new file mode 100644 -index 000000000..634164f94 ---- /dev/null -+++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md -@@ -0,0 +1,18 @@ -+# Validation Lane Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority -+ -+## Lane -+ -+Targeted auth and DEV identity sync validation. -+ -+## Commands -+ -+- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` -+- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -+ -+## Coverage -+ -+- DEV identity sync reads existing database `users.key` values by email. -+- DEV identity sync writes real Supabase Auth ids to `users.authProviderUserId`. -+- Session resolution accepts only `auth.users.id` matched to `users.authProviderUserId`. -+- Email-only session resolution is rejected with the existing Creator-safe setup message. -+ -diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md -new file mode 100644 -index 000000000..5d5f107cc ---- /dev/null -+++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md -@@ -0,0 +1,24 @@ -+# Validation Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority -+ -+## Result -+ -+PASS -+ -+## Targeted Validation -+ -+- `node --check src/dev-runtime/server/local-api-router.mjs`: PASS -+- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs`: PASS -+- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS -+- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS -+- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS, 2 tests -+- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS, 2 tests -+ -+## Repository Guardrails -+ -+- `git diff --check`: PASS -+- `npm run validate:canonical-structure`: PASS -+ -+## Notes -+ -+The targeted tests use a server-side Postgres client fixture to exercise the Local API route without opening a real database connection. Default runtime behavior remains unchanged because the injected client defaults to `null`. -+ +diff --git a/src/dev-runtime/toolbox-api/alfa-tool-services.mjs b/src/dev-runtime/toolbox-api/alfa-tool-services.mjs +index 239c22a4d..6dc033e83 100644 +--- a/src/dev-runtime/toolbox-api/alfa-tool-services.mjs ++++ b/src/dev-runtime/toolbox-api/alfa-tool-services.mjs +@@ -295,38 +295,7 @@ function activeGameFromWorkspace(repository, overrideId = "") { + }; + } + +-async function ensureUsers(adapter) { +- const now = timestamp(); +- await adapter.upsertTable("users", [ +- { +- key: SYSTEM_USER_KEY, +- displayName: "Forge Bot", +- email: null, +- authProvider: "seed", +- authProviderUserId: "forge-bot", +- isActive: true, +- createdAt: now, +- updatedAt: now, +- createdBy: null, +- updatedBy: null, +- }, +- { +- key: DEFAULT_USER_KEY, +- displayName: "Creator", +- email: "creator@example.test", +- authProvider: "seed", +- authProviderUserId: "creator", +- isActive: true, +- createdAt: now, +- updatedAt: now, +- createdBy: SYSTEM_USER_KEY, +- updatedBy: SYSTEM_USER_KEY, +- }, +- ]); +-} +- + async function ensureGameRecord(adapter, game, userKey = DEFAULT_USER_KEY) { +- await ensureUsers(adapter); + await adapter.upsertProductTable("game_workspace_games", [{ + key: game.key, + name: game.name, diff --git a/dev/scripts/sync-supabase-dev-creator-identities.mjs b/dev/scripts/sync-supabase-dev-creator-identities.mjs index 0363e1483..31bcc5f08 100644 --- a/dev/scripts/sync-supabase-dev-creator-identities.mjs +++ b/dev/scripts/sync-supabase-dev-creator-identities.mjs @@ -66,14 +66,16 @@ function formatRecordList(label, records, selector) { const args = new Set(process.argv.slice(2)); const json = args.has("--json"); const dryRun = args.has("--dry-run"); +const updatePasswords = args.has("--update-passwords"); const envLoad = loadEnvLocal(); try { - const result = await syncSupabaseDevCreatorIdentities({ dryRun }); + const result = await syncSupabaseDevCreatorIdentities({ dryRun, updatePasswords }); if (json) { console.log(JSON.stringify({ envLocalLoaded: envLoad.loaded, envLocalLoadedKeys: envLoad.loadedKeys, + updatePasswords, ...result, }, null, 2)); } else { @@ -81,6 +83,7 @@ try { console.log(envLoad.loaded ? `.env.local loaded (${envLoad.loadedKeys} key(s) applied).` : ".env.local was not found."); + console.log(`Password updates for existing Auth users: ${updatePasswords ? "enabled" : "disabled"}.`); formatCounts("Before", result.beforeCounts); formatCounts("After", result.afterCounts); formatRecordList("Auth upserts", result.authUpsertRecords, (record) => `${record.email}: ${record.action}`); diff --git a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs index f3411521d..c3176b89c 100644 --- a/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs +++ b/dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs @@ -301,6 +301,10 @@ test("Supabase DEV creator identity sync upserts canonical users and deletes ext const davidq = fake.state.users.find((user) => user.email === "qbytes.dq@gmail.com"); assert.equal(fake.state.user_roles.some((row) => row.userKey === davidq.key && row.roleKey === ownerRole.key), true); assert.equal(fake.calls.some((call) => call.path === "/auth/v1/admin/users?page=1&per_page=100" && call.method === "GET"), true); + const existingUserUpdate = fake.calls.find((call) => call.method === "PUT" && call.path === "/auth/v1/admin/users/auth-user-1"); + assert.equal(Boolean(existingUserUpdate), true); + assert.equal(Object.hasOwn(existingUserUpdate.body, "password"), false); + assert.equal(result.authUpsertRecords.find((record) => record.email === "user1@example.invalid").passwordUpdated, false); }); test("Supabase DEV creator identity sync requires existing database users rows by email", async () => { diff --git a/dev/tests/dev-runtime/TagsApiService.test.mjs b/dev/tests/dev-runtime/TagsApiService.test.mjs index bf9a6512a..8a9a3f6ef 100644 --- a/dev/tests/dev-runtime/TagsApiService.test.mjs +++ b/dev/tests/dev-runtime/TagsApiService.test.mjs @@ -115,6 +115,7 @@ test("Tags list reads and seeds through the API database adapter", async () => { assert.equal(adapter.calls.some(([action, table]) => action === "requestTable" && table === "project_tags"), true); assert.equal(adapter.calls.some(([action, table]) => action === "upsertProductTable" && table === "project_tags"), true); assert.equal(adapter.calls.some(([action, table]) => action === "upsertProductTable" && table === "project_tag_assignments"), true); + assert.equal(adapter.calls.some(([action, table]) => action === "upsertTable" && table === "users"), false); }); test("Tags list reports an actionable setup error when readTables cannot read the schema", async () => { diff --git a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs index 0eeb0cd0b..9da3e7a87 100644 --- a/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs +++ b/src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs @@ -330,20 +330,21 @@ async function deleteDeprecatedRoleArtifacts({ databaseProvider, dryRun, roles, }; } -async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun) { +async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun, { updatePasswords = false } = {}) { const existingUser = existingByEmail.get(identity.email.toLowerCase()); if (dryRun) { return { action: existingUser ? "would-update" : "would-create", authProviderUserId: maskId(authUserId(existingUser)), email: identity.email, + passwordUpdated: false, }; } if (existingUser) { const payload = await authProvider.updateAccount({ authProviderUserId: authUserId(existingUser), email: identity.email, - password: requestedDevPassword(identity), + password: updatePasswords ? requestedDevPassword(identity) : "", userMetadata: { display_name: identity.displayName, name: identity.displayName, @@ -353,6 +354,7 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu action: "updated", authProviderUserId: maskId(authUserId(payload) || authUserId(existingUser)), email: identity.email, + passwordUpdated: updatePasswords, }; } const created = await authProvider.createAccount({ @@ -363,7 +365,7 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu const updated = await authProvider.updateAccount({ authProviderUserId: createdUserId, email: identity.email, - password: requestedDevPassword(identity), + password: updatePasswords ? requestedDevPassword(identity) : "", userMetadata: { display_name: identity.displayName, name: identity.displayName, @@ -373,6 +375,7 @@ async function upsertAuthIdentity(authProvider, identity, existingByEmail, dryRu action: "created", authProviderUserId: maskId(authUserId(updated) || createdUserId), email: identity.email, + passwordUpdated: true, }; } @@ -682,6 +685,7 @@ export async function syncSupabaseDevCreatorIdentities({ databaseProvider = new SupabasePostgresProviderAdapter(), dryRun = false, env = process.env, + updatePasswords = false, } = {}) { assertDevOnlyAuthTestCleanupEnvironment(env); const beforeAuthUsers = await authProvider.listAllAdminUsers(); @@ -697,7 +701,7 @@ export async function syncSupabaseDevCreatorIdentities({ const existingByEmail = authUsersByEmail(beforeAuthUsers); const authUpsertRecords = []; for (const identity of DEV_CREATOR_IDENTITIES) { - authUpsertRecords.push(await upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun)); + authUpsertRecords.push(await upsertAuthIdentity(authProvider, identity, existingByEmail, dryRun, { updatePasswords })); } const authUsersForIdentity = dryRun ? beforeAuthUsers : await authProvider.listAllAdminUsers(); diff --git a/src/dev-runtime/toolbox-api/alfa-tool-services.mjs b/src/dev-runtime/toolbox-api/alfa-tool-services.mjs index d24eb63ac..426f4a83a 100644 --- a/src/dev-runtime/toolbox-api/alfa-tool-services.mjs +++ b/src/dev-runtime/toolbox-api/alfa-tool-services.mjs @@ -418,38 +418,7 @@ function activeGameFromWorkspace(repository, overrideId = "") { }; } -async function ensureUsers(adapter) { - const now = timestamp(); - await adapter.upsertTable("users", [ - { - key: SYSTEM_USER_KEY, - displayName: "Forge Bot", - email: null, - authProvider: "seed", - authProviderUserId: "forge-bot", - isActive: true, - createdAt: now, - updatedAt: now, - createdBy: null, - updatedBy: null, - }, - { - key: DEFAULT_USER_KEY, - displayName: "Creator", - email: "creator@example.test", - authProvider: "seed", - authProviderUserId: "creator", - isActive: true, - createdAt: now, - updatedAt: now, - createdBy: SYSTEM_USER_KEY, - updatedBy: SYSTEM_USER_KEY, - }, - ]); -} - async function ensureGameRecord(adapter, game, userKey = DEFAULT_USER_KEY) { - await ensureUsers(adapter); await adapter.upsertProductTable("game_workspace_games", [{ key: game.key, name: game.name, From 1fe30811eb648fdafb3662ad19d8917b60fea9f9 Mon Sep 17 00:00:00 2001 From: Alfa Team Date: Sun, 28 Jun 2026 14:41:37 -0400 Subject: [PATCH 3/3] Package PR035 DEV auth user key authority --- ...user-key-db-authority_branch_validation.md | 38 ++------ ...ey-db-authority_manual_validation_notes.md | 25 ++--- ...5-dev-auth-user-key-db-authority_report.md | 90 ++++++++---------- ...-key-db-authority_requirement_checklist.md | 38 +++----- ...key-db-authority_validation_lane_report.md | 26 +++-- ...user-key-db-authority_validation_report.md | 54 ++++------- dev/reports/codex_changed_files.txt | Bin 3014 -> 2004 bytes dev/reports/codex_review.diff | Bin 85171 -> 133302 bytes 8 files changed, 102 insertions(+), 169 deletions(-) diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md index 50c821aaa..51b5fb979 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md @@ -1,34 +1,10 @@ -# Branch Validation: PR_26179_OWNER_035-dev-auth-user-key-db-authority +# PR_26179_OWNER_035-dev-auth-user-key-db-authority Branch Validation -## Status - -PASS - -## Branch +Status: PASS - Current branch: `PR_26179_OWNER_035-dev-auth-user-key-db-authority` -- Source branch: `main` -- Worktree at report time: modified files only for this PR plus generated report artifacts - -## Validation Commands - -- PASS `node --check src/dev-runtime/server/local-api-router.mjs` -- PASS `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` -- PASS `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -- PASS `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` -- PASS `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` -- PASS `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs` -- PASS `node --check dev/tests/dev-runtime/TagsApiService.test.mjs` -- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` -- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -- PASS `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` -- PASS `git diff --check` -- PASS `npm run validate:canonical-structure` - -## DEV Identity Verification - -- PASS `qbytes.dq@gmail.com` exists in Supabase Auth. -- PASS `qbytes.dq@gmail.com` exists in product `users`. -- PASS `users.authProviderUserId` matches Supabase Auth id. -- PASS DEV identity sync ran with password updates disabled. -- PASS `/api/auth/sign-in` resolves qbytes to the database `users.key`. +- Base branch: `main` +- Rebased onto latest synchronized main: PASS +- Worktree clean before validation: PASS +- Main excludes the fix until PR merge: PASS +- Branch ready to push/open PR: PASS diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md index 363e4ccda..eef115426 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md @@ -1,16 +1,17 @@ -# Manual Validation Notes: PR_26179_OWNER_035-dev-auth-user-key-db-authority +# PR_26179_OWNER_035-dev-auth-user-key-db-authority Manual Validation Notes -Manual browser validation was not run because this PR changes server-side auth/session resolution and DEV identity sync behavior. The targeted API-level tests and live API sign-in smoke cover the affected user-visible outcomes: +Status: READY FOR OWNER VALIDATION -- A valid Supabase Auth id linked to `users.authProviderUserId` signs in and returns the database-owned `users.key`. -- A matching email with stale `users.authProviderUserId` does not sign in and returns the existing Creator-safe identity setup message. -- The browser response does not expose raw Auth ids or database user keys on the failure path. +Manual checks after merge: -Live DEV notes: +1. Run the approved DEV identity sync against DEV. +2. Confirm `qbytes.dq@gmail.com` exists in Supabase Auth. +3. Confirm `qbytes.dq@gmail.com` exists in the product `users` table. +4. Confirm product `users.authProviderUserId` equals Supabase Auth `auth.users.id`. +5. Sign in as `qbytes.dq@gmail.com` without changing password. +6. Confirm the resolved session uses the database `users.key`. -- Initial audit found Supabase Auth present but product `users` missing for `qbytes.dq@gmail.com`. -- The missing database row was caused by a toolbox helper upserting seed users over the reserved account keys. -- The helper was fixed so toolbox seeding no longer upserts `users`. -- The approved DEV account DML restored the missing rows. -- DEV identity sync ran with password updates disabled. -- Final `/api/auth/sign-in` smoke for `qbytes.dq@gmail.com` passed and resolved to the database `users.key`. +Packaging note: + +- Read-only pre-package verification found the Auth user present but the product users row missing on the current DEV target. +- The mutating DEV identity sync was intentionally not run during this package/push step. diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md index fb83e9015..5548a0d22 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md @@ -1,30 +1,36 @@ -# PR_26179_OWNER_035-dev-auth-user-key-db-authority +# PR_26179_OWNER_035-dev-auth-user-key-db-authority ## Summary This PR makes DEV account session resolution treat the database `users` row as authoritative. -Runtime sign-in now resolves a session only when the Supabase Auth user id matches `users.authProviderUserId`. Matching by email alone is no longer accepted for login/session resolution. Email remains valid only for the DEV identity sync step that locates the existing database `users` row before writing the real Supabase Auth id back to `users.authProviderUserId`. +Runtime sign-in now resolves a session only when the Supabase Auth user id matches `users.authProviderUserId`. Matching by email alone is not accepted for login/session resolution. Email remains valid only for the approved DEV identity sync step that locates the existing database `users` row before writing the real Supabase Auth id back to `users.authProviderUserId`. + +## Branch Status + +- Source branch: `PR_26179_OWNER_035-dev-auth-user-key-db-authority` +- Base branch: `main` +- Rebased onto main HEAD: `13cbe98955c1bcc014bff07480b7d3399386e72f` +- Main still excludes this fix until PR merge: yes ## Diagnostic Findings -### Where `users.key` currently comes from +### Where `users.key` comes from -- DEV seed setup defines static user keys in `src/dev-runtime/seed/seed-db-keys.mjs`. -- Current database seed DML also contains DEV account rows under `dev/build/database/dml/account.sql`. -- Before this PR, `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` imported those seed keys and used them as the canonical keys during DEV Supabase identity sync. -- After this PR, DEV sync locates existing `users` rows by email and reads `users.key` from the database row. The sync no longer imports `SEED_DB_KEYS`. +- DEV seed setup still defines static fixture keys in `src/dev-runtime/seed/seed-db-keys.mjs` for seed/bootstrap data. +- Before this PR, DEV Supabase identity sync used those seed constants as the authoritative app user keys. +- After this PR, DEV sync locates existing database `users` rows by email and reads `users.key` from the database row. Missing or duplicate database rows are explicit setup errors. ### Where `authProviderUserId` is populated -- Account creation provisioning still writes `authProviderUserId` in `src/dev-runtime/server/local-api-router.mjs` through `provisionSupabaseIdentityForAuthPayload()`. -- DEV identity sync now reads `auth.users.id` from Supabase Auth, reads `users.key` from the database row found by email, and sends that database-owned key plus the real Auth id through `databaseProvider.initializeIdentity()`. +- Account provisioning continues to write `authProviderUserId` in `src/dev-runtime/server/local-api-router.mjs` through the API/server path. +- DEV identity sync reads Supabase Auth `auth.users.id`, reads the existing product `users.key` from the database row found by email, then writes the real Auth id to `users.authProviderUserId`. -### Seed constant use +### Runtime resolution -- Seed constants remain valid for DEV seed setup, seed fixtures, and existing test/demo data. -- They are no longer used by the DEV identity sync helper as the authoritative app user key source. -- Runtime sign-in/session resolution does not hardcode DavidQ or other user keys. It reads identity tables and resolves `matchingUser.key` after matching Supabase Auth id to `users.authProviderUserId`. +- Supabase sign-in/session resolution now requires an active product `users` row whose `authProviderUserId` equals the Supabase Auth user id. +- If Auth succeeds but the product row is not linked, the API returns a controlled setup message directing the operator to run the approved DEV identity sync. +- Runtime login no longer hardcodes DavidQ/user keys and no longer uses display name as an identity key. ## Exact Fix @@ -33,61 +39,43 @@ Runtime sign-in now resolves a session only when the Supabase Auth user id match - Added a narrow server-side Postgres client injection hook for tests, with default runtime behavior unchanged. - Refactored DEV creator identity sync so canonical identity definitions are email-based and database `users.key` is read from existing rows. - Changed role assignment and cleanup repair logic to derive canonical user keys from database rows. -- Removed the Alfa toolbox tag/design/configuration helper behavior that upserted seed `users` rows over the static DEV account keys. -- Changed the DEV identity sync default so existing Supabase Auth users are updated without sending a password. Password updates now require an explicit `--update-passwords` flag. -- Added regression tests proving: - - Sign-in uses the database-owned `users.key` matched by `authProviderUserId`. - - Sign-in does not create a session from matching email when `authProviderUserId` is stale. - - DEV sync preserves non-seed database user keys. - - DEV sync fails if an expected database `users` row is missing. - - Tags API seeding does not upsert rows into `users`. - - Existing Auth user sync does not send a password by default. - -## DEV Database Verification - -Performed against the current `.env` DEV database target. +- Removed the Alfa toolbox tag/design/configuration helper behavior that upserted seed `users` rows over DEV account keys. +- Changed the DEV identity sync default so existing Supabase Auth users are updated without sending a password. Password updates now require explicit `--update-passwords`. +- Added regression tests for auth id matching, stale authProviderUserId handling, existing database key preservation, missing database rows, Tags API seeding not modifying `users`, and password-safe sync defaults. -Initial audit: +## Current DEV Data Note -- `qbytes.dq@gmail.com` existed in Supabase Auth. -- `qbytes.dq@gmail.com` was missing from the product `users` table. -- The reserved static DEV account keys for User 1 and DavidQ had been overwritten by seed `Creator` / `Forge Bot` rows. +Read-only verification before packaging found: -Repair and sync: +- `qbytes.dq@gmail.com` exists in Supabase Auth. +- `qbytes.dq@gmail.com` is currently missing from the product `users` table. +- Therefore `users.authProviderUserId` is not synced for that account yet. -- Re-applied the approved DEV account DML to restore the missing database identity rows. -- Ran the DEV identity sync with `updatePasswords: false`. -- qbytes sync action: `updated`. -- qbytes password update: `false`. - -Final audit: - -- Supabase Auth match count: `1`. -- Product `users` match count: `1`. -- `users.authProviderUserId` equals Supabase Auth `auth.users.id`: PASS. -- Sign-in smoke through `/api/auth/sign-in`: PASS. -- Session resolved to the database `users.key`: PASS. +The mutating DEV identity sync was not run during this package/push step. Next action after this PR is merged: run the approved DEV identity sync against the current DEV database, then re-test sign-in. ## Files Changed -- `src/dev-runtime/server/local-api-router.mjs` -- `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` -- `dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -- `dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` +- `src/dev-runtime/server/local-api-router.mjs` - updated +- `src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` - updated +- `src/dev-runtime/toolbox-api/alfa-tool-services.mjs` - updated +- `dev/scripts/sync-supabase-dev-creator-identities.mjs` - updated +- `dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` - updated +- `dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` - updated +- `dev/tests/dev-runtime/TagsApiService.test.mjs` - updated ## Validation PASS: +- `git diff --check` +- `npm run validate:canonical-structure` - `node --check src/dev-runtime/server/local-api-router.mjs` - `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` -- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` - `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` - `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs` +- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` +- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` - `node --check dev/tests/dev-runtime/TagsApiService.test.mjs` - `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` - `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` - `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` -- `git diff --check` -- `npm run validate:canonical-structure` diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md index 0b53b8e3a..193faf3d6 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md @@ -1,25 +1,15 @@ -# Requirement Checklist: PR_26179_OWNER_035-dev-auth-user-key-db-authority +# PR_26179_OWNER_035-dev-auth-user-key-db-authority Requirement Checklist -- PASS Do not use hardcoded user keys as runtime identity source. -- PASS Use email only to locate the existing DEV `users` row during DEV identity sync. -- PASS Read `users.key` from the database row during DEV sync. -- PASS Read `auth.users.id` from Supabase Auth during DEV sync. -- PASS Write `auth.users.id` into `users.authProviderUserId`. -- PASS Login/session resolution matches Supabase Auth id to `users.authProviderUserId`. -- PASS Do not create browser-owned auth. -- PASS Do not create fake login. -- PASS Do not add password tables. -- PASS Do not use `displayName` as an identity key. -- PASS Do not hardcode DavidQ/user keys in runtime login resolution. -- PASS Verify qbytes exists in Supabase Auth. -- PASS Verify qbytes exists in the product `users` table. -- PASS Verify `users.authProviderUserId` equals Supabase Auth id. -- PASS If the database row/link is stale, run DEV identity sync. -- PASS Do not change password during existing Auth user sync. -- PASS Do not recreate qbytes Auth user because it already exists. -- PASS Do not use email fallback for session resolution. -- PASS Re-test sign-in after sync. -- PASS Report where `users.key` currently comes from. -- PASS Report where `authProviderUserId` is populated. -- PASS Report whether seed constants are setup-only or runtime identity authority. -- PASS Report exact fix. +Status: PASS + +- Database `users` row is authoritative for `users.key`: PASS +- Runtime sign-in does not use hardcoded DavidQ/user keys: PASS +- Runtime sign-in does not use display name as identity key: PASS +- Supabase Auth id must match `users.authProviderUserId`: PASS +- Email is used only by DEV identity sync to locate existing DB rows: PASS +- DEV identity sync reads DB-owned `users.key`: PASS +- DEV identity sync writes Auth id to `users.authProviderUserId`: PASS +- Toolbox seed helper no longer upserts seed rows into `users`: PASS +- Existing Auth user password is not changed by default sync: PASS +- Required reports under `dev/reports/`: PASS +- Repo-structured ZIP under `dev/workspace/zips/`: PASS diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md index d3aa670c1..751d597ad 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md @@ -1,21 +1,17 @@ -# Validation Lane Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority +# PR_26179_OWNER_035-dev-auth-user-key-db-authority Validation Lane Report -## Lane +Status: PASS -Targeted auth and DEV identity sync validation. +Lane: Owner auth identity authority and DEV sync guardrails. -## Commands +Coverage: -- PASS `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` -- PASS `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` -- PASS `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` -- PASS live DEV identity audit and sign-in smoke for `qbytes.dq@gmail.com` +- Supabase session resolution requires `users.authProviderUserId` to match Auth id. +- Stale `authProviderUserId` does not create a session by email fallback. +- DEV identity sync requires existing product users rows and preserves DB-owned keys. +- Tags API service seeding does not write seed user rows into `users`. +- Canonical repository structure remains valid. -## Coverage +Not run: -- DEV identity sync reads existing database `users.key` values by email. -- DEV identity sync writes real Supabase Auth ids to `users.authProviderUserId`. -- Existing Auth user sync does not update passwords by default. -- Session resolution accepts only `auth.users.id` matched to `users.authProviderUserId`. -- Email-only session resolution is rejected with the existing Creator-safe setup message. -- Tags API setup no longer writes seed rows to the account `users` table. +- Full workspace smoke. Not required for this package/push step; targeted auth and Tags guardrail validation passed. diff --git a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md index bc901b4c1..00b5d5f97 100644 --- a/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md @@ -1,36 +1,18 @@ -# Validation Report: PR_26179_OWNER_035-dev-auth-user-key-db-authority - -## Result - -PASS - -## Targeted Validation - -- `node --check src/dev-runtime/server/local-api-router.mjs`: PASS -- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs`: PASS -- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS -- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS -- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs`: PASS -- `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs`: PASS -- `node --check dev/tests/dev-runtime/TagsApiService.test.mjs`: PASS -- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs`: PASS, 2 tests -- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs`: PASS, 2 tests -- `node --test dev/tests/dev-runtime/TagsApiService.test.mjs`: PASS, 3 tests - -## Live DEV Validation - -- PASS Initial audit detected Supabase Auth user for `qbytes.dq@gmail.com`. -- PASS Initial audit detected missing/stale product `users` row and halted sign-in retest. -- PASS Approved DEV account DML restored the missing identity rows. -- PASS DEV identity sync completed with `updatePasswords: false`. -- PASS Final audit verified `users.authProviderUserId` equals Supabase Auth id. -- PASS `/api/auth/sign-in` returned HTTP 200 and resolved the session to the database `users.key`. - -## Repository Guardrails - -- `git diff --check`: PASS -- `npm run validate:canonical-structure`: PASS - -## Notes - -The targeted tests use a server-side Postgres client fixture to exercise the Local API route without opening a real database connection. Default runtime behavior remains unchanged because the injected client defaults to `null`. +# PR_26179_OWNER_035-dev-auth-user-key-db-authority Validation Report + +Status: PASS + +Validated commands: + +- `git diff --check` - PASS +- `npm run validate:canonical-structure` - PASS +- `node --check src/dev-runtime/server/local-api-router.mjs` - PASS +- `node --check src/dev-runtime/testing/supabase-dev-creator-identity-seed-sync.mjs` - PASS +- `node --check src/dev-runtime/toolbox-api/alfa-tool-services.mjs` - PASS +- `node --check dev/scripts/sync-supabase-dev-creator-identities.mjs` - PASS +- `node --check dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` - PASS +- `node --check dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` - PASS +- `node --check dev/tests/dev-runtime/TagsApiService.test.mjs` - PASS +- `node --test dev/tests/dev-runtime/SupabaseDevCreatorIdentitySeedSync.test.mjs` - PASS, 2 tests +- `node --test --test-name-pattern "Supabase sign-in resolves|Supabase sign-in does not" dev/tests/dev-runtime/SupabaseProviderContractStub.test.mjs` - PASS, 2 tests +- `node --test dev/tests/dev-runtime/TagsApiService.test.mjs` - PASS, 3 tests diff --git a/dev/reports/codex_changed_files.txt b/dev/reports/codex_changed_files.txt index 451d447a33cd5a5ea839cb068a8eb110144669b5..952a0ad68613d82b87fbe1153d77bdda99b517b7 100644 GIT binary patch literal 2004 zcmd6oPfsH;48`9Sr2QyEVG$sXXyLF276eEft7#`KU0Mb@nL@uj@Uznq1r?Tm(pDoS zPA0aW?HA|aeoreZ6f{vwW9Dm>%uyBVW1Xn2pW4=jcC?Fpq;vhzf$y(tQ(MYr+L?0I z=v}Z=qaCo4>5_TsXF80{3G*=E>=cU~o6nqiq>A?OzU0;$JmcYfYN z1uLv`Tw~q(Q$+`{&XFP2?B;Zn965N$F^idN1a_nMwG3^Gz?fzo$ULT-`j`2ozjTk2 zuhb(m{MR#WV{A*OFFfzFJJBy^LgV%KGoMp*0$;(L@LR?VM`HSSAvp#^=~*@GV=!4a zjYv6JiFl7-?|8kw?)?m_ePY<74g-&H?7qABem%YlG+nazT6WVrGI9JZ_*HV+UI=?j z%#YQhBnr#l7(XM;XYXIuo2(DWOW*nQITqOqXGpE4CN)+4D4LR3hE$~F{xlJ`Jl-B- PMwID9^7fS9=L~)XkUVLc literal 3014 zcmcJR!E)L#5Qfk3Q)niaFo|%1goI-z?V$(SOvp@+9$_z_#z<6>Da`QjT}gH@4tAkX zADF@R|7&->-%6Kc$_>$mnM@N(^-L-g3`qjZP{9KK=h?SLgXsH@Z(m-bPrW;e zk15N{jAj}XeSJq`<{F}$Kl1rQe>PZTtSZqcn8*kyP zQ`f;fyr@EGaRU{mGG?hIkt&%PP>$J;Z~qJVdt43%Kh@L?R)k-g87R9a!zxa*#0>(G zFqH&XLJ|ymk(pMoJ2335AZ}8$+wCJaXtE)scT29X1A^kc`+7a;R@~_muRZLL@ZQr_ zJLuuUc)7J}P8ACFBW(5EyCU%YCz4u9ow$<+-cYfU!A{^kOd6vukL4ODN#(NT#;oZ+ zaf#K6D*wA7x3>?zWUg-hhuX`P@Ze4M-H#9~VJ<#mf3^Az=?uDgFz*2r9*sQbt9y@L zkx*Wek8`Gma_@rmB<@2VL42{k?I3LZqIL~ulgc)44KV%wy-(j|UtQns^3B`6ZNgp$ oQ4dJxj`#tPpR;brV9+OAAdmJSt*@?IH>BHtz>Z77-=VGG59i1dhX4Qo diff --git a/dev/reports/codex_review.diff b/dev/reports/codex_review.diff index 91372cfbd4512c0e1eb9f1eff0ac893dec0058df..8540ba2e6e9361cd56a7dd474302f42fd5c2a7cb 100644 GIT binary patch literal 133302 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 literal 85171 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 )=Am z#gvNhOgkHIvrSwxoxOmGX|+Ejquvz_ z4x~ZDX%%1L$Q$W{%K;ae{QbtSw0lBFi2MDm5~Wv2^y7%$vTS$$?Ss9Xy328wECgIl zCv-NSb2!_Ti$!6o-g$;1;0yuJ=OUKb$y`*Ps)%t?>xg9#ajSHt#x7AH#-XXKGAV9y zezss0x$fjDvZ1*SUO+AYg&VSs-7W5RH#glS2HwhD>dJHdY^0g0jmpvf*n$2j=Crff z%{`*ZL~)i;oV0=St6}Dkm`UpK6Jk?#2@#Z6+WN5>QF-6DBPG!rxsm)`V;Px|@mbNq z4<^>O_oTv-cmr^2!_hj*`^Xx<9^ByU0+BPfVUwlH`;(RMs#gXivdzYZWCL&Fe%oBJ z)g@CI1xX<;F9S;HD3t@Q?0k_hN>i>v8nlR@GfP6EDkMt?zuKZpm-~#3rmKWSGTK@- zun0N&C?Z3Jci?c!AQ4%zi9#SUjXhR{9%*!X;?U1uqZa6BM%O;pxJcK&5e*jvio>qC zDuJXlLel{WE?5l{lJu7xVoWpZy8|WeJ$LCqIth20C}8!Fkwv~(mim2y?`LW(PB5eHNO4BhhqN} zY9l9LQtc@DcxhDF%bUU%1&&iTKDnbJG@?@EBxIGMZqpuLu24G^o#ThT;I#js`fBIQY;T4MmBs0&FNv^^lu4 zz)5RT>yx`8H}HDDhr*wItxQ8I#PQ`o7bu9pX7@ny2%$Qk)-Z%8{mgdf1Q1o6rsDuKeMC^x(kIgJSIyliF zF^SYOI(rJ=6}L%1gCd|e=uLWhfZfkqH4Bqvxl;I1bU43_KEvr>-!c;|rhY*wbkdoe zOE|=liSLikP`l*RJl)BBFsq&7-c$FZDjI^wYeo=61wmV#?C{wR7l~YWaRWE7NgH z96ad^hza&t*phm?Pf5^tPnuUcWvL8G=?RAAF|F^t0e z9Sv9kqZGjO6XC;Q|8n?i4R!L?{w zZoR(M?KIl$WT!p7bRN5(KAx)M-Lo4~iMDpJLuIZp{>{29nAY+k`3~_Z?md1Sj(#5B z#+?Kw?on*6$E%HTH#G_ij-Uc0+{*BWC~KE{l4GA_Fh(_0QU`KRv`&>7$jUMU)QN&S z3#I#z=6WM#+#ue~GPJr@YAZ`EkyQz^usG^FRRdlHKzSla_m9)FqgUs0N))GEz4Xq6 zzgEDV*9jb%lT<}mY)cOznSKc=5V@E<&z?32UvhSG*UJ?F*DgcAF52ZRK7-w%ix7p5 zaODs%;KylsLHWn_H7^j#jL<&MJ$D*1iyGLD`)^;K&BBd&BhrZ)X53jN?~G$jg7m8p z9rNKz<|K#F3?<9PR23}*NGtzD@y8RrHMzsSl%mNZx=(auy%w>ihGzFb5`wDpC^y1a w;x1zh@q3ifKIE9M!n(S`e$J7>s=Rs5KZPUAXckW3!@!`_v^jamU)P@eKh?5KH2?qr