mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(cloud): workspace numbers (W-123), org slug links and synced sidebar prefs on the control plane (G13, G14)
This commit is contained in:
@@ -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/<slug>/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)
|
||||
);
|
||||
@@ -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') {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Org URL slugs for workspace links (orca://cloud/<slug>/W-123).
|
||||
// GET /v1/team/org?name=<org display 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/<sbx>).
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
if (!isValidSlug(slug)) {
|
||||
return null
|
||||
}
|
||||
const row = await db.prepare('SELECT account_id FROM org_slugs WHERE slug = ?').bind(slug).first<Row>()
|
||||
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 }
|
||||
}
|
||||
@@ -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<Pick<SandboxRow, 'environment_id' | 'environment_build_id' | 'source_snapshot_id' | 'name' | 'repo_branch' | 'visibility' | 'section'>>,
|
||||
now: number
|
||||
): Promise<SandboxRow> {
|
||||
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')
|
||||
|
||||
@@ -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<typeof WorkspacePrefsDocumentSchema>
|
||||
|
||||
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<Row>()
|
||||
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)
|
||||
}
|
||||
@@ -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=<sandboxId>&c=<chatId>
|
||||
// GET /v1/team/links/resolve?w=<sandboxId>&c=<chatId> or ?org=<slug>&n=<number>&c=<chatId>
|
||||
// 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/<id>[/c/<chat>] → 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/<id>[/c/<chat>] or orca://cloud/<slug>/W-<n>[/c/<chat>] → 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<SandboxRow>()
|
||||
}
|
||||
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<SandboxRow, 'id' | 'number'>, 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}`
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {}) => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user