Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ For subscribed workspaces these three meters now reset with the billing period,
**Fix: yearly overage lock copy.**
A workspace on a yearly plan that tries to enable overage now sees that yearly plans do not bill overage, instead of the trial/not-in-subscription message.

**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/<slug>` 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.

Expand Down
20 changes: 14 additions & 6 deletions server/utils/migrate-provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof validateMigrateProvisionResponse>[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) })
}
Expand Down Expand Up @@ -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<MigrateProvisionResponse> {
Expand Down Expand Up @@ -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) })
}
88 changes: 88 additions & 0 deletions supabase/migrations/047_workspace_slug_repair.sql
Original file line number Diff line number Diff line change
@@ -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/<slug>) 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$;
56 changes: 56 additions & 0 deletions tests/contract/workspace-slug.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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())
})
})
14 changes: 14 additions & 0 deletions tests/unit/migrate-provision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading