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
27 changes: 17 additions & 10 deletions DESIGN.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion apps/app/src/main/ipc/hub-share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isDiscoverySessionSid,
isResumableSessionProvider,
parseSessionText,
sessionRecordData,
type SessionProvider,
} from '@spool-lab/session-kit'
// Type-only: share-kit's runtime bundle needs a DOM at import time and
Expand Down Expand Up @@ -206,7 +207,9 @@ export function registerHubShareIpc(deps: HubShareIpcDeps = {}): void {
async (_e, args: { sessionUuid: string }): Promise<HubSharePrepareResult> => {
try {
const { prepared, card } = await prepareEntry(args.sessionUuid, deps)
const secrets = scanRecordsForSecrets(prepared.records.map((record) => record.data))
const secrets = scanRecordsForSecrets(
prepared.records.map((record) => sessionRecordData(record)),
)
return {
ok: true,
prepared: {
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/functions/_scheduled/deletion-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,16 @@ async function deleteHubContent(
env.DB.prepare('DELETE FROM hub_sessions WHERE owner_user_id=? AND team_id IS NULL').bind(
userId,
),
env.DB.prepare(
`/* account-deletion:delete-personal-project-receipts */
DELETE FROM project_creation_requests
WHERE owner_user_id=? AND owner_team_id IS NULL`,
).bind(userId),
env.DB.prepare(
`/* account-deletion:delete-personal-projects */
DELETE FROM projects
WHERE owner_user_id=? AND owner_team_id IS NULL`,
).bind(userId),
])
}

Expand Down
11 changes: 3 additions & 8 deletions apps/backend/functions/api/handles/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,9 @@ export const onRequestGet: PagesFunction<Env> = async (ctx) => {
const v = validateHandle(handle)
const headers = { 'cache-control': ccPublicRevalidate(10) }
if (!v.ok) return jsonOk({ available: false, reason: v.reason }, { headers })
// NOTE: `handles.handle` is the table PK, so once a row exists the
// value is occupied for INSERT purposes even after `released_at` is
// set. This query matches the design intent (released handles are
// re-claimable) — when the release flow ships, the claim path must
// switch to INSERT … ON CONFLICT(handle) DO UPDATE SET released_at=NULL.
const row = await ctx.env.DB.prepare(
'SELECT 1 FROM handles WHERE handle=? AND released_at IS NULL',
)
// Handles are permanent URL tombstones. Account/Team deletion makes the
// route inactive but never lets a different owner inherit old links.
const row = await ctx.env.DB.prepare('SELECT 1 FROM handles WHERE handle=?')
.bind(v.handle)
.first()
return jsonOk({ available: !row }, { headers })
Expand Down
11 changes: 7 additions & 4 deletions apps/backend/functions/api/handles/claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,17 @@ export const onRequestPost: PagesFunction<Env> = async (ctx) => {
if (!v.ok) throw new ApiError('UNPROCESSABLE', v.reason)

const [existing, prior] = await Promise.all([
ctx.env.DB.prepare('SELECT user_id FROM handles WHERE handle=? AND released_at IS NULL')
ctx.env.DB.prepare('SELECT user_id, team_id, released_at FROM handles WHERE handle=?')
.bind(v.handle)
.first<{ user_id: string }>(),
.first<{ user_id: string | null; team_id: string | null; released_at: number | null }>(),
ctx.env.DB.prepare('SELECT handle FROM handles WHERE user_id=? AND released_at IS NULL')
.bind(user.id)
.first<{ handle: string }>(),
])
if (existing && existing.user_id !== user.id) {
if (
existing &&
(existing.user_id !== user.id || existing.team_id !== null || existing.released_at !== null)
) {
throw new ApiError('CONFLICT', 'handle taken')
}
if (prior && prior.handle !== v.handle) {
Expand All @@ -57,7 +60,7 @@ export const onRequestPost: PagesFunction<Env> = async (ctx) => {
// of letting jsonError surface it as 500 INTERNAL.
try {
await ctx.env.DB.prepare(
'INSERT INTO handles (handle, user_id, claimed_at) VALUES (?, ?, ?)',
'INSERT INTO handles (handle, user_id, team_id, claimed_at) VALUES (?, ?, NULL, ?)',
)
.bind(v.handle, user.id, Date.now())
.run()
Expand Down
160 changes: 160 additions & 0 deletions apps/backend/functions/api/hub/v1/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { PagesFunction } from '@cloudflare/workers-types'
import { z } from 'zod'

import { ApiError, jsonError, jsonOk } from '../../../../src/errors'
import { requireHubUser } from '../../../../src/hub/auth'
import { activeTeamRole, type HubEnv } from '../../../../src/hub/head'
import { TEAM_ID_RE } from '../../../../src/hub/wire'
import { PROJECT_CREATE_RATE, PROJECT_LIST_RATE } from '../../../../src/projects/limits'
import {
createProjectIdempotently,
ensureProjectTenantHandle,
listHubProjectsForUser,
parseProjectListPageOptions,
serializeHubProject,
serializeProject,
} from '../../../../src/projects/store'
import {
CreateProjectBody,
requireProjectIdempotencyKey,
slugFromName,
} from '../../../../src/projects/validators'
import { checkRate } from '../../../../src/rate-limit'

const HubProjectOwner = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('user'), id: z.string().min(1).max(192) }).strict(),
z.object({ kind: z.literal('team'), id: z.string().regex(TEAM_ID_RE) }).strict(),
])

const HubCreateProjectBody = CreateProjectBody.extend({
owner: HubProjectOwner.optional(),
// Rolling-upgrade alias for clients from the first Projects preview.
teamId: z.string().regex(TEAM_ID_RE).nullable().optional(),
idempotency_key: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.owner === undefined && value.teamId === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['owner'],
message: 'owner is required',
})
return
}
if (
value.owner !== undefined &&
value.teamId !== undefined &&
(value.owner.kind !== (value.teamId === null ? 'user' : 'team') ||
(value.owner.kind === 'team' && value.owner.id !== value.teamId))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['owner'],
message: 'owner and teamId must identify the same tenant',
})
}
})

