From 7e8cbc2a2b15ad503638304167a18daa89f26b07 Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 03:20:46 +0300 Subject: [PATCH 1/4] fix(e2e): seed provisioned personal org so the sign-in shell renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authenticated shell renders behind WorkspaceProvisioningGate, which only mounts the app layout once the user has a personal org. The e2e seeds a bare admin user and relied on async login self-heal provisioning to create that org within the test window; it never appeared, so the gate stayed on the 'Creating your workspace…' card and the .app-layout/.top-bar/.main-content (and .app-grid-button) the specs assert never rendered. Seed the personal org + active owner membership directly (mirroring ensurePersonalOrg) so the gate opens immediately and the test is deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e.yml | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 88f1b9ae1..f3a9fd09b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -74,11 +74,19 @@ jobs: done echo "backend failed to start"; cat /tmp/backend.log; exit 1 - - name: Create admin user + # Seed a FULLY-PROVISIONED admin: user + personal org + active owner + # membership. The authenticated shell sits behind WorkspaceProvisioningGate + # (frontend/src/components/WorkspaceProvisioningGate.tsx), which only renders + # the app layout once the user has a personal org. Real logins provision that + # org asynchronously (fire-and-forget self-heal on login); seeding it directly + # makes the gate open immediately, so the e2e is deterministic instead of + # racing a background job (and tolerant of Permit/Kafka being absent in CI). + - name: Create admin user (provisioned with personal org) working-directory: backend run: | node -e ' const bcrypt = require("bcrypt"); + const crypto = require("crypto"); const knex = require("knex")({ client: "pg", connection: { host: process.env.DB_HOST, port: +process.env.DB_PORT, @@ -86,10 +94,32 @@ jobs: }); (async () => { const password_hash = await bcrypt.hash("admin123", 10); - await knex("users").insert({ email: "admin@fuzefront.dev", password_hash, + const [u] = await knex("users").insert({ email: "admin@fuzefront.dev", password_hash, first_name: "Admin", last_name: "User", roles: JSON.stringify(["admin","user"]) }) - .onConflict("email").merge({ password_hash }); - console.log("admin user ready"); + .onConflict("email").merge({ password_hash }).returning("id"); + const userId = u.id || u; + console.log("admin user ready", userId); + + // Mirror ensurePersonalOrg(): one active personal org owned by the user. + const existing = await knex("organizations") + .where({ owner_id: userId, type: "personal" }).first(); + if (!existing) { + const orgId = crypto.randomUUID(); + await knex.transaction(async trx => { + await trx("organizations").insert({ id: orgId, name: "Personal", + slug: "personal-" + userId, parent_id: null, owner_id: userId, + type: "personal", settings: JSON.stringify({}), + metadata: JSON.stringify({ personal: true }), is_active: true, + provisioning_state: "active" }); + await trx("organization_memberships").insert({ id: crypto.randomUUID(), + user_id: userId, organization_id: orgId, role: "owner", status: "active", + joined_at: new Date(), permissions: JSON.stringify({}), + metadata: JSON.stringify({}) }); + }); + console.log("personal org provisioned", orgId); + } else { + console.log("personal org already present", existing.id); + } await knex.destroy(); })().catch(e => { console.error(e); process.exit(1); }); ' From b6b475d376b57805baf22366384ae0c61b94dd6b Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 03:32:05 +0300 Subject: [PATCH 2/4] fix(api): stop 500 on GET /organizations from double-parsing jsonb settings/metadata are jsonb columns; the pg driver returns them already parsed as objects, so JSON.parse(org.settings) throws ('[object Object]' is not valid JSON) and the route 500s as soon as any org row is returned. That 500 also breaks WorkspaceProvisioningGate: its getOrganizations() poll rejects, the gate flips to its error state, and the authenticated shell never mounts. Add parseJsonColumn() that passes objects through and only JSON.parse()s strings (sqlite/json-column paths), falling back to {} on invalid input. Apply it to all four settings/metadata reads in the organizations routes. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/organizations.ts | 34 ++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/backend/src/routes/organizations.ts b/backend/src/routes/organizations.ts index ff97a7f89..2ea163f03 100644 --- a/backend/src/routes/organizations.ts +++ b/backend/src/routes/organizations.ts @@ -11,6 +11,24 @@ import { reconcileOrganizationProvisioning } from '../services/organizationProvi const router = express.Router() +// `settings`/`metadata` are jsonb columns. The `pg` driver already parses jsonb +// into JS objects on read, so calling JSON.parse() on them throws +// ("[object Object]" is not valid JSON) and 500s the route. Older code paths / +// other drivers (e.g. sqlite) may hand back a string instead, so accept both: +// pass objects through, parse strings, and fall back to {} on anything invalid. +function parseJsonColumn(value: unknown): Record { + if (value == null) return {} + if (typeof value === 'object') return value as Record + if (typeof value === 'string') { + try { + return JSON.parse(value) + } catch { + return {} + } + } + return {} +} + // Input validation helpers function validateOrganizationInput(data: any) { const errors: string[] = [] @@ -164,8 +182,8 @@ router.post('/', authenticateToken, async (req: any, res) => { parent_id: newOrganization.parent_id, owner_id: newOrganization.owner_id, type: newOrganization.type, - settings: JSON.parse(newOrganization.settings || '{}'), - metadata: JSON.parse(newOrganization.metadata || '{}'), + settings: parseJsonColumn(newOrganization.settings), + metadata: parseJsonColumn(newOrganization.metadata), is_active: newOrganization.is_active, created_at: newOrganization.created_at, updated_at: newOrganization.updated_at, @@ -298,8 +316,8 @@ router.get('/', authenticateToken, async (req: any, res) => { parent_id: org.parent_id, owner_id: org.owner_id, type: org.type, - settings: JSON.parse(org.settings || '{}'), - metadata: JSON.parse(org.metadata || '{}'), + settings: parseJsonColumn(org.settings), + metadata: parseJsonColumn(org.metadata), is_active: org.is_active, created_at: org.created_at, updated_at: org.updated_at, @@ -374,8 +392,8 @@ router.get( parent_id: organization.parent_id, owner_id: organization.owner_id, type: organization.type, - settings: JSON.parse(organization.settings || '{}'), - metadata: JSON.parse(organization.metadata || '{}'), + settings: parseJsonColumn(organization.settings), + metadata: parseJsonColumn(organization.metadata), is_active: organization.is_active, created_at: organization.created_at, updated_at: organization.updated_at, @@ -459,8 +477,8 @@ router.put( parent_id: updatedOrganization.parent_id, owner_id: updatedOrganization.owner_id, type: updatedOrganization.type, - settings: JSON.parse(updatedOrganization.settings || '{}'), - metadata: JSON.parse(updatedOrganization.metadata || '{}'), + settings: parseJsonColumn(updatedOrganization.settings), + metadata: parseJsonColumn(updatedOrganization.metadata), is_active: updatedOrganization.is_active, created_at: updatedOrganization.created_at, updated_at: updatedOrganization.updated_at, From bcf2937c8d60ebc76dacd26e7e08f0c98162bc7a Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 03:36:15 +0300 Subject: [PATCH 3/4] style(e2e): use _ for unused seq loop var (actionlint SC2034 clean) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f3a9fd09b..2f265c2f2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -68,7 +68,7 @@ jobs: cd backend node dist/index.js > /tmp/backend.log 2>&1 & echo "Waiting for backend /health..." - for i in $(seq 1 60); do + for _ in $(seq 1 60); do if curl -fsS http://localhost:3001/health >/dev/null 2>&1; then echo "backend up"; exit 0; fi sleep 2 done @@ -131,7 +131,7 @@ jobs: npm ci --include=dev # need vite/@playwright (devDeps) despite NODE_ENV=production npm run build npx vite preview --port 4173 --host 127.0.0.1 > /tmp/frontend.log 2>&1 & - for i in $(seq 1 30); do + for _ in $(seq 1 30); do if curl -fsS http://localhost:4173 >/dev/null 2>&1; then echo "frontend up"; exit 0; fi sleep 2 done @@ -144,7 +144,7 @@ jobs: npm install --include=dev # need vite (devDep) despite NODE_ENV=production VITE_HUB_API_URL=http://localhost:3001 VITE_PUBLIC_URL=http://localhost:4174 npm run build npx vite preview --port 4174 --host 127.0.0.1 > /tmp/clock.log 2>&1 & - for i in $(seq 1 30); do + for _ in $(seq 1 30); do if curl -fsS http://localhost:4174/assets/remoteEntry.js >/dev/null 2>&1; then echo "clock-app up"; break; fi sleep 2 done From 39421909303820f097361517cd68ad98ad0dad4d Mon Sep 17 00:00:00 2001 From: AppHub Developer Date: Mon, 22 Jun 2026 03:47:12 +0300 Subject: [PATCH 4/4] fix(api): GET /organizations hid all active orgs (boolean default vs string compare) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The is_active query param defaults to the boolean `true` when not sent, but the filter compared it with `is_active === 'true'` — true === 'true' is false, so with no param the route filtered WHERE is_active = false and returned ZERO active orgs. The frontend WorkspaceProvisioningGate calls GET /organizations with no params, so it never saw the user's (active) personal org and stayed stuck on the 'Creating your workspace…' card — the actual reason the sign-in e2e never reached the app shell. Coerce both shapes: boolean true and string 'true' mean active. Verified against Postgres: old filter returns 0 rows / no personal org; new filter returns the personal org. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/organizations.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/organizations.ts b/backend/src/routes/organizations.ts index 2ea163f03..66c3141fb 100644 --- a/backend/src/routes/organizations.ts +++ b/backend/src/routes/organizations.ts @@ -283,8 +283,15 @@ router.get('/', authenticateToken, async (req: any, res) => { } } + // `is_active` defaults to the boolean `true` (when no query param is sent), + // but arrives as a string when it IS sent. Comparing `true === 'true'` + // yields false, which previously filtered to is_active=false and hid every + // active org (including the user's personal org) — leaving the frontend + // WorkspaceProvisioningGate stuck on "Creating your workspace…". Coerce both + // shapes: treat boolean true and the string 'true' as active. if (is_active !== undefined) { - query = query.where('organizations.is_active', is_active === 'true') + const wantActive = is_active === true || is_active === 'true' + query = query.where('organizations.is_active', wantActive) } if (search) {