From bbe46785169630e848f995cc693783f34f63373f Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:39:42 +0300 Subject: [PATCH 1/2] fix(migrate): log why Studio's own provision answer failed validation before it answers 502 --- server/utils/migrate-provision.ts | 20 ++++++++++++++------ tests/unit/migrate-provision.test.ts | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/server/utils/migrate-provision.ts b/server/utils/migrate-provision.ts index 770d180b..b6e6b75d 100644 --- a/server/utils/migrate-provision.ts +++ b/server/utils/migrate-provision.ts @@ -30,6 +30,18 @@ import { migrateExportOrigins } from './migrate-comments-export' /** A checkout is reused only while it has at least this long left to be paid. */ const MIN_REMAINING_MS = 5 * 60 * 1000 +/** + * Fail closed on our own answer, and say why: the 502 carries no detail to the caller, so the validator's errors + * (never the response, which holds a checkout address) go to the log with the order they belong to. + */ +function answerOrFail(response: MigrateProvisionResponse, orderId: string, options: Parameters[1]): MigrateProvisionResponse { + const checked = validateMigrateProvisionResponse(response, options) + if (checked.ok) return response + // eslint-disable-next-line no-console -- ops visibility: a refused own answer is otherwise silent + console.error('[migrate-provision] own response failed validation:', { orderId, state: response.state, workspaceSlug: response.workspace_slug, errors: checked.errors }) + return fail(502, 'billing.provider_unavailable') +} + function fail(statusCode: number, key: string): never { throw createError({ statusCode, message: errorMessage(key) }) } @@ -113,9 +125,7 @@ async function provisionCovered(claim: MigrateStudioClaimV2, userId: string): Pr plan: claim.plan, workspace_slug: target.slug, } - if (!validateMigrateProvisionResponse(response, { quoted_total_cents: claim.billing.quoted_total_cents }).ok) - fail(502, 'billing.provider_unavailable') - return response + return answerOrFail(response, claim.order_id, { quoted_total_cents: claim.billing.quoted_total_cents }) } export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: Date = new Date()): Promise { @@ -224,7 +234,5 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D checkout_expires_at: Math.floor((expiresAt as Date).getTime() / 1000), } // Fail closed on our own answer: Migrate redirects a browser to it. - if (!validateMigrateProvisionResponse(response, { quoted_total_cents: quoted, now: Math.floor(now.getTime() / 1000) }).ok) - fail(502, 'billing.provider_unavailable') - return response + return answerOrFail(response, claim.order_id, { quoted_total_cents: quoted, now: Math.floor(now.getTime() / 1000) }) } diff --git a/tests/unit/migrate-provision.test.ts b/tests/unit/migrate-provision.test.ts index 6a88e36a..9464f1c9 100644 --- a/tests/unit/migrate-provision.test.ts +++ b/tests/unit/migrate-provision.test.ts @@ -150,6 +150,17 @@ describe('provisionMigrateBundle', () => { db.markMigrateGrantRedeemed = vi.fn().mockResolvedValue(undefined) }) + it('refuses with 502 and logs why when the workspace slug is not one Migrate accepts', async () => { + // handle_new_user() lowercases after replacing [^a-z0-9-], so an uppercase GitHub name ("ABB65") becomes "---65-1a2b3c4d". + coveringWorkspace.mockResolvedValue({ id: 'ws-paid', slug: '---65-1a2b3c4d' }) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(await refused(covered())).toEqual({ status: 502, key: 'billing.provider_unavailable' }) + expect(log).toHaveBeenCalledWith('[migrate-provision] own response failed validation:', expect.objectContaining({ + orderId: 'ord_1', state: 'redeemed', workspaceSlug: '---65-1a2b3c4d', errors: ['workspace_slug: invalid'], + })) + log.mockRestore() + }) + it('ties the grant to the plan\'s workspace and answers redeemed: no checkout, no Polar call, Studio fee $0', async () => { expect(await run(covered())).toEqual({ grant_id: 'grant-1', state: 'redeemed', plan: 'pro', workspace_slug: 'agency' }) expect(coveringWorkspace).toHaveBeenCalledWith('user-1', 'pro') @@ -302,7 +313,10 @@ describe('provisionMigrateBundle', () => { it('never hands Migrate a checkout address that is not Polar\'s', async () => { payment.createBundleCheckout.mockResolvedValue({ url: 'https://evil.example/checkout/c_1', sessionId: 'co_1', expiresAt: '2026-10-10T13:00:00.000Z', targetProductId: 'prod_pro_y' }) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(await refused()).toEqual({ status: 502, key: 'billing.provider_unavailable' }) + expect(log).toHaveBeenCalledWith('[migrate-provision] own response failed validation:', expect.objectContaining({ errors: ['checkout_url: not a Polar checkout address'] })) + log.mockRestore() }) it('takes the personal workspace over the first one it finds', async () => { From 3f94210b36380bc486bf9b9805240500ad59f7f9 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:50:23 +0300 Subject: [PATCH 2/2] fix(db): workspace slugs lowercase before replacing, repair invalid ones (047) --- CHANGELOG.md | 5 ++ .../migrations/047_workspace_slug_repair.sql | 88 +++++++++++++++++++ .../contract/workspace-slug.contract.test.ts | 56 ++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 supabase/migrations/047_workspace_slug_repair.sql create mode 100644 tests/contract/workspace-slug.contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 368bffad..6dfce9f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### ⚠️ Upgrade notes +**Migration 047: workspace slugs repaired.** +`047_workspace_slug_repair.sql` replaces `handle_new_user()` (it lowercased after replacing characters, so a GitHub name like `ABB65` gave the slug `---65-1a2b3c4d`) and rewrites only the workspace slugs that fail `^[a-z0-9][a-z0-9-]{0,62}$`; valid slugs are untouched and a repeat run changes nothing. Migrate's provision refused such an account with a 502. A repaired workspace changes its `/w/` address; nothing else stores a slug (grants, billing, CDN, MCP and CLI use ids). The migration runs in the pre-deploy step; the old image keeps working against it. + +**Fix: provision logs why its own answer failed validation.** A refused answer now logs the order, the slug and the validator's errors before the 502. + **Policy change: emptying a field makes a content write `bulk_content`.** A field counts as emptied when it had a value before the change and is empty after it (`''`, `null`, `[]`, `{}` or removed), and that includes sub-fields of objects and fields inside lists of objects. It is read from the branch's before/after, so the save and the Merge button give the same answer. Under the default policy nothing changes: `bulk_content` asks for the same single review. A policy that sets `low_risk_content` to `auto` now holds these writes, and the panel Merge holds them too. A blank optional sub-field that was already empty no longer lifts a save. diff --git a/supabase/migrations/047_workspace_slug_repair.sql b/supabase/migrations/047_workspace_slug_repair.sql new file mode 100644 index 00000000..db9c8db5 --- /dev/null +++ b/supabase/migrations/047_workspace_slug_repair.sql @@ -0,0 +1,88 @@ +-- 047: workspace slugs Migrate (and every other consumer) can use. +-- +-- handle_new_user() replaced [^a-z0-9-] with "-" BEFORE lowercasing, so a GitHub name with capitals ("ABB65") became +-- "---65-1a2b3c4d": a slug that starts with a hyphen. @contentrain/types validates the workspace slug Studio answers +-- Migrate with (^[a-z0-9][a-z0-9-]{0,62}$), so POST /api/migrate/provision refused such an account with a 502. +-- +-- 1. The trigger lowercases first, collapses runs of other characters to one hyphen, trims hyphens, and falls back to "user". +-- 2. Repair: only workspaces whose slug fails the pattern are rewritten (same normalisation); a valid slug is never touched. +-- A rewritten slug that collides with another gets the workspace id's first 8 characters (then a counter) appended. +-- Nothing else stores a slug: grants, billing, CDN, MCP and CLI use workspace ids. What does change is the address +-- of that one workspace (/w/) and any link to it already sent by email. +-- Re-running is a no-op: after the first run every slug matches. + +CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO '' + AS $$ +DECLARE + ws_id uuid; + ws_slug text; +BEGIN + INSERT INTO public.profiles (id, display_name, email, avatar_url) + VALUES ( + new.id, + coalesce( + new.raw_user_meta_data ->> 'full_name', + new.raw_user_meta_data ->> 'name', + split_part(new.email, '@', 1) + ), + new.email, + new.raw_user_meta_data ->> 'avatar_url' + ); + + -- Lowercase BEFORE replacing: replacing first turned every capital of "ABB65" into "-" and left a slug that starts + -- with a hyphen. Runs of anything else collapse to one hyphen; an empty result falls back to "user". + ws_slug := coalesce(nullif(trim(BOTH '-' FROM regexp_replace(lower( + coalesce( + new.raw_user_meta_data ->> 'user_name', + new.raw_user_meta_data ->> 'preferred_username', + split_part(new.email, '@', 1) + ) + ), '[^a-z0-9]+', '-', 'g')), ''), 'user'); + ws_slug := trim(BOTH '-' FROM left(ws_slug, 40)) || '-' || substr(new.id::text, 1, 8); + + ws_id := gen_random_uuid(); + INSERT INTO public.workspaces (id, name, slug, type, owner_id, plan) + VALUES ( + ws_id, + coalesce( + new.raw_user_meta_data ->> 'full_name', + new.raw_user_meta_data ->> 'name', + split_part(new.email, '@', 1) + ) || '''s Workspace', + ws_slug, + 'primary', + new.id, + 'free' + ); + + RETURN new; +END; +$$; + +DO $repair$ +DECLARE + w record; + base text; + candidate text; + n integer; +BEGIN + FOR w IN + SELECT id, slug FROM public.workspaces WHERE slug !~ '^[a-z0-9][a-z0-9-]{0,62}$' ORDER BY id + LOOP + base := coalesce(nullif(trim(BOTH '-' FROM regexp_replace(lower(w.slug), '[^a-z0-9]+', '-', 'g')), ''), 'workspace'); + base := coalesce(nullif(trim(BOTH '-' FROM left(base, 54)), ''), 'workspace'); + candidate := base; + IF EXISTS (SELECT 1 FROM public.workspaces WHERE slug = candidate AND id <> w.id) THEN + candidate := base || '-' || substr(w.id::text, 1, 8); + n := 1; + WHILE EXISTS (SELECT 1 FROM public.workspaces WHERE slug = candidate AND id <> w.id) LOOP + n := n + 1; + candidate := base || '-' || substr(w.id::text, 1, 8) || '-' || n; + END LOOP; + END IF; + UPDATE public.workspaces SET slug = candidate WHERE id = w.id; + END LOOP; +END +$repair$; diff --git a/tests/contract/workspace-slug.contract.test.ts b/tests/contract/workspace-slug.contract.test.ts new file mode 100644 index 00000000..d5b82ed1 --- /dev/null +++ b/tests/contract/workspace-slug.contract.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs' +import { afterAll, describe, expect, it } from 'vitest' +import { deleteSeededUser, getDb, seedUser, sql } from './helpers' + +// What @contentrain/types accepts as the workspace slug Studio answers Migrate with. +const SLUG = /^[a-z0-9][a-z0-9-]{0,62}$/ + +describe('workspace slugs (contract)', () => { + const users: string[] = [] + const created: string[] = [] + afterAll(async () => { + for (const id of created) await sql`DELETE FROM public.workspaces WHERE id = ${id}`.execute(getDb()) + for (const id of users) await deleteSeededUser(id) + }) + + const slugOf = async (workspaceId: string) => + (await sql<{ slug: string }>`SELECT slug FROM public.workspaces WHERE id = ${workspaceId}`.execute(getDb())).rows[0]!.slug + + it.each([ + ['capitals (a GitHub name like ABB65)', 'ABB65', /^abb65-[0-9a-f]{8}$/], + ['dots and underscores', 'Jane.Doe_X', /^jane-doe-x-[0-9a-f]{8}$/], + ['nothing usable', '__', /^user-[0-9a-f]{8}$/], + ])('a new user whose name has %s gets a valid primary workspace slug', async (_label, userName, expected) => { + const user = await seedUser('slug', { user_name: userName }) + users.push(user.userId) + const slug = await slugOf(user.workspaceId) + expect(slug).toMatch(SLUG) + expect(slug).toMatch(expected) + }) + + it('the repair rewrites only invalid slugs, keeps them unique, and a second run changes nothing', async () => { + const owner = await seedUser('slug-repair') + users.push(owner.userId) + const valid = `keep-${owner.userId.slice(0, 8)}` + const bad = ['---65-1a2b3c4d', 'ABC_def', '!!!', 'a'.repeat(70)] + for (const slug of [valid, ...bad]) { + const row = await sql<{ id: string }>` + INSERT INTO public.workspaces (name, slug, type, owner_id, plan) + VALUES ('slug repair', ${slug}, 'secondary', ${owner.userId}, 'free') RETURNING id`.execute(getDb()) + created.push(row.rows[0]!.id) + } + const migration = readFileSync(new URL('../../supabase/migrations/047_workspace_slug_repair.sql', import.meta.url), 'utf8') + const run = async () => { + await sql.raw(migration).execute(getDb()) + } + const slugs = async () => (await sql<{ slug: string }>`SELECT slug FROM public.workspaces WHERE id = ANY(${created})`.execute(getDb())).rows.map(r => r.slug) + + await run() + const after = await slugs() + expect(after).toContain(valid) + expect(after.every(s => SLUG.test(s))).toBe(true) + expect(new Set(after).size).toBe(after.length) + await run() + expect((await slugs()).sort()).toEqual(after.sort()) + }) +})