export const onRequestGet: PagesFunction<HubEnv> = async (ctx) => {
try {
const user = await requireHubUser(ctx.request, ctx.env)
const rate = await checkRate(ctx.env.RATE, { ...PROJECT_LIST_RATE, key: user.id })
if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS')
const page = await listHubProjectsForUser(
ctx.env.DB,
user.id,
await parseProjectListPageOptions(ctx.request, ['hub-project-list', user.id]),
)
return jsonOk(
{
actor: { id: user.id },
projects: page.rows.map(serializeHubProject),
next_cursor: page.nextCursor,
},
{ headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' } },
)
} catch (error) {
return privateProjectError(error)
}
}

export const onRequestPost: PagesFunction<HubEnv> = async (ctx) => {
try {
const user = await requireHubUser(ctx.request, ctx.env)
const rate = await checkRate(ctx.env.RATE, { ...PROJECT_CREATE_RATE, key: user.id })
if (!rate.ok) throw new ApiError('TOO_MANY_REQUESTS')
const parsed = HubCreateProjectBody.safeParse(await readJson(ctx.request))
if (!parsed.success) {
throw new ApiError('UNPROCESSABLE', 'invalid Project', { issues: parsed.error.issues })
}
const idempotencyKey = requireProjectIdempotencyKey(ctx.request, parsed.data.idempotency_key)
const canonicalOwner =
parsed.data.owner ??
(parsed.data.teamId === null
? { kind: 'user' as const, id: user.id }
: { kind: 'team' as const, id: parsed.data.teamId! })
if (canonicalOwner.kind === 'user' && canonicalOwner.id !== user.id) {
throw new ApiError('NOT_FOUND')
}
const teamId = canonicalOwner.kind === 'team' ? canonicalOwner.id : null
const teamRole = teamId === null ? null : await activeTeamRole(ctx.env.DB, teamId, user.id)
if (teamId !== null && teamRole !== 'owner' && teamRole !== 'admin') {
throw new ApiError(teamRole === null ? 'NOT_FOUND' : 'FORBIDDEN')
}
const now = Date.now()
await ensureProjectTenantHandle(ctx.env.DB, {
actorUserId: user.id,
tenant: teamId === null ? { userId: user.id, teamId: null } : { userId: null, teamId },
now,
})
const input = {
name: parsed.data.name,
slug: parsed.data.slug ?? slugFromName(parsed.data.name),
description: parsed.data.description,
github_url: parsed.data.github_url,
}
const tenant =
teamId === null
? ({ userId: user.id, teamId: null } as const)
: ({ userId: null, teamId } as const)
const result = await createProjectIdempotently(ctx.env.DB, {
actorUserId: user.id,
tenant,
input,
idempotencyKey,
now,
})
return jsonOk(
{
project: await serializeProject(ctx.env.DB, result.project, { canManage: true }),
},
{
status: result.replayed ? 200 : 201,
headers: { 'cache-control': 'private, no-store', vary: 'Cookie, Authorization' },
},
)
} catch (error) {
return privateProjectError(error)
}
}

function privateProjectError(error: unknown): Response {
const response = jsonError(error)
const headers = new Headers(response.headers)
headers.set('cache-control', 'private, no-store')
headers.set('vary', 'Cookie, Authorization')
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}

async function readJson(request: Request): Promise<unknown> {
try {
return await request.json()
} catch {
throw new ApiError('BAD_REQUEST', 'invalid json')
}
}
46 changes: 37 additions & 9 deletions apps/backend/functions/api/hub/v1/sessions/[sid]/head.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PagesFunction } from '@cloudflare/workers-types'
import type { D1PreparedStatement, PagesFunction } from '@cloudflare/workers-types'
import { costForUsage, isDiscoverySessionSid } from '@spool-lab/session-kit'

