From 46b467d84792d99b5c565e3b443c0964ff4eaba7 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 19:22:39 -0800 Subject: [PATCH] feat(cloud): workspace numbers (W-123), org slug links and synced sidebar prefs on the control plane (G13, G14) --- .../0051_workspace_numbers_prefs.sql | 40 ++++ cloud/apps/sandbox-control/src/index.ts | 37 +++- cloud/apps/sandbox-control/src/org-slugs.ts | 123 ++++++++++++ cloud/apps/sandbox-control/src/store.ts | 36 ++-- .../sandbox-control/src/workspace-prefs.ts | 86 +++++++++ cloud/apps/sandbox-control/src/workspaces.ts | 59 ++++-- .../test/workspace-numbers-prefs.test.ts | 175 ++++++++++++++++++ 7 files changed, 529 insertions(+), 27 deletions(-) create mode 100644 cloud/apps/sandbox-control/migrations/0051_workspace_numbers_prefs.sql create mode 100644 cloud/apps/sandbox-control/src/org-slugs.ts create mode 100644 cloud/apps/sandbox-control/src/workspace-prefs.ts create mode 100644 cloud/apps/sandbox-control/test/workspace-numbers-prefs.test.ts diff --git a/cloud/apps/sandbox-control/migrations/0051_workspace_numbers_prefs.sql b/cloud/apps/sandbox-control/migrations/0051_workspace_numbers_prefs.sql new file mode 100644 index 00000000000..329a96abb2d --- /dev/null +++ b/cloud/apps/sandbox-control/migrations/0051_workspace_numbers_prefs.sql @@ -0,0 +1,40 @@ +-- G14: every cloud workspace gets a short number in its account (W-123), allocated from a per-account +-- counter so a number is never reused, even after the row is destroyed. Orgs get a URL slug for links +-- (orca://cloud//W-123). G13: each member's sidebar prefs for cloud workspaces (pins, their +-- order, and the order of sections) live on the account so every device shows the same sidebar. +ALTER TABLE sandboxes ADD COLUMN number INTEGER; + +-- Backfill: existing rows numbered by creation order within their account. +UPDATE sandboxes SET number = ( + SELECT COUNT(*) FROM sandboxes AS earlier + WHERE earlier.account_id = sandboxes.account_id + AND (earlier.created_at < sandboxes.created_at OR (earlier.created_at = sandboxes.created_at AND earlier.id <= sandboxes.id)) +); + +CREATE UNIQUE INDEX sandboxes_account_number ON sandboxes (account_id, number) WHERE number IS NOT NULL; + +CREATE TABLE org_workspace_counters ( + account_id TEXT PRIMARY KEY, + last_number INTEGER NOT NULL +); + +INSERT INTO org_workspace_counters (account_id, last_number) + SELECT account_id, MAX(number) FROM sandboxes GROUP BY account_id; + +CREATE TABLE org_slugs ( + account_id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + created_by TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE workspace_prefs ( + account_id TEXT NOT NULL, + user_id TEXT NOT NULL, + document TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + updated_by_device TEXT, + PRIMARY KEY (account_id, user_id) +); diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 8d833367e3e..6723096d443 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -24,7 +24,8 @@ // POST /v1/device-sessions | GET | DELETE /:id per-phone access (see device-sessions.ts) // Phones sign their own EdDSA tokens (device-sessions.ts) and act as their user, except that they // cannot enroll devices, manage secrets or connect GitHub. -// /v1/team/workspaces[/:id[/presence|wake|lease|grants[/:grantId]]], /v1/team/links/resolve, /v1/team/prompts +// /v1/team/workspaces[/:id[/presence|wake|lease|grants[/:grantId]]], /v1/team/links/resolve, /v1/team/prompts, +// /v1/team/prefs (src/workspace-prefs.ts), /v1/team/org (src/org-slugs.ts) // team workspace registry, presence, per-device grants (src/workspaces.ts, src/grants.ts) // POST /v1/team/workspaces/:id/wake?wait=30s: any member who can open it wakes it (single-flight, // billed to the account, counted on the owner's seat like every wake) -> {state, endpoint: null, …} @@ -186,7 +187,10 @@ import { userAuthorizeUrl } from './github-user-tokens' import { MEMBER_GIT_WINDOW_MS, memberGitRoutes, memberSentWithin, recordMemberSend } from './member-git' +import { getWorkspacePrefs, putWorkspacePrefs } from './workspace-prefs' +import { getOrgSlug, putOrgSlug } from './org-slugs' import { + LinkOtherOrgError, PatchSchema, PresenceSchema, WORKSPACE_ERROR_STATUS, @@ -1480,7 +1484,9 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {}) url.pathname === '/v1/team/workspaces' || url.pathname.startsWith('/v1/team/workspaces/') || url.pathname === '/v1/team/links/resolve' || - url.pathname === '/v1/team/prompts' + url.pathname === '/v1/team/prompts' || + url.pathname === '/v1/team/prefs' || + url.pathname === '/v1/team/org' ) { const caller = await authenticateDesktop(env, request, (deps.now ?? Date.now)()) if (!caller) { @@ -1818,7 +1824,32 @@ async function workspaceRoute(request: Request, url: URL, env: Env, deps: Deps, // Labels for teammates' lists; a failure here must never fail the request itself. await rememberCallerName(db, caller, now).catch((error: unknown) => console.warn('member_name_write_failed', String(error))) if (url.pathname === '/v1/team/links/resolve' && request.method === 'GET') { - return json(await resolveLink(db, caller, url, now)) + try { + return json(await resolveLink(db, caller, url, now)) + } catch (error) { + if (error instanceof LinkOtherOrgError) { + return json({ error: 'not_found', reason: 'other_org', orgId: error.orgId }, 404) + } + throw error + } + } + if (url.pathname === '/v1/team/prefs') { + if (request.method === 'GET') { + return json(await getWorkspacePrefs(db, caller)) + } + if (request.method === 'PUT') { + return json(await putWorkspacePrefs(db, caller, await readJson(request), now)) + } + return fail('not_found', 404) + } + if (url.pathname === '/v1/team/org') { + if (request.method === 'GET') { + return json(await getOrgSlug(db, caller, url, now)) + } + if (request.method === 'PUT') { + return json(await putOrgSlug(db, caller, await readJson(request), now)) + } + return fail('not_found', 404) } if (url.pathname === '/v1/team/prompts') { if (request.method === 'GET') { diff --git a/cloud/apps/sandbox-control/src/org-slugs.ts b/cloud/apps/sandbox-control/src/org-slugs.ts new file mode 100644 index 00000000000..90de39782e2 --- /dev/null +++ b/cloud/apps/sandbox-control/src/org-slugs.ts @@ -0,0 +1,123 @@ +// Org URL slugs for workspace links (orca://cloud//W-123). +// GET /v1/team/org?name= -> {slug, orgId}; the first call names it from `name` +// PUT /v1/team/org {slug} org owner/admin renames it (409 when another org has it) +// Auth owns orgs and knows their names; this Worker only sees ids. So the first member to ask +// proposes a slug from the name their client shows, and the Worker makes it unique. Personal +// accounts have no slug: their links keep the sandbox id form (orca://cloud/w/). +import { z } from 'zod' +import type { SandboxCaller } from './auth' +import { WorkspaceError, isOrgAdmin } from './workspaces' + +/** Path words a slug can't take: they'd read as a different link form. */ +const RESERVED = new Set(['w', 'c', 'me', 'api', 'app', 'new', 'org', 'team', 'settings', 'cloud', 'orca', 'admin', 'help']) + +export const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/ + +export function isValidSlug(value: string): boolean { + return SLUG.test(value) && !value.includes('--') && !RESERVED.has(value) +} + +/** "Acme Labs, Inc." → "acme-labs-inc"; falls back to "team" when nothing usable is left. */ +export function slugify(name: string): string { + const base = name + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32) + .replace(/-+$/g, '') + return base.length >= 2 && !RESERVED.has(base) ? base : base ? `${base}-team` : 'team' +} + +type Row = { account_id: string; slug: string } + +/** `org:abc` / `dev:org:abc` → `abc` (the id the desktop's org list uses). */ +export function orgIdOf(accountId: string): string | null { + const match = /^(?:dev:)?org:(.+)$/.exec(accountId) + return match ? match[1]! : null +} + +export async function slugForAccount(db: D1Database, accountId: string): Promise { + const row = await db.prepare('SELECT slug FROM org_slugs WHERE account_id = ?').bind(accountId).first<{ slug: string }>() + return row?.slug ?? null +} + +export async function accountForSlug(db: D1Database, slug: string): Promise { + if (!isValidSlug(slug)) { + return null + } + const row = await db.prepare('SELECT account_id FROM org_slugs WHERE slug = ?').bind(slug).first() + return row?.account_id ?? null +} + +const NameQuery = z.string().trim().min(1).max(120) + +export async function getOrgSlug(db: D1Database, caller: SandboxCaller, url: URL, now: number) { + const orgId = caller.orgId ? orgIdOf(caller.accountId) : null + if (!orgId) { + return { slug: null, orgId: null } + } + const existing = await slugForAccount(db, caller.accountId) + if (existing) { + return { slug: existing, orgId } + } + const name = NameQuery.safeParse(url.searchParams.get('name') ?? '') + if (!name.success) { + // Nothing to name it from yet; the next client that knows the org's name will. + return { slug: null, orgId } + } + const base = slugify(name.data) + for (let attempt = 0; attempt < 20; attempt += 1) { + const candidate = attempt === 0 ? base : `${base.slice(0, 32)}-${attempt + 1}` + const result = await db + .prepare('INSERT OR IGNORE INTO org_slugs (account_id, slug, created_at, created_by, updated_at) VALUES (?, ?, ?, ?, ?)') + .bind(caller.accountId, candidate, now, caller.userId, now) + .run() + if (result.meta.changes === 1) { + return { slug: candidate, orgId } + } + // Either another org holds this slug, or a teammate just named ours (then use theirs). + const raced = await slugForAccount(db, caller.accountId) + if (raced) { + return { slug: raced, orgId } + } + } + throw new WorkspaceError('conflict', 'slug_exhausted') +} + +const PutSchema = z.object({ slug: z.string().min(1).max(40) }).strict() + +export async function putOrgSlug(db: D1Database, caller: SandboxCaller, body: unknown, now: number) { + const orgId = caller.orgId ? orgIdOf(caller.accountId) : null + if (!orgId) { + throw new WorkspaceError('invalid_request', 'personal_account') + } + if (!isOrgAdmin(caller)) { + throw new WorkspaceError('forbidden', 'admin_only') + } + const parsed = PutSchema.safeParse(body) + if (!parsed.success || !isValidSlug(parsed.data.slug)) { + throw new WorkspaceError('invalid_request', 'invalid_slug') + } + const slug = parsed.data.slug + const holder = await accountForSlug(db, slug) + if (holder && holder !== caller.accountId) { + throw new WorkspaceError('conflict', 'slug_taken') + } + await db + .prepare( + `INSERT INTO org_slugs (account_id, slug, created_at, created_by, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (account_id) DO UPDATE SET slug = excluded.slug, updated_at = excluded.updated_at` + ) + .bind(caller.accountId, slug, now, caller.userId, now) + .run() + .catch((error: unknown) => { + // The UNIQUE index lost a race with another org claiming the same slug. + if (String(error).includes('UNIQUE')) { + throw new WorkspaceError('conflict', 'slug_taken') + } + throw error + }) + return { slug, orgId } +} diff --git a/cloud/apps/sandbox-control/src/store.ts b/cloud/apps/sandbox-control/src/store.ts index 14ec858457b..64a5c48b88a 100644 --- a/cloud/apps/sandbox-control/src/store.ts +++ b/cloud/apps/sandbox-control/src/store.ts @@ -71,6 +71,8 @@ export type SandboxRow = { last_activity_at: number | null last_activity_by: string | null token_usd_micros: number | null + /** 0051: this workspace's number in its account (shown as W-123); null only on rows from before it. */ + number?: number | null } export type WorkspaceVisibility = 'private' | 'team' @@ -118,18 +120,28 @@ export async function insertSandbox( Partial>, now: number ): Promise { - await db - .prepare( - `INSERT INTO sandboxes (id, account_id, user_id, workspace_id, provider, provider_name, status, - machine_size, region, generation, created_at, updated_at, repo_url, environment_id, environment_build_id, - source_snapshot_id, created_by, name, repo_branch, visibility, section, last_activity_at, last_activity_by) - VALUES (?, ?, ?, ?, ?, ?, 'creating', ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind(row.id, row.account_id, row.user_id, row.workspace_id, row.provider, row.provider_name, - row.machine_size, row.region, now, now, row.repo_url, row.environment_id ?? null, - row.environment_build_id ?? null, row.source_snapshot_id ?? null, row.user_id, row.name ?? null, - row.repo_branch ?? null, row.visibility ?? 'private', row.section ?? null, now, row.user_id) - .run() + // One transaction: bump the account's counter, then take its value as this row's number (W-123). + // A failed insert rolls the bump back; a destroyed row keeps its number, so none is ever reused. + await db.batch([ + db + .prepare( + `INSERT INTO org_workspace_counters (account_id, last_number) VALUES (?, 1) + ON CONFLICT (account_id) DO UPDATE SET last_number = last_number + 1` + ) + .bind(row.account_id), + db + .prepare( + `INSERT INTO sandboxes (id, account_id, user_id, workspace_id, provider, provider_name, status, + machine_size, region, generation, created_at, updated_at, repo_url, environment_id, environment_build_id, + source_snapshot_id, created_by, name, repo_branch, visibility, section, last_activity_at, last_activity_by, number) + VALUES (?, ?, ?, ?, ?, ?, 'creating', ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + (SELECT last_number FROM org_workspace_counters WHERE account_id = ?))` + ) + .bind(row.id, row.account_id, row.user_id, row.workspace_id, row.provider, row.provider_name, + row.machine_size, row.region, now, now, row.repo_url, row.environment_id ?? null, + row.environment_build_id ?? null, row.source_snapshot_id ?? null, row.user_id, row.name ?? null, + row.repo_branch ?? null, row.visibility ?? 'private', row.section ?? null, now, row.user_id, row.account_id) + ]) const inserted = await getSandbox(db, row.id) if (!inserted) { throw new Error('sandbox insert lost') diff --git a/cloud/apps/sandbox-control/src/workspace-prefs.ts b/cloud/apps/sandbox-control/src/workspace-prefs.ts new file mode 100644 index 00000000000..49fca99c3d0 --- /dev/null +++ b/cloud/apps/sandbox-control/src/workspace-prefs.ts @@ -0,0 +1,86 @@ +// A member's own sidebar arrangement of cloud workspaces, stored on the account so every device +// (desktops and the phone) shows the same pins, order and sections. +// GET /v1/team/prefs -> {document, revision, updatedAt} +// PUT /v1/team/prefs {document, revision} `revision` is the one the client edited (compare-and-set: +// a stale write gets 409 and the client re-applies its edit) +// Per member, per account: switching teams switches arrangements. Ids that no longer exist are +// harmless (clients skip them) and are dropped the next time that client writes. +import { z } from 'zod' +import type { SandboxCaller } from './auth' +import { WorkspaceError } from './workspaces' + +const SandboxId = z.string().regex(/^sbx_[0-9a-f]{24}$/) +const SectionName = z.string().trim().min(1).max(64).regex(/^[^\u0000-\u001f]+$/) + +const unique = (list: readonly string[]) => new Set(list).size === list.length + +export const WorkspacePrefsDocumentSchema = z + .object({ + /** Pinned workspaces, top to bottom. */ + pinned: z.array(SandboxId).max(200).refine(unique, 'duplicate id'), + /** Manual order of the rest (drag to reorder); workspaces not listed follow, newest first. */ + order: z.array(SandboxId).max(500).refine(unique, 'duplicate id'), + /** Section order, including sections that are empty right now. */ + sections: z.array(SectionName).max(50).refine(unique, 'duplicate section'), + /** Sections this member keeps collapsed. */ + collapsed: z.array(SectionName).max(50).refine(unique, 'duplicate section') + }) + .strict() + .refine((doc) => !doc.pinned.some((id) => doc.order.includes(id)), 'pinned ids stay out of order') + +export type WorkspacePrefsDocument = z.infer + +const PutSchema = z + .object({ + document: WorkspacePrefsDocumentSchema, + revision: z.number().int().min(0), + /** Which device wrote it (a label for debugging sync, never trusted for access). */ + deviceId: z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/).optional() + }) + .strict() + +export const EMPTY_PREFS: WorkspacePrefsDocument = { pinned: [], order: [], sections: [], collapsed: [] } + +type Row = { document: string; revision: number; updated_at: number } + +export async function getWorkspacePrefs(db: D1Database, caller: SandboxCaller) { + const row = await db + .prepare('SELECT document, revision, updated_at FROM workspace_prefs WHERE account_id = ? AND user_id = ?') + .bind(caller.accountId, caller.userId) + .first() + if (!row) { + return { document: EMPTY_PREFS, revision: 0, updatedAt: null } + } + // A document from a newer schema parses leniently: unknown keys are ignored, bad ones reset. + const parsed = WorkspacePrefsDocumentSchema.safeParse(JSON.parse(row.document)) + return { document: parsed.success ? parsed.data : EMPTY_PREFS, revision: row.revision, updatedAt: row.updated_at } +} + +export async function putWorkspacePrefs(db: D1Database, caller: SandboxCaller, body: unknown, now: number) { + const parsed = PutSchema.safeParse(body) + if (!parsed.success) { + throw new WorkspaceError('invalid_request') + } + const { document, revision, deviceId } = parsed.data + const text = JSON.stringify(document) + const result = + revision === 0 + ? await db + .prepare( + `INSERT OR IGNORE INTO workspace_prefs (account_id, user_id, document, revision, updated_at, updated_by_device) + VALUES (?, ?, ?, 1, ?, ?)` + ) + .bind(caller.accountId, caller.userId, text, now, deviceId ?? null) + .run() + : await db + .prepare( + `UPDATE workspace_prefs SET document = ?, revision = revision + 1, updated_at = ?, updated_by_device = ? + WHERE account_id = ? AND user_id = ? AND revision = ?` + ) + .bind(text, now, deviceId ?? null, caller.accountId, caller.userId, revision) + .run() + if (result.meta.changes !== 1) { + throw new WorkspaceError('conflict', 'stale_revision') + } + return getWorkspacePrefs(db, caller) +} diff --git a/cloud/apps/sandbox-control/src/workspaces.ts b/cloud/apps/sandbox-control/src/workspaces.ts index f29d0c52e5c..880544bc536 100644 --- a/cloud/apps/sandbox-control/src/workspaces.ts +++ b/cloud/apps/sandbox-control/src/workspaces.ts @@ -4,7 +4,7 @@ // GET /v1/team/workspaces/:id // PATCH /v1/team/workspaces/:id {title?, branch?, visibility?, section?, archived?, ownerUserId?, tokenUsdMicros?} // POST /v1/team/workspaces/:id/presence {clientId, state: viewing|typing|left, chatId?} -// GET /v1/team/links/resolve?w=&c= +// GET /v1/team/links/resolve?w=&c= or ?org=&n=&c= // Access rule: a workspace is visible to its owner, and to every member of the owning org when // visibility is `team`. Org owners/admins additionally see private rows' metadata (scope=all) so a // departed member's workspaces can be reassigned or archived — metadata only, never access. @@ -15,6 +15,7 @@ import { getSandbox, touchActivity, updateRegistry, type SandboxRow } from './st import { memberNamesFor } from './member-names' import { readSnapshotTimes } from './read-snapshots' import { sandboxView } from './wire' +import { accountForSlug, orgIdOf, slugForAccount } from './org-slugs' import { ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire' export const PRESENCE_TTL_MS = 45_000 @@ -170,11 +171,12 @@ function repoView(row: SandboxRow) { export async function workspaceViews(db: D1Database, caller: SandboxCaller, rows: readonly SandboxRow[], now: number) { const ids = rows.map((r) => r.id) - const [presence, compute, granted, readAt] = await Promise.all([ + const [presence, compute, granted, readAt, slug] = await Promise.all([ presenceFor(db, ids, now), computeMsFor(db, ids), activeGrantUsers(db, ids, caller.userId), - readSnapshotTimes(db, ids) + readSnapshotTimes(db, ids), + rows.length > 0 ? slugForAccount(db, caller.accountId) : Promise.resolve(null) ]) const referenced = (row: SandboxRow, live: readonly PresenceRow[]) => [row.user_id, row.created_by, row.last_activity_by, ...live.map((p) => p.user_id)].filter( @@ -196,6 +198,10 @@ export async function workspaceViews(db: D1Database, caller: SandboxCaller, rows return { ...sandboxView(row), title: row.name, + // W-123 in its account; null only for a row that predates numbering and was never backfilled. + number: row.number ?? null, + // The link to copy: org slug + number when both exist, else the sandbox id form. + link: workspaceLink(row, slug), repo: repoView(row), visibility: row.visibility, section: row.section, @@ -368,27 +374,56 @@ export async function reportPresence( return { presence: view!.presence } } -const LinkQuery = z.object({ - w: z.string().regex(/^sbx_[0-9a-f]{24}$/), - c: z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/).optional() -}) +const ChatParam = z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/).optional() +const LinkQuery = z.union([ + z.object({ w: z.string().regex(/^sbx_[0-9a-f]{24}$/), c: ChatParam }), + z.object({ org: z.string().min(1).max(40), n: z.coerce.number().int().min(1).max(1e9), c: ChatParam }) +]) -/** orca://cloud/w/[/c/] → the workspace if the caller can see it; otherwise not_found. */ +/** A slug link that names a different org than the caller's current one. */ +export class LinkOtherOrgError extends WorkspaceError { + constructor(readonly orgId: string | null) { + super('not_found', 'other_org') + } +} + +/** + * orca://cloud/w/[/c/] or orca://cloud//W-[/c/] → the workspace if the caller + * can see it; otherwise not_found. A slug link for another org answers not_found with reason + * `other_org` and that org's id, so a client that belongs to it can offer to switch teams. + */ export async function resolveLink(db: D1Database, caller: SandboxCaller, url: URL, now: number) { const query = LinkQuery.safeParse(Object.fromEntries(url.searchParams)) if (!query.success) { throw new WorkspaceError('invalid_request') } - const row = await getSandbox(db, query.data.w) + const q = query.data + let row: SandboxRow | null + if ('w' in q) { + row = await getSandbox(db, q.w) + } else { + const accountId = await accountForSlug(db, q.org) + if (!accountId) { + throw new WorkspaceError('not_found') + } + if (accountId !== caller.accountId) { + throw new LinkOtherOrgError(orgIdOf(accountId)) + } + row = await db + .prepare('SELECT * FROM sandboxes WHERE account_id = ? AND number = ?') + .bind(accountId, q.n) + .first() + } if (!row || !canSee(caller, row)) { // Private, other org, or gone: the link reveals nothing about which. throw new WorkspaceError('not_found') } const [view] = await workspaceViews(db, caller, [row], now) - return { workspace: view, chatId: query.data.c ?? null } + return { workspace: view, chatId: q.c ?? null } } /** Canonical deep link for a workspace (and optionally a chat in it). */ -export function workspaceLink(id: string, chatId?: string): string { - return `orca://cloud/w/${id}${chatId ? `/c/${encodeURIComponent(chatId)}` : ''}` +export function workspaceLink(row: Pick, slug: string | null, chatId?: string): string { + const chat = chatId ? `/c/${encodeURIComponent(chatId)}` : '' + return slug && row.number ? `orca://cloud/${slug}/W-${row.number}${chat}` : `orca://cloud/w/${row.id}${chat}` } diff --git a/cloud/apps/sandbox-control/test/workspace-numbers-prefs.test.ts b/cloud/apps/sandbox-control/test/workspace-numbers-prefs.test.ts new file mode 100644 index 00000000000..270c1d2be83 --- /dev/null +++ b/cloud/apps/sandbox-control/test/workspace-numbers-prefs.test.ts @@ -0,0 +1,175 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { isValidSlug, slugify } from '../src/org-slugs' +import { createHarness } from './harness' + +type Role = 'owner' | 'admin' | 'member' +type Who = { sub: string; org?: string; role: Role } + +async function setup() { + const h = await createHarness() + const req = async (who: Who, method: string, path: string, body?: unknown) => + h.call(method, path, { bearer: await h.token(who.sub, who.org, 'sandbox', who.org ? { org_role: who.role } : {}), body }) + let n = 0 + const create = async (who: Who, extra: Record = {}) => { + n += 1 + const res = await req(who, 'POST', '/v1/sandboxes', { workspaceId: `ws-${n}`, machineSize: 'standard', ...extra }) + expect(res.status).toBe(200) + return res.body.sandbox.id as string + } + const alice: Who = { sub: 'alice', org: 'acme', role: 'member' } + const bob: Who = { sub: 'bob', org: 'acme', role: 'member' } + const admin: Who = { sub: 'carol', org: 'acme', role: 'admin' } + const eve: Who = { sub: 'eve', org: 'other', role: 'owner' } + const solo: Who = { sub: 'sam', role: 'member' } + return { h, req, create, alice, bob, admin, eve, solo } +} + +describe('workspace numbers (W-123)', () => { + it('numbers workspaces per account and never reuses a number', async () => { + const t = await setup() + const a1 = await t.create(t.alice) + const a2 = await t.create(t.bob, { visibility: 'team' }) + const e1 = await t.create(t.eve) + const view = async (who: Who, id: string) => (await t.req(who, 'GET', `/v1/team/workspaces/${id}`)).body.workspace + expect((await view(t.alice, a1)).number).toBe(1) + expect((await view(t.bob, a2)).number).toBe(2) + expect((await view(t.eve, e1)).number).toBe(1) + expect((await t.req(t.alice, 'DELETE', `/v1/sandboxes/${a1}`)).status).toBe(200) + const a3 = await t.create(t.alice) + expect((await view(t.alice, a3)).number).toBe(3) + }) + + it('backfills existing rows by creation order and seeds the counter', () => { + const raw = new DatabaseSync(':memory:') + const dir = fileURLToPath(new URL('../migrations/', import.meta.url).href) + const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort() + const at = files.indexOf('0051_workspace_numbers_prefs.sql') + for (const file of files.slice(0, at)) { + raw.exec(readFileSync(`${dir}${file}`, 'utf8')) + } + const insert = raw.prepare( + `INSERT INTO sandboxes (id, account_id, user_id, workspace_id, provider, provider_name, status, machine_size, region, + generation, created_at, updated_at) VALUES (?, ?, 'u', ?, 'vercel', ?, 'destroyed', 'standard', 'iad1', 0, ?, ?)` + ) + insert.run('sbx_b', 'org:a', 'w2', 'p2', 20, 20) + insert.run('sbx_a', 'org:a', 'w1', 'p1', 10, 10) + insert.run('sbx_c', 'org:a', 'w3', 'p3', 20, 20) + insert.run('sbx_z', 'org:b', 'w1', 'pz', 5, 5) + for (const file of files.slice(at)) { + raw.exec(readFileSync(`${dir}${file}`, 'utf8')) + } + expect(raw.prepare('SELECT id, number FROM sandboxes ORDER BY id').all()).toEqual([ + { id: 'sbx_a', number: 1 }, + { id: 'sbx_b', number: 2 }, + { id: 'sbx_c', number: 3 }, + { id: 'sbx_z', number: 1 } + ]) + expect(raw.prepare('SELECT * FROM org_workspace_counters ORDER BY account_id').all()).toEqual([ + { account_id: 'org:a', last_number: 3 }, + { account_id: 'org:b', last_number: 1 } + ]) + }) +}) + +describe('org slugs', () => { + it('slugifies names and refuses reserved or malformed slugs', () => { + expect(slugify('Acme Labs, Inc.')).toBe('acme-labs-inc') + expect(slugify('Ünïcode Crew')).toBe('unicode-crew') + expect(slugify('!!!')).toBe('team') + expect(slugify('w')).toBe('w-team') + expect(isValidSlug('acme')).toBe(true) + expect(isValidSlug('w')).toBe(false) + expect(isValidSlug('-acme')).toBe(false) + expect(isValidSlug('a--b')).toBe(false) + expect(isValidSlug('Acme')).toBe(false) + }) + + it('names an org once from the first name it hears, uniquely across orgs', async () => { + const t = await setup() + expect((await t.req(t.alice, 'GET', '/v1/team/org')).body).toEqual({ slug: null, orgId: 'acme' }) + expect((await t.req(t.alice, 'GET', '/v1/team/org?name=Acme%20Labs')).body).toEqual({ slug: 'acme-labs', orgId: 'acme' }) + // A teammate whose client shows another name still gets the org's slug. + expect((await t.req(t.bob, 'GET', '/v1/team/org?name=Whatever')).body.slug).toBe('acme-labs') + expect((await t.req(t.eve, 'GET', '/v1/team/org?name=Acme%20Labs')).body.slug).toBe('acme-labs-2') + expect((await t.req(t.solo, 'GET', '/v1/team/org?name=Sam')).body).toEqual({ slug: null, orgId: null }) + }) + + it('lets only admins rename, and not onto another org', async () => { + const t = await setup() + await t.req(t.alice, 'GET', '/v1/team/org?name=Acme') + await t.req(t.eve, 'GET', '/v1/team/org?name=Other') + expect((await t.req(t.alice, 'PUT', '/v1/team/org', { slug: 'acme-co' })).status).toBe(403) + expect((await t.req(t.admin, 'PUT', '/v1/team/org', { slug: 'other' })).status).toBe(409) + expect((await t.req(t.admin, 'PUT', '/v1/team/org', { slug: 'w' })).status).toBe(400) + expect((await t.req(t.admin, 'PUT', '/v1/team/org', { slug: 'acme-co' })).body).toEqual({ slug: 'acme-co', orgId: 'acme' }) + expect((await t.req(t.bob, 'GET', '/v1/team/org')).body.slug).toBe('acme-co') + }) +}) + +describe('slug links', () => { + it('copies org slug links and resolves them only for those who can see the workspace', async () => { + const t = await setup() + const id = await t.create(t.alice) + const before = (await t.req(t.alice, 'GET', `/v1/team/workspaces/${id}`)).body.workspace + // No slug yet: the id form still works. + expect(before.link).toBe(`orca://cloud/w/${id}`) + await t.req(t.alice, 'GET', '/v1/team/org?name=Acme') + const after = (await t.req(t.alice, 'GET', '/v1/team/workspaces?scope=mine')).body.workspaces[0] + expect(after.link).toBe('orca://cloud/acme/W-1') + + const resolved = await t.req(t.alice, 'GET', '/v1/team/links/resolve?org=acme&n=1&c=s:abc') + expect(resolved.status).toBe(200) + expect(resolved.body).toMatchObject({ workspace: { id, number: 1 }, chatId: 's:abc' }) + // Private to Alice: Bob sees nothing until she shares it. + expect((await t.req(t.bob, 'GET', '/v1/team/links/resolve?org=acme&n=1')).status).toBe(404) + await t.req(t.alice, 'PATCH', `/v1/team/workspaces/${id}`, { visibility: 'team' }) + expect((await t.req(t.bob, 'GET', '/v1/team/links/resolve?org=acme&n=1')).body.workspace.id).toBe(id) + expect((await t.req(t.bob, 'GET', '/v1/team/links/resolve?org=acme&n=2')).status).toBe(404) + // Another org: not found, but says which org so a member of both can switch. + const other = await t.req(t.eve, 'GET', '/v1/team/links/resolve?org=acme&n=1') + expect(other.status).toBe(404) + expect(other.body).toEqual({ error: 'not_found', reason: 'other_org', orgId: 'acme' }) + expect((await t.req(t.eve, 'GET', '/v1/team/links/resolve?org=nope&n=1')).body).toEqual({ error: 'not_found' }) + expect((await t.req(t.alice, 'GET', '/v1/team/links/resolve?org=acme&n=0')).status).toBe(400) + }) +}) + +describe('workspace prefs (synced pins and order)', () => { + const A = `sbx_${'a'.repeat(24)}` + const B = `sbx_${'b'.repeat(24)}` + + it('stores one document per member and account with compare-and-set', async () => { + const t = await setup() + expect((await t.req(t.alice, 'GET', '/v1/team/prefs')).body).toEqual({ + document: { pinned: [], order: [], sections: [], collapsed: [] }, + revision: 0, + updatedAt: null + }) + const doc = { pinned: [A], order: [B], sections: ['Auth', 'Billing'], collapsed: ['Billing'] } + const first = await t.req(t.alice, 'PUT', '/v1/team/prefs', { document: doc, revision: 0, deviceId: 'desk-1' }) + expect(first.status).toBe(200) + expect(first.body).toMatchObject({ document: doc, revision: 1 }) + // A second device that still holds revision 0 must re-read before writing. + const stale = await t.req(t.alice, 'PUT', '/v1/team/prefs', { document: { ...doc, pinned: [] }, revision: 0 }) + expect(stale.status).toBe(409) + const second = await t.req(t.alice, 'PUT', '/v1/team/prefs', { document: { ...doc, pinned: [B, A], order: [] }, revision: 1 }) + expect(second.body.revision).toBe(2) + expect((await t.req(t.alice, 'GET', '/v1/team/prefs')).body.document.pinned).toEqual([B, A]) + // Teammates and other accounts have their own. + expect((await t.req(t.bob, 'GET', '/v1/team/prefs')).body.revision).toBe(0) + expect((await t.req({ ...t.alice, org: 'other' }, 'GET', '/v1/team/prefs')).body.revision).toBe(0) + expect((await t.req(t.solo, 'PUT', '/v1/team/prefs', { document: doc, revision: 0 })).status).toBe(200) + }) + + it('rejects malformed documents', async () => { + const t = await setup() + const put = (document: unknown) => t.req(t.alice, 'PUT', '/v1/team/prefs', { document, revision: 0 }) + expect((await put({ pinned: [A], order: [A], sections: [], collapsed: [] })).status).toBe(400) + expect((await put({ pinned: [A, A], order: [], sections: [], collapsed: [] })).status).toBe(400) + expect((await put({ pinned: ['nope'], order: [], sections: [], collapsed: [] })).status).toBe(400) + expect((await put({ pinned: [], order: [], sections: ['x'], collapsed: [], extra: 1 })).status).toBe(400) + }) +})