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..51b5fb979 --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_branch_validation.md @@ -0,0 +1,10 @@ +# PR_26179_OWNER_035-dev-auth-user-key-db-authority Branch Validation + +Status: PASS + +- Current branch: `PR_26179_OWNER_035-dev-auth-user-key-db-authority` +- 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 new file mode 100644 index 000000000..eef115426 --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_manual_validation_notes.md @@ -0,0 +1,17 @@ +# PR_26179_OWNER_035-dev-auth-user-key-db-authority Manual Validation Notes + +Status: READY FOR OWNER VALIDATION + +Manual checks after merge: + +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`. + +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 new file mode 100644 index 000000000..5548a0d22 --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_report.md @@ -0,0 +1,81 @@ +# 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 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` comes from + +- 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 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`. + +### Runtime resolution + +- 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 + +- 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 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. + +## Current DEV Data Note + +Read-only verification before packaging found: + +- `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. + +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` - 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 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` 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..193faf3d6 --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_requirement_checklist.md @@ -0,0 +1,15 @@ +# PR_26179_OWNER_035-dev-auth-user-key-db-authority Requirement Checklist + +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 new file mode 100644 index 000000000..751d597ad --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_lane_report.md @@ -0,0 +1,17 @@ +# PR_26179_OWNER_035-dev-auth-user-key-db-authority Validation Lane Report + +Status: PASS + +Lane: Owner auth identity authority and DEV sync guardrails. + +Coverage: + +- 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. + +Not run: + +- 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 new file mode 100644 index 000000000..00b5d5f97 --- /dev/null +++ b/dev/reports/PR_26179_OWNER_035-dev-auth-user-key-db-authority_validation_report.md @@ -0,0 +1,18 @@ +# 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 9883c823c..952a0ad68 100644 Binary files a/dev/reports/codex_changed_files.txt and b/dev/reports/codex_changed_files.txt differ diff --git a/dev/reports/codex_review.diff b/dev/reports/codex_review.diff index d9c691cd4..8540ba2e6 100644 Binary files a/dev/reports/codex_review.diff and b/dev/reports/codex_review.diff differ 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..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", 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); @@ -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(); + 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/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 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..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 @@ -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) { @@ -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(); + 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 +543,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 +559,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 +572,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 +607,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), @@ -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(); + 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); 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); 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 +722,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 +750,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/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,