import { auditAfterCommit } from '../../../../../../src/audit-after-commit'
Expand Down Expand Up @@ -34,6 +34,10 @@ import {
teamStorageBytes,
} from '../../../../../../src/hub/store'
import { parseHeadBody, requireSid, TEAM_QUOTA_BYTES } from '../../../../../../src/hub/wire'
import {
ensureProjectTenantHandle,
prepareAuthorizedDefaultProjectInsert,
} from '../../../../../../src/projects/store'
import { publicBaseUrl } from '../../../../../../src/public-url'

// Step 3 of the share handshake: commit the head. Re-runs the same
Expand All @@ -49,12 +53,8 @@ export const onRequestPost: PagesFunction<HubEnv> = async (ctx) => {
const body = await parseHeadBody(ctx.request)

const existing = await getHubSession(ctx.env.DB, sid)
const { missing, aliasOids, teamId, teamRole } = await validateHead(
ctx.env.DB,
user.id,
sid,
body,
)
const { missing, aliasOids, teamId, teamRole, projectId, projectNeedsInsert } =
await validateHead(ctx.env.DB, user.id, sid, body)
if (missing.length > 0) {
throw new ApiError('CONFLICT', 'objects missing — upload before committing', {
missing: missing.slice(0, 50),
Expand Down Expand Up @@ -96,6 +96,7 @@ export const onRequestPost: PagesFunction<HubEnv> = async (ctx) => {
: null
const accessChanged =
existing === null || existing.team_id !== teamId || currentVisibility !== effectiveVisibility
const projectChanged = existing === null || existing.project_id !== projectId
const requestedStorageVisibility: 'private' | 'unlisted' =
effectiveVisibility === 'team' ? 'private' : 'unlisted'
const committedStorageVisibility: 'private' | 'unlisted' =
Expand All @@ -106,6 +107,7 @@ export const onRequestPost: PagesFunction<HubEnv> = async (ctx) => {
const requireTeamManager =
teamId !== null &&
((existingTeamOwned && accessChanged) ||
(existingTeamOwned && projectChanged) ||
(!existingTeamOwned && effectiveVisibility !== 'team'))
if (requireTeamManager && teamRole !== 'owner' && teamRole !== 'admin') {
throw new ApiError('FORBIDDEN')
Expand Down Expand Up @@ -160,25 +162,48 @@ export const onRequestPost: PagesFunction<HubEnv> = async (ctx) => {
spoolFileOid: body.spoolFileOid,
costUsd: cost?.usd ?? null,
totalTokens: cost?.totalTokens ?? null,
projectId,
now,
actorUserId: user.id,
expectedTeamId: existing?.team_id ?? null,
expectedProjectId: existing?.project_id ?? null,
expectedVisibility: existing?.visibility ?? 'unlisted',
expectedWithdrawnAt: existing?.withdrawn_at ?? null,
expectedRoot: existing?.root ?? null,
expectedUpdatedAt: existing?.updated_at ?? null,
expectedPublished: wasPublic,
targetTeamId: teamId,
targetProjectId: projectId,
targetVisibility: requestedStorageVisibility,
changeAccess: accessChanged,
changeProject: projectChanged,
clearWithdrawal: existing?.team_id == null && existing?.withdrawn_at != null,
requireTeamManager,
} as const
const sessionCommit =
existing === null
? prepareAuthorizedHeadInsert(ctx.env.DB, sessionWrite)
: prepareAuthorizedHeadUpdate(ctx.env.DB, sessionWrite)
const statements = [sessionCommit]
const statements: D1PreparedStatement[] = []
// A Project may have been synthesized by the rolling-deploy compatibility
// trigger while the previous Worker was still serving traffic. Repair the
// tenant route identity before every commit, not only on first creation.
await ensureProjectTenantHandle(ctx.env.DB, {
actorUserId: user.id,
tenant: teamId === null ? { userId: user.id, teamId: null } : { userId: null, teamId },
now,
})
if (projectNeedsInsert) {
statements.push(
prepareAuthorizedDefaultProjectInsert(ctx.env.DB, {
actorUserId: user.id,
tenant: teamId === null ? { userId: user.id, teamId: null } : { userId: null, teamId },
now,
}),
)
}
const sessionCommitIndex = statements.length
statements.push(sessionCommit)
// A legacy client may recommit Summary/card metadata against the same
// immutable record root. Preserve a server-backfilled projection when
// that old view cannot carry guidance; a new root without guidance must
Expand Down Expand Up @@ -281,7 +306,9 @@ export const onRequestPost: PagesFunction<HubEnv> = async (ctx) => {
}
throw error
}
if ((results[0]?.meta.changes ?? 0) === 0) throw new ApiError('NOT_FOUND')
if ((results[sessionCommitIndex]?.meta.changes ?? 0) === 0) {
throw new ApiError('NOT_FOUND')
}

auditAfterCommit(ctx, {
user_id: user.id,
Expand All @@ -292,6 +319,7 @@ export const onRequestPost: PagesFunction<HubEnv> = async (ctx) => {
count: body.count,
visibility: effectiveVisibility,
team_id: teamId,
project_id: projectId,
},
})

Expand Down
Loading