Publish DevRunbook source
Managed validation / full (push) Successful in 3m18s

This commit is contained in:
DevRunbook release export
2026-09-03 04:09:17 +02:00
commit cfd2804e27
928 changed files with 161642 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+39
View File
@@ -0,0 +1,39 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
reactStrictMode: true,
poweredByHeader: false,
serverExternalPackages: ['postgres'],
transpilePackages: [
'@devrunbook/config',
'@devrunbook/content',
'@devrunbook/db',
'@devrunbook/ui',
],
experimental: {
typedEnv: true,
},
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-Frame-Options', value: 'DENY' },
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
],
},
]
},
}
export default nextConfig
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@devrunbook/web",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev --hostname 127.0.0.1",
"lint": "eslint . --max-warnings=0",
"start": "next start --hostname 0.0.0.0",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@devrunbook/application": "workspace:*",
"@devrunbook/artifacts": "workspace:*",
"@devrunbook/composer": "workspace:*",
"@devrunbook/config": "workspace:*",
"@devrunbook/content": "workspace:*",
"@devrunbook/db": "workspace:*",
"@devrunbook/integrations": "workspace:*",
"@devrunbook/repository-intel": "workspace:*",
"@devrunbook/ui": "workspace:*",
"better-auth": "1.6.25",
"lucide-react": "1.27.0",
"next": "16.2.12",
"react": "19.2.8",
"react-dom": "19.2.8",
"yaml": "2.9.0",
"zod": "4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "24.13.3",
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.3.3",
"typescript": "5.9.3",
"vitest": "4.1.10"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
@@ -0,0 +1,108 @@
import type { ActorContext } from '@devrunbook/application'
import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation'
import type { ReactNode } from 'react'
import type { CommandPaletteItem } from '../../components/command-palette/command-palette'
import { AppShell } from '../../components/shell/app-shell'
import { isThemePreference } from '../../components/theme/theme-model'
import {
detectLocale,
isPresentationMode,
} from '../../components/presentation/presentation-model'
import { getAuth } from '../../auth/auth'
import {
AuthenticatedWorkspaceContextError,
resolveAuthenticatedWorkspaceContext,
} from '../../server/authenticated-workspace-context'
import { DrizzleWorkspaceSelectionLookup } from '@devrunbook/db'
import {
authenticatedCommands,
buildAuthenticatedShellPresentation,
buildLoginHref,
commandsForWorkspaceRole,
localizeAuthenticatedCommands,
} from './authenticated-app-presentation'
export interface AuthenticatedAppLayoutProps {
readonly children: ReactNode
readonly requestPath: string
readonly loginReturnTo: string
readonly activeNavigationId: string
readonly commands?: readonly CommandPaletteItem[]
readonly loadCommands?: (
actor: ActorContext,
) => Promise<readonly CommandPaletteItem[]>
}
export async function AuthenticatedAppLayout({
children,
requestPath,
loginReturnTo,
activeNavigationId,
commands = authenticatedCommands,
loadCommands,
}: AuthenticatedAppLayoutProps) {
let actor: ActorContext
const requestHeaders = await headers()
try {
actor = await resolveAuthenticatedWorkspaceContext(
new Request(new URL(requestPath, 'http://devrunbook.local'), {
headers: requestHeaders,
}),
)
} catch (error) {
if (
error instanceof AuthenticatedWorkspaceContextError &&
error.code === 'authentication_required'
) {
redirect(buildLoginHref(loginReturnTo))
}
throw error
}
const cookieStore = await cookies()
const themeCookie = cookieStore.get('devrunbook_theme')?.value
const initialTheme = isThemePreference(themeCookie) ? themeCookie : 'system'
const modeCookie = cookieStore.get('devrunbook_mode')?.value
const mode = isPresentationMode(modeCookie) ? modeCookie : 'simple'
const locale = detectLocale(
cookieStore.get('devrunbook_locale')?.value,
requestHeaders.get('accept-language'),
)
const session = await getAuth().api.getSession({ headers: requestHeaders })
const workspaces =
await new DrizzleWorkspaceSelectionLookup().listAuthorizedWorkspaces(
actor.userId,
)
const presentation = buildAuthenticatedShellPresentation({
...actor,
mode,
locale,
workspaces: workspaces.map(({ id, name, type }) => ({ id, name, type })),
...(session?.user.name ? { displayName: session.user.name } : {}),
...(session?.user.email ? { email: session.user.email } : {}),
})
const resolvedCommands = localizeAuthenticatedCommands(
commandsForWorkspaceRole(
loadCommands ? await loadCommands(actor) : commands,
actor.workspaceRole,
),
locale,
)
return (
<AppShell
activeNavigationId={activeNavigationId}
navigation={presentation.navigation}
workspaces={presentation.workspaces}
activeWorkspaceId={presentation.activeWorkspaceId}
actor={presentation.actor}
initialTheme={initialTheme}
locale={locale}
commands={resolvedCommands}
>
{children}
</AppShell>
)
}
@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest'
import {
authenticatedCommands,
authenticatedNavigation,
buildAuthenticatedShellPresentation,
buildLoginHref,
commandsForWorkspaceRole,
localizeAuthenticatedCommands,
} from './authenticated-app-presentation'
describe('authenticated app shell presentation', () => {
it('keeps the established navigation contract in one shared definition', () => {
expect(authenticatedNavigation).toEqual([
{ id: 'home', label: 'Start', href: '/start', mobile: true },
{ id: 'library', label: 'Library', href: '/library', mobile: true },
{ id: 'collections', label: 'Collections', href: '/collections' },
{
id: 'repositories',
label: 'Repositories',
href: '/repositories',
mobile: true,
},
{
id: 'compose',
label: 'Compose',
href: '/composer/new',
mobile: true,
},
{
id: 'prompt-lab',
label: 'Prompt Lab',
href: '/prompt-lab',
},
{
id: 'operations',
label: 'Operations',
href: '/operations',
},
{
id: 'settings',
label: 'Settings',
href: '/settings/integrations',
},
])
})
it('defaults to plain navigation and gates management by role', () => {
expect(
buildAuthenticatedShellPresentation({
workspaceId: 'workspace-1',
workspaceRole: 'owner',
locale: 'nl',
mode: 'simple',
}).navigation.map(({ label, href }) => ({ label, href })),
).toEqual([
{ label: 'Nieuwe taak', href: '/start' },
{ label: 'Taken', href: '/runs' },
{ label: 'Projecten', href: '/repositories' },
{ label: 'Taakbibliotheek', href: '/library' },
{ label: 'Instellingen', href: '/settings/integrations' },
{ label: 'Beheer', href: '/management' },
])
expect(
buildAuthenticatedShellPresentation({
workspaceId: 'workspace-2',
workspaceRole: 'viewer',
mode: 'simple',
}).navigation.some(({ id }) => id === 'management'),
).toBe(false)
expect(
buildAuthenticatedShellPresentation({
workspaceId: 'workspace-2',
workspaceRole: 'viewer',
mode: 'expert',
}).navigation,
).toEqual(authenticatedNavigation.filter(({ id }) => id !== 'compose'))
expect(
buildAuthenticatedShellPresentation({
workspaceId: 'workspace-2',
workspaceRole: 'viewer',
locale: 'nl',
mode: 'expert',
}).navigation.map(({ label }) => label),
).toEqual([
'Start',
'Taakbibliotheek',
'Verzamelingen',
'Projecten',
'Prompt Lab',
'Activiteit en taken',
'Instellingen',
])
})
it('presents every authorized workspace without replacing its identity', () => {
const workspaces = [
{ id: 'workspace-1', name: 'Persoonlijk', type: 'personal' as const },
{ id: 'workspace-2', name: 'Team', type: 'team' as const },
]
expect(
buildAuthenticatedShellPresentation({
workspaceId: 'workspace-2',
workspaceRole: 'editor',
workspaces,
}).workspaces,
).toEqual(workspaces)
})
it('offers the common authenticated destinations in the command palette', () => {
expect(authenticatedCommands.map(({ id, href }) => ({ id, href }))).toEqual(
[
{ id: 'home', href: '/start' },
{ id: 'library', href: '/library' },
{ id: 'repositories', href: '/repositories' },
{ id: 'collections', href: '/collections' },
{ id: 'compose', href: '/composer/new' },
{ id: 'prompt-lab', href: '/prompt-lab' },
{ id: 'operations', href: '/operations' },
{ id: 'settings-integrations', href: '/settings/integrations' },
],
)
})
it('removes write-only composition commands for viewers', () => {
expect(
commandsForWorkspaceRole(authenticatedCommands, 'viewer').map(
({ id }) => id,
),
).not.toContain('compose')
expect(
commandsForWorkspaceRole(authenticatedCommands, 'editor').map(
({ id }) => id,
),
).toContain('compose')
})
it('localizes common command destinations without changing their targets', () => {
const localized = localizeAuthenticatedCommands(authenticatedCommands, 'nl')
expect(localized.find(({ id }) => id === 'home')).toMatchObject({
label: 'Nieuwe taak',
href: '/start',
})
expect(localized.find(({ id }) => id === 'library')).toMatchObject({
label: 'Taakbibliotheek openen',
href: '/library',
})
})
it('preserves the current workspace and actor presentation', () => {
expect(
buildAuthenticatedShellPresentation({
workspaceId: 'workspace-1',
workspaceRole: 'owner',
displayName: 'Jens Example',
email: 'jens@example.test',
}),
).toMatchObject({
activeWorkspaceId: 'workspace-1',
workspaces: [
{
id: 'workspace-1',
name: 'Active workspace',
type: 'personal',
},
],
actor: {
displayName: 'Jens Example',
email: 'jens@example.test',
initials: 'JE',
role: 'Owner',
},
})
expect(
buildAuthenticatedShellPresentation({
workspaceId: 'workspace-2',
workspaceRole: 'editor',
}).workspaces[0]?.type,
).toBe('team')
})
it('preserves route-specific local login return targets', () => {
expect(buildLoginHref('/library')).toBe('/login?returnTo=%2Flibrary')
expect(buildLoginHref('/composer/new')).toBe(
'/login?returnTo=%2Fcomposer%2Fnew',
)
expect(buildLoginHref('/repositories/repository-1')).toBe(
'/login?returnTo=%2Frepositories%2Frepository-1',
)
})
})
@@ -0,0 +1,301 @@
import type {
ActorPresentation,
AppNavigationItem,
WorkspaceOption,
} from '../../components/shell/shell-types'
import type { CommandPaletteItem } from '../../components/command-palette/command-model'
import type {
PresentationMode,
SupportedLocale,
} from '../../components/presentation/presentation-model'
export interface AuthenticatedShellActor {
readonly workspaceId: string
readonly workspaceRole: 'owner' | 'editor' | 'viewer'
readonly displayName?: string
readonly email?: string
readonly mode?: PresentationMode
readonly locale?: SupportedLocale
readonly workspaces?: readonly WorkspaceOption[]
}
export interface AuthenticatedShellPresentation {
readonly navigation: readonly AppNavigationItem[]
readonly workspaces: readonly WorkspaceOption[]
readonly activeWorkspaceId: string
readonly actor: ActorPresentation
}
export const authenticatedNavigation: readonly AppNavigationItem[] = [
{ id: 'home', label: 'Start', href: '/start', mobile: true },
{ id: 'library', label: 'Library', href: '/library', mobile: true },
{ id: 'collections', label: 'Collections', href: '/collections' },
{
id: 'repositories',
label: 'Repositories',
href: '/repositories',
mobile: true,
},
{
id: 'compose',
label: 'Compose',
href: '/composer/new',
mobile: true,
},
{
id: 'prompt-lab',
label: 'Prompt Lab',
href: '/prompt-lab',
},
{
id: 'operations',
label: 'Operations',
href: '/operations',
},
{
id: 'settings',
label: 'Settings',
href: '/settings/integrations',
},
]
const expertNavigationLabelsNl: Readonly<Record<string, string>> = {
home: 'Start',
library: 'Taakbibliotheek',
collections: 'Verzamelingen',
repositories: 'Projecten',
compose: 'Taak opstellen',
'prompt-lab': 'Prompt Lab',
operations: 'Activiteit en taken',
settings: 'Instellingen',
}
const simpleNavigationByLocale: Readonly<
Record<SupportedLocale, readonly AppNavigationItem[]>
> = {
en: [
{ id: 'home', label: 'New task', href: '/start', mobile: true },
{ id: 'runs', label: 'Tasks', href: '/runs', mobile: true },
{
id: 'repositories',
label: 'Projects',
href: '/repositories',
mobile: true,
},
{ id: 'library', label: 'Task library', href: '/library', mobile: true },
{ id: 'settings', label: 'Settings', href: '/settings/integrations' },
],
nl: [
{ id: 'home', label: 'Nieuwe taak', href: '/start', mobile: true },
{ id: 'runs', label: 'Taken', href: '/runs', mobile: true },
{
id: 'repositories',
label: 'Projecten',
href: '/repositories',
mobile: true,
},
{ id: 'library', label: 'Taakbibliotheek', href: '/library', mobile: true },
{ id: 'settings', label: 'Instellingen', href: '/settings/integrations' },
],
}
export const authenticatedCommands: readonly CommandPaletteItem[] = [
{
id: 'home',
label: 'Start a task',
description: 'Choose a project and what you want to do',
href: '/start',
keywords: ['home', 'dashboard', 'quick start'],
},
{
id: 'library',
label: 'Open Library',
description: 'Search and filter governed playbooks',
href: '/library',
keywords: ['catalog', 'playbooks'],
},
{
id: 'repositories',
label: 'Open Repositories',
description: 'Review reusable repository context and constraints',
href: '/repositories',
keywords: ['profiles', 'commands', 'paths'],
},
{
id: 'collections',
label: 'Open Collections',
description: 'Organize accessible playbooks into personal named sets',
href: '/collections',
keywords: ['library', 'saved', 'groups'],
},
{
id: 'compose',
label: 'Start a composition',
description: 'Select a playbook and repository context',
href: '/composer/new',
keywords: ['prompt', 'run pack'],
},
{
id: 'prompt-lab',
label: 'Open Prompt Lab',
description: 'Import, inspect and govern private playbook packages',
href: '/prompt-lab',
keywords: ['packages', 'authoring', 'evaluation', 'publish'],
},
{
id: 'operations',
label: 'Open Operations',
description: 'Inspect worker jobs, safe failures and audit history',
href: '/operations',
keywords: ['jobs', 'queue', 'audit', 'health'],
},
{
id: 'settings-integrations',
label: 'Open Integrations',
description: 'Review read-only forge connections and capability health',
href: '/settings/integrations',
keywords: ['settings', 'gitea', 'connections'],
},
]
const commandCopy: Readonly<
Record<
SupportedLocale,
Readonly<Record<string, Pick<CommandPaletteItem, 'label' | 'description'>>>
>
> = {
en: {},
nl: {
home: {
label: 'Nieuwe taak',
description: 'Kies een project en beschrijf wat je wilt bereiken',
},
library: {
label: 'Taakbibliotheek openen',
description: 'Zoek een veilig en herbruikbaar taaktype',
},
repositories: {
label: 'Projecten openen',
description: 'Bekijk projectgegevens, grenzen en synchronisatiestatus',
},
collections: {
label: 'Verzamelingen openen',
description: 'Groepeer bewaarde taaktypes',
},
compose: {
label: 'Geavanceerde taak opstellen',
description: 'Kies zelf een taaktype en projectcontext',
},
'prompt-lab': {
label: 'Prompt Lab openen',
description: 'Beheer private taaktypepakketten',
},
operations: {
label: 'Activiteit en taken openen',
description: 'Bekijk achtergrondtaken en veilige foutmeldingen',
},
'settings-integrations': {
label: 'Koppelingen openen',
description: 'Beheer alleen-lezen forgeverbindingen',
},
},
}
export function commandsForWorkspaceRole(
commands: readonly CommandPaletteItem[],
role: AuthenticatedShellActor['workspaceRole'],
): readonly CommandPaletteItem[] {
return role === 'viewer'
? commands.filter((command) => command.id !== 'compose')
: commands
}
export function localizeAuthenticatedCommands(
commands: readonly CommandPaletteItem[],
locale: SupportedLocale,
): readonly CommandPaletteItem[] {
const translations = commandCopy[locale]
return commands.map((command) => ({
...command,
...(translations[command.id] ?? {}),
}))
}
export function buildLoginHref(returnTo: string): string {
return `/login?returnTo=${encodeURIComponent(returnTo)}`
}
export function buildAuthenticatedShellPresentation(
actor: AuthenticatedShellActor,
): AuthenticatedShellPresentation {
const locale = actor.locale ?? 'en'
const mode = actor.mode ?? 'simple'
const management =
actor.workspaceRole === 'owner'
? [
{
id: 'management',
label: locale === 'nl' ? 'Beheer' : 'Management',
href: '/management',
},
]
: []
return {
navigation:
mode === 'expert'
? authenticatedNavigation
.filter(
(item) =>
actor.workspaceRole !== 'viewer' || item.id !== 'compose',
)
.map((item) => ({
...item,
label:
locale === 'nl'
? (expertNavigationLabelsNl[item.id] ?? item.label)
: item.label,
}))
: [...simpleNavigationByLocale[locale], ...management],
workspaces:
actor.workspaces && actor.workspaces.length > 0
? actor.workspaces
: [
{
id: actor.workspaceId,
name: locale === 'nl' ? 'Actieve werkruimte' : 'Active workspace',
type: actor.workspaceRole === 'owner' ? 'personal' : 'team',
},
],
activeWorkspaceId: actor.workspaceId,
actor: {
displayName: actor.displayName?.trim() || actor.email || 'Signed-in user',
email: actor.email ?? 'Account email unavailable',
initials: initials(actor.displayName?.trim() || actor.email || 'DR'),
role: workspaceRoleLabel(actor.workspaceRole, locale),
},
}
}
function initials(value: string): string {
const emailName = value.split('@')[0] ?? value
const parts = emailName.split(/[\s._-]+/u).filter(Boolean)
return (
parts.length > 1
? `${parts[0]![0]}${parts.at(-1)![0]}`
: emailName.slice(0, 2)
).toUpperCase()
}
function workspaceRoleLabel(
role: AuthenticatedShellActor['workspaceRole'],
locale: SupportedLocale,
) {
if (locale === 'nl') {
return role === 'owner'
? 'Eigenaar'
: role === 'editor'
? 'Bewerker'
: 'Lezer'
}
return role === 'owner' ? 'Owner' : role === 'editor' ? 'Editor' : 'Viewer'
}
@@ -0,0 +1,237 @@
'use client'
import { ArrowRight, CheckCircle2, ShieldCheck, UserPlus } from 'lucide-react'
import Link from 'next/link'
import { type FormEvent, useEffect, useRef, useState } from 'react'
import { PublicLocaleControl } from '../../components/presentation/public-locale-control'
import type { SupportedLocale } from '../../components/presentation/presentation-model'
const tokenPattern = /^[A-Za-z0-9_-]{32,512}$/u
const inputClassName =
'mt-2 w-full rounded-xl border border-white/12 bg-black/30 px-3.5 py-3 text-sm text-white focus:border-lime-300/55 focus:outline-none disabled:opacity-50'
const invitationCopy = {
en: {
invalid: 'This invitation is invalid, expired, or already used.',
validation:
'Check your name and matching password of at least 12 characters.',
eyebrow: 'Private self-hosted access',
titleBefore: "Join your team's ",
titleAccent: 'control plane',
titleAfter: '.',
intro:
'The link is single-use and bound to your assigned instance role and optional workspace membership. Your password remains local to this instance.',
created: 'Account created',
accept: 'Accept invitation',
continue: 'Continue to sign in',
displayName: 'Display name',
password: 'Password',
confirm: 'Confirm password',
creating: 'Creating account…',
create: 'Create local account',
},
nl: {
invalid: 'Deze uitnodiging is ongeldig, verlopen of al gebruikt.',
validation:
'Controleer je naam en vul tweemaal hetzelfde wachtwoord van minstens 12 tekens in.',
eyebrow: 'Privétoegang tot je eigen server',
titleBefore: 'Word lid van het ',
titleAccent: 'beheerplatform',
titleAfter: ' van je team.',
intro:
'De link werkt één keer en is gekoppeld aan je toegewezen rol en eventuele werkruimte. Je wachtwoord blijft op deze DevRunbook-server.',
created: 'Account aangemaakt',
accept: 'Uitnodiging aanvaarden',
continue: 'Verder naar aanmelden',
displayName: 'Weergavenaam',
password: 'Wachtwoord',
confirm: 'Wachtwoord bevestigen',
creating: 'Account aanmaken…',
create: 'Lokaal account aanmaken',
},
} as const
export function AcceptInvitationExperience({
locale,
}: {
readonly locale: SupportedLocale
}) {
const copy = invitationCopy[locale]
const [token, setToken] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [completed, setCompleted] = useState(false)
const [error, setError] = useState<string | null>(null)
const errorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const candidate = new URLSearchParams(window.location.hash.slice(1)).get(
'token',
)
setToken(candidate && tokenPattern.test(candidate) ? candidate : null)
window.history.replaceState(null, '', '/accept-invitation')
if (!candidate || !tokenPattern.test(candidate)) {
setError(copy.invalid)
}
}, [copy.invalid])
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const displayName = data.get('displayName')
const password = data.get('password')
const passwordConfirmation = data.get('passwordConfirmation')
if (
!token ||
typeof displayName !== 'string' ||
!displayName.trim() ||
typeof password !== 'string' ||
password.length < 12 ||
passwordConfirmation !== password
) {
setError(copy.validation)
return
}
setSubmitting(true)
setError(null)
try {
const response = await fetch('/api/v1/auth/invitations/accept', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
token,
displayName: displayName.trim(),
password,
passwordConfirmation,
}),
})
if (!response.ok) throw new Error('failed')
setCompleted(true)
setToken(null)
} catch {
setError(copy.invalid)
requestAnimationFrame(() => errorRef.current?.focus())
} finally {
for (const name of ['password', 'passwordConfirmation']) {
const input = form.elements.namedItem(name)
if (input instanceof HTMLInputElement) input.value = ''
}
setSubmitting(false)
}
}
return (
<main className="mx-auto grid min-h-screen max-w-6xl items-center gap-12 px-5 py-10 lg:grid-cols-2 lg:px-10">
<section className="order-2 lg:order-1">
<div className="hidden items-center justify-between gap-4 lg:flex">
<Link
href="/"
className="inline-flex items-center gap-3 no-underline"
>
<span className="grid size-10 place-items-center rounded-xl bg-lime-300 text-black">
<ShieldCheck aria-hidden="true" size={21} />
</span>
<span className="font-semibold">DevRunbook</span>
</Link>
<PublicLocaleControl locale={locale} />
</div>
<p className="mt-16 text-xs uppercase tracking-[0.2em] text-lime-300/80">
{copy.eyebrow}
</p>
<h1 className="mt-4 text-5xl font-medium tracking-[-0.04em] sm:text-6xl">
{copy.titleBefore}
<span className="text-lime-300">{copy.titleAccent}</span>
{copy.titleAfter}
</h1>
<p className="mt-6 max-w-xl leading-7 text-white/52">{copy.intro}</p>
</section>
<section className="order-1 mx-auto w-full max-w-md rounded-3xl border border-white/10 bg-white/[0.035] p-6 sm:p-8 lg:order-2">
<div className="mb-8 flex items-center justify-between lg:hidden">
<Link href="/" className="flex items-center gap-3 no-underline">
<span className="grid size-9 place-items-center rounded-xl bg-lime-300 text-black">
<ShieldCheck aria-hidden="true" size={20} />
</span>
<span className="text-sm font-semibold">DevRunbook</span>
</Link>
<PublicLocaleControl locale={locale} />
</div>
<span className="grid size-11 place-items-center rounded-2xl bg-lime-300/10 text-lime-300">
{completed ? (
<CheckCircle2 aria-hidden="true" />
) : (
<UserPlus aria-hidden="true" />
)}
</span>
<h2 className="mt-6 text-3xl font-medium">
{completed ? copy.created : copy.accept}
</h2>
{completed ? (
<Link
href="/login"
className="mt-8 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-lime-300 px-5 py-3.5 font-semibold text-black no-underline"
>
{copy.continue} <ArrowRight aria-hidden="true" size={17} />
</Link>
) : (
<form onSubmit={submit} className="mt-8 space-y-5">
<label className="block text-sm font-medium">
{copy.displayName}
<input
name="displayName"
autoComplete="name"
maxLength={120}
required
className={inputClassName}
/>
</label>
<label className="block text-sm font-medium">
{copy.password}
<input
name="password"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
className={inputClassName}
/>
</label>
<label className="block text-sm font-medium">
{copy.confirm}
<input
name="passwordConfirmation"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
className={inputClassName}
/>
</label>
<div
ref={errorRef}
tabIndex={-1}
role={error ? 'alert' : undefined}
className={
error
? 'rounded-xl border border-red-300/20 bg-red-300/[0.07] p-4 text-sm text-red-100'
: 'sr-only'
}
>
{error}
</div>
<button
disabled={!token || submitting}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-lime-300 px-5 py-3.5 font-semibold text-black disabled:opacity-50"
>
{submitting ? copy.creating : copy.create}
{!submitting && <ArrowRight aria-hidden="true" size={17} />}
</button>
</form>
)}
</section>
</main>
)
}
@@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import { resolvePublicLocale } from '../../server/public-locale'
import { AcceptInvitationExperience } from './accept-invitation-experience'
export async function generateMetadata(): Promise<Metadata> {
const locale = await resolvePublicLocale()
return locale === 'nl'
? {
title: 'Uitnodiging aanvaarden · DevRunbook',
description: 'Maak een lokaal account met een eenmalige uitnodiging.',
}
: {
title: 'Accept your DevRunbook invitation',
description: 'Create a local account from a single-use invitation.',
}
}
export default async function AcceptInvitationPage() {
return <AcceptInvitationExperience locale={await resolvePublicLocale()} />
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import { AuthenticatedAppLayout } from '../_authenticated/authenticated-app-layout'
export default function AccountLayout({
children,
}: {
readonly children: ReactNode
}) {
return (
<AuthenticatedAppLayout
activeNavigationId="account"
loginReturnTo="/account"
requestPath="/account"
>
{children}
</AuthenticatedAppLayout>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { getAuth } from '../../auth/auth'
import {
detectLocale,
isPresentationMode,
} from '../../components/presentation/presentation-model'
import { resolveAuthenticatedPageContext } from '../../server/authenticated-page-context'
import { PresentationPreferences } from './presentation-preferences'
export const dynamic = 'force-dynamic'
export default async function AccountPage() {
const cookieStore = await cookies()
const requestHeaders = await headers()
const [actor, session] = await Promise.all([
resolveAuthenticatedPageContext('/account'),
getAuth().api.getSession({ headers: requestHeaders }),
])
if (!session) redirect('/login?returnTo=%2Faccount')
const modeCookie = cookieStore.get('devrunbook_mode')?.value
const mode = isPresentationMode(modeCookie) ? modeCookie : 'simple'
const locale = detectLocale(
cookieStore.get('devrunbook_locale')?.value,
requestHeaders.get('accept-language'),
)
const nl = locale === 'nl'
const role =
actor.workspaceRole === 'owner'
? nl
? 'Eigenaar'
: 'Owner'
: actor.workspaceRole === 'editor'
? nl
? 'Bewerker'
: 'Editor'
: nl
? 'Lezer'
: 'Viewer'
return (
<section className="drb-page" aria-labelledby="account-title">
<header className="drb-page-header">
<p>Account</p>
<h1 id="account-title">{nl ? 'Jouw account' : 'Your account'}</h1>
<p>
{nl
? 'Bekijk je identiteit, rechten en interfacevoorkeuren.'
: 'Review your identity, access and interface preferences.'}
</p>
</header>
<section className="drb-panel" aria-labelledby="identity-title">
<h2 id="identity-title">
{nl ? 'Identiteit en toegang' : 'Identity and access'}
</h2>
<dl>
<div>
<dt>{nl ? 'Naam' : 'Name'}</dt>
<dd>{session.user.name}</dd>
</div>
<div>
<dt>Email</dt>
<dd>{session.user.email}</dd>
</div>
<div>
<dt>{nl ? 'Rol in deze werkruimte' : 'Role in this workspace'}</dt>
<dd>{role}</dd>
</div>
</dl>
<p>
<a href="/account/security">
{nl
? 'Wachtwoord en sessies beheren'
: 'Manage password and sessions'}
</a>
</p>
</section>
<PresentationPreferences initialLocale={locale} initialMode={mode} />
</section>
)
}
@@ -0,0 +1,81 @@
'use client'
import { useState } from 'react'
import type {
PresentationMode,
SupportedLocale,
} from '../../components/presentation/presentation-model'
export function PresentationPreferences({
initialMode,
initialLocale,
}: {
readonly initialMode: PresentationMode
readonly initialLocale: SupportedLocale
}) {
const [mode, setMode] = useState(initialMode)
const [locale, setLocale] = useState(initialLocale)
const [state, setState] = useState<'idle' | 'saving' | 'failed'>('idle')
const nl = initialLocale === 'nl'
async function save() {
setState('saving')
const response = await fetch('/api/v1/account/presentation', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode, locale }),
}).catch(() => null)
if (!response?.ok) {
setState('failed')
return
}
window.location.reload()
}
return (
<section className="drb-panel" aria-labelledby="presentation-title">
<h2 id="presentation-title">Interface</h2>
<label htmlFor="presentation-mode">
{nl ? 'Weergave' : 'Interface mode'}
</label>
<select
id="presentation-mode"
onChange={(event) => setMode(event.target.value as PresentationMode)}
value={mode}
>
<option value="simple">{nl ? 'Eenvoudig' : 'Simple'}</option>
<option value="expert">Expert</option>
</select>
<label htmlFor="presentation-locale">{nl ? 'Taal' : 'Language'}</label>
<select
id="presentation-locale"
onChange={(event) => setLocale(event.target.value as SupportedLocale)}
value={locale}
>
<option value="en">English</option>
<option value="nl">Nederlands</option>
</select>
<button
disabled={state === 'saving'}
onClick={() => void save()}
type="button"
>
{state === 'saving'
? nl
? 'Opslaan…'
: 'Saving…'
: nl
? 'Voorkeuren opslaan'
: 'Save preferences'}
</button>
{state === 'failed' ? (
<p role="alert">
{nl
? 'Je voorkeuren konden niet worden opgeslagen. Probeer opnieuw.'
: 'Your preferences could not be saved. Please try again.'}
</p>
) : null}
</section>
)
}
@@ -0,0 +1,43 @@
import { cookies, headers } from 'next/headers'
import { detectLocale } from '../../../components/presentation/presentation-model'
import { resolveAuthenticatedPageContext } from '../../../server/authenticated-page-context'
import { SessionManager } from './session-manager'
export const dynamic = 'force-dynamic'
export default async function AccountSecurityPage() {
await resolveAuthenticatedPageContext('/account/security')
const locale = detectLocale(
(await cookies()).get('devrunbook_locale')?.value,
(await headers()).get('accept-language'),
)
const nl = locale === 'nl'
return (
<section className="drb-page" aria-labelledby="security-title">
<header className="drb-page-header">
<p>{nl ? 'Accountbeveiliging' : 'Account security'}</p>
<h1 id="security-title">
{nl ? 'Wachtwoord en sessies' : 'Password and sessions'}
</h1>
<p>
{nl
? 'Controleer aangemelde apparaten en trek onbekende toegang in.'
: 'Review signed-in devices and revoke access you no longer recognize.'}
</p>
</header>
<SessionManager locale={locale} />
<section className="drb-panel" aria-labelledby="password-title">
<h2 id="password-title">{nl ? 'Wachtwoord' : 'Password'}</h2>
<p>
{nl
? 'Een wachtwoordwijziging meldt bestaande sessies af om je account te beschermen.'
: 'Password changes sign out existing sessions for your protection.'}
</p>
<a href="/reset-password">
{nl ? 'Wachtwoord veilig herstellen' : 'Reset password securely'}
</a>
</section>
</section>
)
}
@@ -0,0 +1,110 @@
'use client'
import { useEffect, useState } from 'react'
import type { SupportedLocale } from '../../../components/presentation/presentation-model'
interface SessionView {
readonly id: string
readonly createdAt: string
readonly lastSeenAt: string
readonly current: boolean
readonly userAgentSummary?: string
}
export function SessionManager({
locale,
}: {
readonly locale: SupportedLocale
}) {
const [sessions, setSessions] = useState<readonly SessionView[]>([])
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
const nl = locale === 'nl'
async function load() {
try {
const response = await fetch('/api/v1/auth/sessions')
if (!response.ok) throw new Error('load failed')
setSessions((await response.json()) as readonly SessionView[])
setState('ready')
} catch {
setState('error')
}
}
useEffect(() => {
void load()
}, [])
async function revoke(sessionId: string) {
const response = await fetch(
'/api/v1/auth/sessions/' + encodeURIComponent(sessionId),
{ method: 'DELETE' },
)
if (!response.ok) {
setState('error')
return
}
await load()
}
return (
<section className="drb-panel" aria-labelledby="sessions-title">
<h2 id="sessions-title">{nl ? 'Actieve sessies' : 'Active sessions'}</h2>
<div aria-live="polite">
{state === 'loading' ? (
<p>{nl ? 'Sessies laden…' : 'Loading sessions…'}</p>
) : null}
{state === 'error' ? (
<p role="alert">
{nl
? 'Sessies konden niet worden geladen. Probeer opnieuw.'
: 'Sessions could not be loaded. Please try again.'}
</p>
) : null}
</div>
{state === 'ready' && sessions.length === 0 ? (
<p>
{nl
? 'Er zijn geen actieve sessies gevonden.'
: 'No active sessions were found.'}
</p>
) : null}
{state === 'ready' ? (
<ul>
{sessions.map((session) => (
<li key={session.id}>
<strong>
{session.current
? nl
? 'Deze sessie'
: 'This session'
: nl
? 'Aangemelde sessie'
: 'Signed-in session'}
</strong>
<span>
{session.userAgentSummary ??
(nl
? 'Apparaatgegevens niet beschikbaar'
: 'Device details unavailable')}
</span>
<span>
{nl ? 'Laatst actief' : 'Last active'}{' '}
{new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(session.lastSeenAt))}
</span>
{!session.current ? (
<button onClick={() => void revoke(session.id)} type="button">
{nl ? 'Sessie intrekken' : 'Revoke session'}
</button>
) : null}
</li>
))}
</ul>
) : null}
</section>
)
}
@@ -0,0 +1,21 @@
import { getAuth } from '@/auth/auth'
import { handleAuthRequest } from '@/auth/csrf'
export const dynamic = 'force-dynamic'
function handle(request: Request) {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) {
return Response.json(
{ code: 'AUTH_UNAVAILABLE', message: 'Authentication is unavailable' },
{ status: 503 },
)
}
return handleAuthRequest(request, getAuth().handler, publicBaseUrl)
}
export const GET = handle
export const POST = handle
export const PATCH = handle
export const PUT = handle
export const DELETE = handle
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import {
handlePersonalDataDeletion,
handlePersonalDataExport,
type PersonalDataRouteDependencies,
} from './personal-data-route'
const origin = 'https://runbook.example.test'
function dependencies(
overrides: Partial<PersonalDataRouteDependencies> = {},
): PersonalDataRouteDependencies {
return {
publicBaseUrl: origin,
resolveUserId: vi.fn().mockResolvedValue('user-1'),
confirmPassword: vi.fn().mockResolvedValue(true),
exportForUser: vi.fn().mockResolvedValue({
schemaVersion: 'devrunbook.personal-data/v1',
profile: { id: 'user-1', email: 'user@example.test' },
}),
anonymizeUser: vi.fn().mockResolvedValue(true),
...overrides,
}
}
function request(method: 'POST' | 'DELETE', requestOrigin = origin) {
return new Request(`${origin}/api/v1/account/personal-data`, {
method,
headers: { 'content-type': 'application/json', origin: requestOrigin },
body: JSON.stringify({ password: 'correct horse battery staple' }),
})
}
describe('personal data HTTP boundary', () => {
it('requires password confirmation and exports without credential material', async () => {
const deps = dependencies()
const response = await handlePersonalDataExport(request('POST'), deps)
const body = await response.text()
expect(response.status).toBe(200)
expect(response.headers.get('content-disposition')).toContain('attachment')
expect(body).toContain('devrunbook.personal-data/v1')
expect(body).not.toContain('correct horse battery staple')
expect(body).not.toContain('passwordHash')
})
it('denies wrong passwords and cross-origin requests before data access', async () => {
const wrong = dependencies({
confirmPassword: vi.fn().mockResolvedValue(false),
})
expect(
(await handlePersonalDataExport(request('POST'), wrong)).status,
).toBe(403)
expect(wrong.exportForUser).not.toHaveBeenCalled()
const crossOrigin = dependencies()
expect(
(
await handlePersonalDataDeletion(
request('DELETE', 'https://attacker.test'),
crossOrigin,
)
).status,
).toBe(403)
expect(crossOrigin.resolveUserId).not.toHaveBeenCalled()
})
it('anonymizes the authenticated non-owner and revokes access', async () => {
const deps = dependencies()
const response = await handlePersonalDataDeletion(request('DELETE'), deps)
expect(response.status).toBe(204)
expect(deps.anonymizeUser).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1' }),
)
})
it('keeps instance-owner deletion behind an explicit ownership transfer', async () => {
const deps = dependencies({
anonymizeUser: vi.fn().mockResolvedValue(false),
})
const response = await handlePersonalDataDeletion(request('DELETE'), deps)
expect(response.status).toBe(409)
})
})
@@ -0,0 +1,159 @@
import { handleAuthRequest } from '../../../../../auth/csrf'
const maximumBodyBytes = 1_024
export interface PersonalDataRouteDependencies {
readonly publicBaseUrl: string
readonly resolveUserId: (headers: Headers) => Promise<string | null>
readonly confirmPassword: (
userId: string,
password: string,
) => Promise<boolean>
readonly exportForUser: (userId: string) => Promise<unknown | null>
readonly anonymizeUser: (input: {
readonly userId: string
readonly requestId: string
}) => Promise<boolean>
}
function error(
status: number,
code: string,
message: string,
requestId: string,
) {
return Response.json({ error: { code, message, requestId } }, { status })
}
async function passwordFromRequest(request: Request) {
if (
request.headers.get('content-type')?.split(';', 1)[0]?.trim() !==
'application/json'
)
return null
const declared = Number(request.headers.get('content-length') ?? 0)
if (declared > maximumBodyBytes) return null
if (!request.body) return null
const reader = request.body.getReader()
const decoder = new TextDecoder()
let bytes = 0
let body = ''
try {
while (true) {
const chunk = await reader.read()
if (chunk.done) break
bytes += chunk.value.byteLength
if (bytes > maximumBodyBytes) {
await reader.cancel()
return null
}
body += decoder.decode(chunk.value, { stream: true })
}
body += decoder.decode()
} finally {
reader.releaseLock()
}
try {
const value: unknown = JSON.parse(body)
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Record<string, unknown>
if (Object.keys(record).join(',') !== 'password') return null
return typeof record.password === 'string' && record.password.length <= 128
? record.password
: null
} catch {
return null
}
}
async function authorizeSensitiveRequest(
request: Request,
dependencies: PersonalDataRouteDependencies,
requestId: string,
) {
const userId = await dependencies.resolveUserId(request.headers)
if (!userId)
return {
response: error(
401,
'authentication_required',
'Authentication is required',
requestId,
),
}
const password = await passwordFromRequest(request)
if (!password || !(await dependencies.confirmPassword(userId, password))) {
return {
response: error(
403,
'recent_authentication_required',
'Password confirmation is required',
requestId,
),
}
}
return { userId }
}
export function handlePersonalDataExport(
request: Request,
dependencies: PersonalDataRouteDependencies,
) {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
const authorization = await authorizeSensitiveRequest(
sameOriginRequest,
dependencies,
requestId,
)
if ('response' in authorization) return authorization.response
const data = await dependencies.exportForUser(authorization.userId)
if (!data) return error(404, 'not_found', 'User was not found', requestId)
return new Response(JSON.stringify(data, null, 2), {
headers: {
'content-type': 'application/json; charset=utf-8',
'content-disposition':
'attachment; filename="devrunbook-personal-data.json"',
'cache-control': 'no-store',
},
})
},
dependencies.publicBaseUrl,
() => error(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
export function handlePersonalDataDeletion(
request: Request,
dependencies: PersonalDataRouteDependencies,
) {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
const authorization = await authorizeSensitiveRequest(
sameOriginRequest,
dependencies,
requestId,
)
if ('response' in authorization) return authorization.response
const deleted = await dependencies.anonymizeUser({
userId: authorization.userId,
requestId,
})
if (!deleted) {
return error(
409,
'deletion_not_permitted',
'Personal data deletion is not permitted for this account',
requestId,
)
}
return new Response(null, { status: 204 })
},
dependencies.publicBaseUrl,
() => error(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
@@ -0,0 +1,15 @@
import { createPersonalDataDependencies } from '../../../../../server/personal-data'
import {
handlePersonalDataDeletion,
handlePersonalDataExport,
} from './personal-data-route'
export const dynamic = 'force-dynamic'
export function POST(request: Request) {
return handlePersonalDataExport(request, createPersonalDataDependencies())
}
export function DELETE(request: Request) {
return handlePersonalDataDeletion(request, createPersonalDataDependencies())
}
@@ -0,0 +1,58 @@
import { z } from 'zod'
import { handleAuthRequest } from '../../../../../auth/csrf'
import {
presentationModes,
supportedLocales,
} from '../../../../../components/presentation/presentation-model'
import { resolveAuthenticatedWorkspaceContext } from '../../../../../server/authenticated-workspace-context'
export const dynamic = 'force-dynamic'
const requestSchema = z.strictObject({
mode: z.enum(presentationModes),
locale: z.enum(supportedLocales),
})
export function POST(request: Request) {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return handleAuthRequest(
request,
async () => {
try {
await resolveAuthenticatedWorkspaceContext(request)
const preference = requestSchema.parse(await request.json())
const secure = new URL(publicBaseUrl).protocol === 'https:'
const response = new Response(null, { status: 204 })
const suffix = `Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure ? '; Secure' : ''}`
response.headers.append(
'Set-Cookie',
`devrunbook_mode=${preference.mode}; ${suffix}`,
)
response.headers.append(
'Set-Cookie',
`devrunbook_locale=${preference.locale}; ${suffix}`,
)
return response
} catch (error) {
const code =
error !== null && typeof error === 'object' && 'code' in error
? error.code
: undefined
return Response.json(
{ error: { code: 'presentation_preference_invalid' } },
{
status:
code === 'authentication_required'
? 401
: code === 'workspace_access_denied'
? 403
: 422,
},
)
}
},
publicBaseUrl,
)
}
@@ -0,0 +1,17 @@
import { handleDownloadGeneratedArtifact } from '../../artifact-http'
import { generatedArtifactRouteDependencies } from '../../artifact-route-dependencies'
export const dynamic = 'force-dynamic'
export function GET(
request: Request,
context: { params: Promise<{ artifactId: string }> },
) {
return context.params.then(({ artifactId }) =>
handleDownloadGeneratedArtifact(
request,
artifactId,
generatedArtifactRouteDependencies(),
),
)
}
@@ -0,0 +1,212 @@
import type {
ActorContext,
GeneratedArtifactMetadata,
} from '@devrunbook/application'
import { describe, expect, it, vi } from 'vitest'
import {
contentDisposition,
handleCreateGeneratedArtifact,
handleDownloadGeneratedArtifact,
type GeneratedArtifactHttpService,
type GeneratedArtifactRouteDependencies,
} from './artifact-http'
const userId = '00000000-0000-4000-8000-000000000001'
const workspaceId = '00000000-0000-4000-8000-000000000002'
const runId = '00000000-0000-4000-8000-000000000003'
const artifactId = '00000000-0000-4000-8000-000000000004'
const actor: ActorContext = {
userId,
workspaceId,
instanceRole: 'user',
workspaceRole: 'editor',
}
const metadata: GeneratedArtifactMetadata = {
id: artifactId,
workspaceId,
runId,
artifactType: 'markdown',
storageKey: 'a'.repeat(64),
filename: 'DevRunbook-résumé-TASK.md',
mediaType: 'text/markdown; charset=utf-8',
sizeBytes: 7n,
sha256: 'b'.repeat(64),
expiresAt: '2026-10-25T12:00:00.000Z',
createdAt: '2026-07-27T12:00:00.000Z',
}
function service(created = true): GeneratedArtifactHttpService {
return {
create: vi.fn(async () => ({ artifact: metadata, created })),
download: vi.fn(async () => ({
artifact: metadata,
content: new TextEncoder().encode('# Task\n'),
})),
}
}
function dependencies(
overrides: Partial<GeneratedArtifactRouteDependencies> = {},
): GeneratedArtifactRouteDependencies {
return {
publicBaseUrl: 'https://devrunbook.example',
resolveContext: vi.fn(async () => actor),
service: service(),
...overrides,
}
}
function post(
body = '{"type":"markdown"}',
headers: Readonly<Record<string, string>> = {},
): Request {
return new Request(
`https://devrunbook.example/api/v1/runs/${runId}/artifacts`,
{
method: 'POST',
body,
headers: {
Origin: 'https://devrunbook.example',
'Content-Type': 'application/json',
'Idempotency-Key': 'artifact-export-1',
...headers,
},
},
)
}
describe('generated artifact HTTP boundary', () => {
it('creates synchronous artifacts and reports exact idempotent replays', async () => {
const createdService = service()
const created = await handleCreateGeneratedArtifact(
post(),
runId,
dependencies({ service: createdService }),
)
expect(created.status).toBe(201)
expect(created.headers.get('location')).toBe(
`/api/v1/artifacts/${artifactId}/download`,
)
expect(created.headers.get('cache-control')).toBe('no-store')
expect(created.headers.get('x-content-type-options')).toBe('nosniff')
expect(createdService.create).toHaveBeenCalledWith(
actor,
runId,
'markdown',
'artifact-export-1',
)
expect(await created.json()).toMatchObject({
id: artifactId,
runId,
type: 'markdown',
sizeBytes: 7,
downloadUrl: `/api/v1/artifacts/${artifactId}/download`,
})
const replay = await handleCreateGeneratedArtifact(
post(),
runId,
dependencies({ service: service(false) }),
)
expect(replay.status).toBe(200)
expect(replay.headers.get('idempotency-replayed')).toBe('true')
})
it('rejects cross-origin, viewer, malformed, duplicate and unsupported requests safely', async () => {
const crossOrigin = await handleCreateGeneratedArtifact(
post('{"type":"markdown"}', { Origin: 'https://attacker.example' }),
runId,
dependencies(),
)
expect(crossOrigin.status).toBe(403)
const denied = service()
denied.create = vi.fn(async () => {
throw Object.assign(new Error('private membership detail'), {
code: 'workspace_access_denied',
})
})
const viewer = await handleCreateGeneratedArtifact(
post(),
runId,
dependencies({ service: denied }),
)
expect(viewer.status).toBe(403)
expect(await viewer.text()).not.toContain('private membership')
for (const request of [
post('{"type":"markdown","type":"prompt_text"}'),
post('{"type":"support_bundle"}'),
post('{"type":"markdown","content":"spoofed"}'),
post('{"type":"markdown"}', { 'Idempotency-Key': '' }),
]) {
const response = await handleCreateGeneratedArtifact(
request,
runId,
dependencies(),
)
expect(response.status).toBe(422)
}
})
it('allows authorized viewer downloads with safe immutable headers', async () => {
const viewer = { ...actor, workspaceRole: 'viewer' } satisfies ActorContext
const downloadService = service()
const response = await handleDownloadGeneratedArtifact(
new Request(
`https://devrunbook.example/api/v1/artifacts/${artifactId}/download`,
),
artifactId,
dependencies({
resolveContext: vi.fn(async () => viewer),
service: downloadService,
}),
)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe(
'text/markdown; charset=utf-8',
)
expect(response.headers.get('content-length')).toBe('7')
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
expect(response.headers.get('x-devrunbook-artifact-sha256')).toBe(
metadata.sha256,
)
expect(response.headers.get('content-disposition')).toBe(
contentDisposition(metadata.filename),
)
expect(response.headers.get('content-disposition')).toContain(
"filename*=UTF-8''DevRunbook-r%C3%A9sum%C3%A9-TASK.md",
)
expect(await response.text()).toBe('# Task\n')
expect(downloadService.download).toHaveBeenCalledWith(viewer, artifactId)
})
it('conceals invalid ids and refuses metadata-controlled active content types', async () => {
const invalid = await handleDownloadGeneratedArtifact(
new Request(
'https://devrunbook.example/api/v1/artifacts/not-uuid/download',
),
'not-uuid',
dependencies(),
)
expect(invalid.status).toBe(404)
const unsafe = service()
unsafe.download = vi.fn(async () => ({
artifact: { ...metadata, mediaType: 'text/html' },
content: new TextEncoder().encode('<script>bad</script>'),
}))
const refused = await handleDownloadGeneratedArtifact(
new Request(
`https://devrunbook.example/api/v1/artifacts/${artifactId}/download`,
),
artifactId,
dependencies({ service: unsafe }),
)
expect(refused.status).toBe(503)
expect(refused.headers.get('content-type')).toContain('application/json')
expect(await refused.text()).not.toContain('<script>')
})
})
@@ -0,0 +1,397 @@
import type {
ActorContext,
GeneratedArtifactDownload,
StoreGeneratedArtifactResult,
SynchronousRunArtifactType,
} from '@devrunbook/application'
import { handleAuthRequest } from '../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
import { parseStrictJson } from '../compositions/drafts/composition-draft-http'
const maximumRequestBytes = 4_096
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
const artifactTypes = new Set<SynchronousRunArtifactType>([
'prompt_text',
'markdown',
'run_pack_zip',
'agents_suggestion',
])
export interface GeneratedArtifactHttpService {
create(
actor: ActorContext,
runId: string,
artifactType: SynchronousRunArtifactType,
idempotencyKey: string,
): Promise<StoreGeneratedArtifactResult>
download(
actor: ActorContext,
artifactId: string,
): Promise<GeneratedArtifactDownload>
}
export interface GeneratedArtifactRouteDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly service: GeneratedArtifactHttpService
}
interface ErrorLike {
readonly code: string
}
function errorLike(value: unknown): ErrorLike | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const candidate = value as Readonly<Record<string, unknown>>
return typeof candidate.code === 'string' ? { code: candidate.code } : null
}
function errorResponse(
status: number,
code: string,
message: string,
requestId: string,
headers: Readonly<Record<string, string>> = {},
): Response {
return Response.json(
{ error: { code, message, requestId } },
{
status,
headers: {
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
...headers,
},
},
)
}
function mappedError(caught: unknown, requestId: string): Response {
if (caught instanceof AuthenticatedWorkspaceContextError) {
return errorResponse(
caught.code === 'authentication_required' ? 401 : 403,
caught.code,
caught.code === 'authentication_required'
? 'Authentication required'
: 'Access denied',
requestId,
)
}
const error = errorLike(caught)
if (error?.code === 'authentication_required') {
return errorResponse(401, error.code, 'Authentication required', requestId)
}
if (error?.code === 'workspace_access_denied') {
return errorResponse(403, error.code, 'Access denied', requestId)
}
if (
error?.code === 'generated_artifact_not_found' ||
error?.code === 'generated_artifact_run_not_found'
) {
return errorResponse(404, error.code, 'Resource not found', requestId)
}
if (
error?.code === 'generated_artifact_request_invalid' ||
error?.code === 'generated_artifact_type_invalid' ||
error?.code === 'generated_artifact_idempotency_key_invalid' ||
error?.code === 'generated_artifact_source_unavailable' ||
error?.code === 'agents_suggestion_profile_unavailable'
) {
return errorResponse(
422,
error.code,
'Artifact request is invalid',
requestId,
)
}
if (error?.code === 'generated_artifact_request_too_large') {
return errorResponse(
413,
error.code,
'Artifact request is too large',
requestId,
)
}
if (error?.code === 'generated_artifact_too_large') {
return errorResponse(
413,
error.code,
'Generated artifact is too large',
requestId,
)
}
if (
error?.code === 'generated_artifact_idempotency_conflict' ||
error?.code === 'generated_artifact_store_invariant_failed'
) {
return errorResponse(
409,
error.code,
'Idempotency key is associated with a different artifact request',
requestId,
)
}
if (error?.code === 'generated_artifact_renderer_unavailable') {
return errorResponse(
503,
error.code,
'Requested artifact generation is temporarily unavailable',
requestId,
{ 'Retry-After': '30' },
)
}
if (error?.code === 'generated_artifact_expired') {
return errorResponse(410, error.code, 'Artifact has expired', requestId)
}
return errorResponse(
503,
'generated_artifact_service_unavailable',
'Artifact service is temporarily unavailable',
requestId,
)
}
function plainObject(value: unknown): value is Record<string, unknown> {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
)
}
async function readRequestBody(request: Request): Promise<string> {
const contentType = request.headers.get('content-type')
if (
!contentType ||
!/^application\/json(?:\s*;\s*charset=utf-8)?$/iu.test(contentType)
) {
throw Object.assign(new Error('Unsupported content type'), {
code: 'generated_artifact_request_too_large',
})
}
const declared = request.headers.get('content-length')
if (
declared !== null &&
(!/^[0-9]+$/u.test(declared) || Number(declared) > maximumRequestBytes)
) {
throw Object.assign(new Error('Artifact request is too large'), {
code: 'generated_artifact_request_invalid',
})
}
if (!request.body) {
throw Object.assign(new Error('Artifact request body is required'), {
code: 'generated_artifact_request_invalid',
})
}
const reader = request.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > maximumRequestBytes) {
await reader.cancel()
throw Object.assign(new Error('Artifact request is too large'), {
code: 'generated_artifact_request_too_large',
})
}
chunks.push(value)
}
const bytes = new Uint8Array(size)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
throw Object.assign(new Error('Artifact request must be valid UTF-8'), {
code: 'generated_artifact_request_invalid',
})
}
}
async function artifactType(
request: Request,
): Promise<SynchronousRunArtifactType> {
let value: unknown
try {
value = parseStrictJson(await readRequestBody(request))
} catch (caught) {
const parsed = errorLike(caught)
if (parsed?.code.startsWith('generated_artifact_request_')) throw caught
throw Object.assign(new Error('Artifact request must contain valid JSON'), {
code: 'generated_artifact_request_invalid',
})
}
if (
!plainObject(value) ||
Object.keys(value).length !== 1 ||
!('type' in value) ||
typeof value.type !== 'string' ||
!artifactTypes.has(value.type as SynchronousRunArtifactType)
) {
throw Object.assign(new Error('Artifact type is invalid'), {
code: 'generated_artifact_request_invalid',
})
}
return value.type as SynchronousRunArtifactType
}
function assertIdentifier(value: string, code: string): void {
if (!uuidPattern.test(value)) {
throw Object.assign(new Error('Resource not found'), { code })
}
}
function idempotencyKey(request: Request): string {
const value = request.headers.get('idempotency-key')
if (
!value ||
value.length > 255 ||
value.trim() !== value ||
/[\0\r\n]/u.test(value)
) {
throw Object.assign(new Error('Invalid idempotency key'), {
code: 'generated_artifact_idempotency_key_invalid',
})
}
return value
}
function artifactEnvelope(result: StoreGeneratedArtifactResult) {
const artifact = result.artifact
return {
id: artifact.id,
runId: artifact.runId,
type: artifact.artifactType,
filename: artifact.filename,
mediaType: artifact.mediaType,
sizeBytes: Number(artifact.sizeBytes),
sha256: artifact.sha256,
expiresAt: artifact.expiresAt,
createdAt: artifact.createdAt,
downloadUrl: `/api/v1/artifacts/${artifact.id}/download`,
}
}
function contentType(download: GeneratedArtifactDownload): string {
const expected: Partial<
Record<typeof download.artifact.artifactType, string>
> = {
prompt_text: 'text/plain; charset=utf-8',
markdown: 'text/markdown; charset=utf-8',
run_pack_zip: 'application/zip',
agents_suggestion: 'text/markdown; charset=utf-8',
support_bundle: 'application/zip',
}
const mediaType = expected[download.artifact.artifactType]
if (!mediaType || download.artifact.mediaType !== mediaType) {
throw Object.assign(new Error('Unsafe artifact media type'), {
code: 'generated_artifact_integrity_failed',
})
}
return mediaType
}
function asciiFilename(filename: string): string {
const safe = filename
.replace(/[^\x20-\x7e]/gu, '_')
.replace(/["\\]/gu, '_')
.slice(0, 180)
return safe || 'devrunbook-artifact'
}
export function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename).replace(
/[!'()*]/gu,
(value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`,
)
return `attachment; filename="${asciiFilename(filename)}"; filename*=UTF-8''${encoded}`
}
export function handleCreateGeneratedArtifact(
request: Request,
runId: string,
dependencies: GeneratedArtifactRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
if (
sameOriginRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return errorResponse(
403,
'invalid_origin',
'Invalid request origin',
requestId,
)
}
try {
assertIdentifier(runId, 'generated_artifact_run_not_found')
const actor = await dependencies.resolveContext(sameOriginRequest)
const key = idempotencyKey(sameOriginRequest)
const type = await artifactType(sameOriginRequest)
const result = await dependencies.service.create(
actor,
runId,
type,
key,
)
return Response.json(artifactEnvelope(result), {
status: result.created ? 201 : 200,
headers: {
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
Location: `/api/v1/artifacts/${result.artifact.id}/download`,
...(result.created ? {} : { 'Idempotency-Replayed': 'true' }),
},
})
} catch (caught) {
return mappedError(caught, requestId)
}
},
dependencies.publicBaseUrl,
() =>
errorResponse(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
export async function handleDownloadGeneratedArtifact(
request: Request,
artifactId: string,
dependencies: GeneratedArtifactRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
try {
assertIdentifier(artifactId, 'generated_artifact_not_found')
const actor = await dependencies.resolveContext(request)
const download = await dependencies.service.download(actor, artifactId)
const body = download.content.buffer.slice(
download.content.byteOffset,
download.content.byteOffset + download.content.byteLength,
) as ArrayBuffer
return new Response(body, {
headers: {
'Cache-Control': 'no-store',
'Content-Type': contentType(download),
'Content-Length': String(download.content.byteLength),
'Content-Disposition': contentDisposition(download.artifact.filename),
'X-Content-Type-Options': 'nosniff',
'X-DevRunbook-Artifact-SHA256': download.artifact.sha256,
},
})
} catch (caught) {
return mappedError(caught, requestId)
}
}
@@ -0,0 +1,14 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../server/authenticated-workspace-context'
import { getGeneratedArtifactServer } from '../../../../server/generated-artifacts'
import type { GeneratedArtifactRouteDependencies } from './artifact-http'
export function generatedArtifactRouteDependencies(): GeneratedArtifactRouteDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
service: getGeneratedArtifactServer(),
}
}
@@ -0,0 +1,8 @@
import { handleListAuditEvents } from '../operations-http'
import { operationsRouteDependencies } from '../operations-route-dependencies'
export const dynamic = 'force-dynamic'
export function GET(request: Request) {
return handleListAuditEvents(request, operationsRouteDependencies())
}
@@ -0,0 +1,26 @@
import {
createInvitation,
consumeInvitation,
resolveInvitationActor,
} from '../../../../../../server/invitations'
import {
handleAcceptInvitation,
type InvitationHttpDependencies,
} from '../../../invitations/invitation-http'
export const dynamic = 'force-dynamic'
function dependencies(): InvitationHttpDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveActor: resolveInvitationActor,
create: createInvitation,
consume: consumeInvitation,
}
}
export function POST(request: Request) {
return handleAcceptInvitation(request, dependencies())
}
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from 'vitest'
import { handlePasswordResetPost } from './password-reset-route'
const token = 'a'.repeat(43)
const publicBaseUrl = 'https://runbook.example.test'
function request(origin = publicBaseUrl) {
return new Request(`${publicBaseUrl}/api/v1/auth/password-reset`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin },
body: JSON.stringify({
token,
password: 'a secure password value',
passwordConfirmation: 'a secure password value',
}),
})
}
function dependencies(
overrides: Partial<Parameters<typeof handlePasswordResetPost>[1]> = {},
) {
return {
publicBaseUrl,
isConsumable: vi.fn(async () => true),
hashPassword: vi.fn(async () => 'better-auth-hash'),
consume: vi.fn(async () => undefined),
...overrides,
}
}
describe('password reset route boundary', () => {
it('hashes only after preflight and consumes atomically', async () => {
const deps = dependencies()
const response = await handlePasswordResetPost(request(), deps)
expect(response.status).toBe(200)
expect(deps.isConsumable).toHaveBeenCalledWith(token)
expect(deps.hashPassword).toHaveBeenCalledWith('a secure password value')
expect(deps.consume).toHaveBeenCalledWith({
rawToken: token,
betterAuthPasswordHash: 'better-auth-hash',
})
})
it('returns identical failures for invalid preflight and final replay race', async () => {
const preflight = await handlePasswordResetPost(
request(),
dependencies({ isConsumable: vi.fn(async () => false) }),
)
const replay = await handlePasswordResetPost(
request(),
dependencies({
consume: vi.fn(async () => Promise.reject(new Error('used'))),
}),
)
expect(preflight.status).toBe(400)
expect(replay.status).toBe(400)
expect(await preflight.json()).toEqual(await replay.json())
})
it('rejects cross-origin requests before validation or hashing', async () => {
const deps = dependencies()
const response = await handlePasswordResetPost(
request('https://attacker.example.test'),
deps,
)
expect(response.status).toBe(403)
expect(deps.isConsumable).not.toHaveBeenCalled()
expect(deps.hashPassword).not.toHaveBeenCalled()
})
it('rejects oversized request bodies before preflight or hashing', async () => {
const deps = dependencies()
const oversized = request()
oversized.headers.set('content-length', '4097')
const response = await handlePasswordResetPost(oversized, deps)
expect(response.status).toBe(400)
expect(deps.isConsumable).not.toHaveBeenCalled()
expect(deps.hashPassword).not.toHaveBeenCalled()
})
it('caps an oversized streamed body without a content length', async () => {
const deps = dependencies()
const oversized = new Request(
`${publicBaseUrl}/api/v1/auth/password-reset`,
{
method: 'POST',
headers: { 'content-type': 'application/json', origin: publicBaseUrl },
body: ' '.repeat(4097),
},
)
const response = await handlePasswordResetPost(oversized, deps)
expect(response.status).toBe(400)
expect(deps.isConsumable).not.toHaveBeenCalled()
expect(deps.hashPassword).not.toHaveBeenCalled()
})
it('requires an application/json request body', async () => {
const deps = dependencies()
const invalidContentType = request()
invalidContentType.headers.set('content-type', 'text/plain')
const response = await handlePasswordResetPost(invalidContentType, deps)
expect(response.status).toBe(400)
expect(deps.isConsumable).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,118 @@
import { handleAuthRequest } from '../../../../../auth/csrf'
import { genericPasswordResetFailure } from '../../../../reset-password/reset-password-form'
const maximumBodyBytes = 4_096
const tokenPattern = /^[A-Za-z0-9_-]{32,512}$/u
export interface PasswordResetRouteDependencies {
readonly publicBaseUrl: string
readonly isConsumable: (rawToken: string) => Promise<boolean>
readonly hashPassword: (password: string) => Promise<string>
readonly consume: (input: {
rawToken: string
betterAuthPasswordHash: string
}) => Promise<void>
}
function genericFailure() {
return Response.json(
{ code: 'PASSWORD_RESET_FAILED', message: genericPasswordResetFailure },
{ status: 400 },
)
}
function readLength(request: Request): number | null {
const header = request.headers.get('content-length')
if (header === null) return null
const parsed = Number(header)
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null
}
async function readBoundedBody(request: Request): Promise<string | null> {
if (!request.body) return ''
const reader = request.body.getReader()
const decoder = new TextDecoder()
let byteLength = 0
let text = ''
try {
while (true) {
const chunk = await reader.read()
if (chunk.done) return text + decoder.decode()
byteLength += chunk.value.byteLength
if (byteLength > maximumBodyBytes) {
await reader.cancel()
return null
}
text += decoder.decode(chunk.value, { stream: true })
}
} finally {
reader.releaseLock()
}
}
async function parseRequest(request: Request) {
if (
request.headers.get('content-type')?.split(';', 1)[0]?.trim() !==
'application/json'
) {
return null
}
const declaredLength = readLength(request)
if (declaredLength !== null && declaredLength > maximumBodyBytes) return null
const text = await readBoundedBody(request)
if (text === null) return null
let value: unknown
try {
value = JSON.parse(text)
} catch {
return null
}
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Record<string, unknown>
const keys = Object.keys(record).sort()
if (keys.join(',') !== 'password,passwordConfirmation,token') return null
if (
typeof record.token !== 'string' ||
!tokenPattern.test(record.token) ||
typeof record.password !== 'string' ||
record.password.length < 12 ||
record.password.length > 128 ||
record.passwordConfirmation !== record.password
) {
return null
}
return { token: record.token, password: record.password }
}
export function handlePasswordResetPost(
request: Request,
dependencies: PasswordResetRouteDependencies,
): Promise<Response> {
return handleAuthRequest(
request,
async (sameOriginRequest) => {
const parsed = await parseRequest(sameOriginRequest)
if (!parsed) return genericFailure()
try {
// Cheap HMAC/database preflight prevents untrusted random tokens from
// triggering the intentionally expensive password hashing operation.
if (!(await dependencies.isConsumable(parsed.token))) {
return genericFailure()
}
const betterAuthPasswordHash = await dependencies.hashPassword(
parsed.password,
)
await dependencies.consume({
rawToken: parsed.token,
betterAuthPasswordHash,
})
return Response.json({ success: true })
} catch {
// Final consume is authoritative. Races, expiry, replay, and unavailable
// users intentionally share the same public response as preflight.
return genericFailure()
}
},
dependencies.publicBaseUrl,
)
}
@@ -0,0 +1,19 @@
import { createPasswordResetService } from '@/server/password-reset-service'
import { handlePasswordResetPost } from './password-reset-route'
export const dynamic = 'force-dynamic'
export function POST(request: Request) {
const service = createPasswordResetService(process.env)
if (!service) {
return Response.json(
{
code: 'PASSWORD_RESET_UNAVAILABLE',
message: 'Password reset is unavailable.',
},
{ status: 503 },
)
}
return handlePasswordResetPost(request, service)
}
@@ -0,0 +1,15 @@
import { createSessionRouteDependencies } from '../../../../../../server/session-management'
import { handleRevokeSession } from '../session-route'
export const dynamic = 'force-dynamic'
type RouteContext = { params: Promise<{ sessionId: string }> }
export async function DELETE(request: Request, context: RouteContext) {
const { sessionId } = await context.params
return handleRevokeSession(
request,
sessionId,
createSessionRouteDependencies(),
)
}
@@ -0,0 +1,8 @@
import { createSessionRouteDependencies } from '../../../../../server/session-management'
import { handleListSessions } from './session-route'
export const dynamic = 'force-dynamic'
export async function GET(request: Request) {
return handleListSessions(request, createSessionRouteDependencies())
}
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest'
import {
handleListSessions,
handleRevokeSession,
type SessionRouteDependencies,
} from './session-route'
const now = new Date('2026-07-27T12:00:00.000Z')
function dependencies(
overrides: Partial<SessionRouteDependencies> = {},
): SessionRouteDependencies {
return {
publicBaseUrl: 'https://runbook.example.test',
resolveIdentity: vi.fn().mockResolvedValue({
userId: '00000000-0000-4000-8000-000000000001',
sessionId: 'current-session',
}),
listActive: vi.fn().mockResolvedValue([
{
id: 'current-session',
createdAt: now,
lastSeenAt: now,
idleExpiresAt: new Date('2026-07-28T12:00:00.000Z'),
absoluteExpiresAt: new Date('2026-08-27T12:00:00.000Z'),
userAgentSummary: 'Firefox on Linux',
},
]),
revokeOwned: vi.fn().mockResolvedValue(true),
...overrides,
}
}
describe('session HTTP boundary', () => {
it('lists only store-provided actor sessions and marks the current one', async () => {
const deps = dependencies()
const response = await handleListSessions(
new Request('http://devrunbook.test/api/v1/auth/sessions'),
deps,
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual([
expect.objectContaining({
id: 'current-session',
current: true,
userAgentSummary: 'Firefox on Linux',
}),
])
expect(deps.listActive).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000001',
)
})
it('does not disclose sessions across users', async () => {
const deps = dependencies({ revokeOwned: vi.fn().mockResolvedValue(false) })
const response = await handleRevokeSession(
new Request('https://runbook.example.test/api/v1/auth/sessions/other', {
method: 'DELETE',
headers: { origin: 'https://runbook.example.test' },
}),
'other',
deps,
)
expect(response.status).toBe(404)
expect(deps.revokeOwned).toHaveBeenCalledWith(
expect.objectContaining({
actorUserId: '00000000-0000-4000-8000-000000000001',
sessionId: 'other',
}),
)
})
it('requires authentication for listing and revocation', async () => {
const deps = dependencies({
resolveIdentity: vi.fn().mockResolvedValue(null),
})
const list = await handleListSessions(
new Request('http://devrunbook.test/api/v1/auth/sessions'),
deps,
)
const revoke = await handleRevokeSession(
new Request('https://runbook.example.test/api/v1/auth/sessions/session', {
method: 'DELETE',
headers: { origin: 'https://runbook.example.test' },
}),
'session',
deps,
)
expect(list.status).toBe(401)
expect(revoke.status).toBe(401)
expect(deps.revokeOwned).not.toHaveBeenCalled()
})
it('rejects cross-origin revocation before identity resolution', async () => {
const deps = dependencies()
const response = await handleRevokeSession(
new Request('https://runbook.example.test/api/v1/auth/sessions/session', {
method: 'DELETE',
headers: { origin: 'https://attacker.example.test' },
}),
'session',
deps,
)
expect(response.status).toBe(403)
expect(deps.resolveIdentity).not.toHaveBeenCalled()
expect(deps.revokeOwned).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,108 @@
import { handleAuthRequest } from '../../../../../auth/csrf'
interface SessionView {
readonly id: string
readonly createdAt: Date
readonly lastSeenAt: Date
readonly idleExpiresAt: Date
readonly absoluteExpiresAt: Date
readonly userAgentSummary: string | null
}
export interface SessionIdentity {
readonly userId: string
readonly sessionId: string
}
export interface SessionRouteDependencies {
readonly publicBaseUrl: string
readonly resolveIdentity: (
headers: Headers,
) => Promise<SessionIdentity | null>
readonly listActive: (userId: string) => Promise<readonly SessionView[]>
readonly revokeOwned: (input: {
readonly actorUserId: string
readonly sessionId: string
readonly requestId: string
}) => Promise<boolean>
}
function errorResponse(
status: number,
code: string,
message: string,
requestId: string,
) {
return Response.json({ error: { code, message, requestId } }, { status })
}
export async function handleListSessions(
request: Request,
dependencies: SessionRouteDependencies,
) {
const requestId = crypto.randomUUID()
const identity = await dependencies.resolveIdentity(request.headers)
if (!identity) {
return errorResponse(
401,
'authentication_required',
'Authentication is required',
requestId,
)
}
const sessions = await dependencies.listActive(identity.userId)
return Response.json(
sessions.map((session) => ({
id: session.id,
createdAt: session.createdAt.toISOString(),
lastSeenAt: session.lastSeenAt.toISOString(),
idleExpiresAt: session.idleExpiresAt.toISOString(),
absoluteExpiresAt: session.absoluteExpiresAt.toISOString(),
current: session.id === identity.sessionId,
...(session.userAgentSummary
? { userAgentSummary: session.userAgentSummary }
: {}),
})),
)
}
export async function handleRevokeSession(
request: Request,
sessionId: string,
dependencies: SessionRouteDependencies,
) {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
const identity = await dependencies.resolveIdentity(
sameOriginRequest.headers,
)
if (!identity) {
return errorResponse(
401,
'authentication_required',
'Authentication is required',
requestId,
)
}
const revoked = await dependencies.revokeOwned({
actorUserId: identity.userId,
sessionId,
requestId,
})
if (!revoked) {
return errorResponse(
404,
'not_found',
'Session was not found',
requestId,
)
}
return new Response(null, { status: 204 })
},
dependencies.publicBaseUrl,
() =>
errorResponse(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
@@ -0,0 +1,30 @@
import { handleCollectionItemMutation } from '../../../collection-http'
import { collectionRouteDependencies } from '../../../route'
export const dynamic = 'force-dynamic'
type RouteContext = {
params: Promise<{ collectionId: string; playbookId: string }>
}
export async function PUT(request: Request, context: RouteContext) {
const { collectionId, playbookId } = await context.params
return handleCollectionItemMutation(
request,
collectionId,
playbookId,
'add',
collectionRouteDependencies(),
)
}
export async function DELETE(request: Request, context: RouteContext) {
const { collectionId, playbookId } = await context.params
return handleCollectionItemMutation(
request,
collectionId,
playbookId,
'remove',
collectionRouteDependencies(),
)
}
@@ -0,0 +1,188 @@
import type { ActorContext, PlaybookCollection } from '@devrunbook/application'
import { describe, expect, it, vi } from 'vitest'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
import {
handleCollectionItemMutation,
handleCreateCollection,
handleListCollections,
type CollectionRouteDependencies,
} from './collection-http'
const origin = 'https://runbook.example.test'
const collectionId = '00000000-0000-4000-8000-000000000003'
const playbookId = '00000000-0000-4000-8000-000000000004'
const actor: ActorContext = {
userId: '00000000-0000-4000-8000-000000000001',
workspaceId: '00000000-0000-4000-8000-000000000002',
instanceRole: 'user',
workspaceRole: 'viewer',
}
const created: PlaybookCollection = {
id: collectionId,
name: 'Release checks',
description: 'Before shipping',
itemCount: 0,
playbookIds: [],
createdAt: new Date('2026-07-27T12:00:00.000Z'),
updatedAt: new Date('2026-07-27T12:00:00.000Z'),
}
function dependencies(
overrides: Partial<CollectionRouteDependencies> = {},
): CollectionRouteDependencies {
return {
publicBaseUrl: origin,
resolveContext: vi.fn(async () => actor),
list: vi.fn(async () => [created]),
create: vi.fn(async () => created),
mutateItem: vi.fn(async () => undefined),
...overrides,
}
}
function mutationRequest(method: 'POST' | 'PUT' | 'DELETE', body?: unknown) {
return new Request(`${origin}/api/v1/collections`, {
method,
headers: {
origin,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
}
async function expectError(response: Response, status: number, code: string) {
expect(response.status).toBe(status)
expect(await response.json()).toMatchObject({
error: { code, message: expect.any(String), requestId: expect.any(String) },
})
}
describe('collection HTTP contract', () => {
it('lists only the authenticated actor personal collections', async () => {
const context = dependencies()
const response = await handleListCollections(
new Request(`${origin}/api/v1/collections`),
context,
)
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(await response.json()).toEqual([
{
...created,
createdAt: created.createdAt.toISOString(),
updatedAt: created.updatedAt.toISOString(),
},
])
expect(context.list).toHaveBeenCalledWith(actor)
})
it('creates from the closed JSON body and rejects unknown fields', async () => {
const context = dependencies()
const response = await handleCreateCollection(
mutationRequest('POST', {
name: 'Release checks',
description: 'Before shipping',
}),
context,
)
expect(response.status).toBe(201)
expect(context.create).toHaveBeenCalledWith(actor, {
name: 'Release checks',
description: 'Before shipping',
})
await expectError(
await handleCreateCollection(
mutationRequest('POST', { name: 'Unsafe', workspaceId: 'substitute' }),
dependencies(),
),
422,
'collection_request_invalid',
)
})
it.each([
['PUT', 'add'],
['DELETE', 'remove'],
] as const)(
'maps %s to an idempotent %s item mutation',
async (method, mutation) => {
const context = dependencies()
const response = await handleCollectionItemMutation(
mutationRequest(method),
collectionId,
playbookId,
mutation,
context,
)
expect(response.status).toBe(204)
expect(context.mutateItem).toHaveBeenCalledWith({
actor,
collectionId,
playbookId,
mutation,
})
},
)
it('conflates malformed and cross-workspace substituted targets', async () => {
await expectError(
await handleCollectionItemMutation(
mutationRequest('PUT'),
'not-a-uuid',
playbookId,
'add',
dependencies(),
),
404,
'collection_target_not_found',
)
await expectError(
await handleCollectionItemMutation(
mutationRequest('PUT'),
collectionId,
playbookId,
'add',
dependencies({
mutateItem: vi.fn(async () => {
throw { code: 'collection_target_not_found', secret: 'workspace-b' }
}),
}),
),
404,
'collection_target_not_found',
)
})
it('enforces same-origin mutations and safe authentication errors', async () => {
const foreign = mutationRequest('PUT')
foreign.headers.set('origin', 'https://attacker.example.test')
await expectError(
await handleCollectionItemMutation(
foreign,
collectionId,
playbookId,
'add',
dependencies(),
),
403,
'invalid_origin',
)
await expectError(
await handleListCollections(
new Request(`${origin}/api/v1/collections`),
dependencies({
resolveContext: vi.fn(async () => {
throw new AuthenticatedWorkspaceContextError(
'authentication_required',
)
}),
}),
),
401,
'authentication_required',
)
})
})
@@ -0,0 +1,209 @@
import type { ActorContext, PlaybookCollection } from '@devrunbook/application'
import { handleAuthRequest } from '../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
const maximumBodyBytes = 4096
export interface CollectionRouteDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly list: (actor: ActorContext) => Promise<readonly PlaybookCollection[]>
readonly create: (
actor: ActorContext,
input: { readonly name: unknown; readonly description?: unknown },
) => Promise<PlaybookCollection>
readonly mutateItem: (input: {
readonly actor: ActorContext
readonly collectionId: string
readonly playbookId: string
readonly mutation: 'add' | 'remove'
}) => Promise<void>
}
function error(
status: number,
code: string,
message: string,
requestId: string,
) {
return Response.json({ error: { code, message, requestId } }, { status })
}
function codeOf(value: unknown): unknown {
return value !== null && typeof value === 'object' && 'code' in value
? value.code
: undefined
}
function mappedError(caught: unknown, requestId: string): Response {
const code = codeOf(caught)
if (caught instanceof AuthenticatedWorkspaceContextError) {
return error(
code === 'authentication_required' ? 401 : 403,
caught.code,
code === 'authentication_required'
? 'Authentication required'
: 'Access denied',
requestId,
)
}
if (code === 'collection_target_not_found') {
return error(404, String(code), 'Collection target not found', requestId)
}
if (code === 'collection_name_conflict') {
return error(409, String(code), 'Collection name already exists', requestId)
}
if (
code === 'collection_name_invalid' ||
code === 'collection_description_invalid' ||
code === 'collection_request_invalid'
) {
return error(422, String(code), 'Collection request is invalid', requestId)
}
return error(
503,
'collection_service_unavailable',
'Collection service is temporarily unavailable',
requestId,
)
}
function plainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
async function parseCreateRequest(request: Request) {
const contentType = request.headers.get('content-type')?.split(';', 1)[0]
const contentLength = request.headers.get('content-length')
if (contentType !== 'application/json') {
throw { code: 'collection_request_invalid' }
}
if (
contentLength !== null &&
(!/^\d+$/u.test(contentLength) || Number(contentLength) > maximumBodyBytes)
) {
throw { code: 'collection_request_invalid' }
}
const body = await request.text()
if (new TextEncoder().encode(body).length > maximumBodyBytes) {
throw { code: 'collection_request_invalid' }
}
let value: unknown
try {
value = JSON.parse(body) as unknown
} catch {
throw { code: 'collection_request_invalid' }
}
if (!plainObject(value)) throw { code: 'collection_request_invalid' }
const keys = Object.keys(value)
if (keys.some((key) => key !== 'name' && key !== 'description')) {
throw { code: 'collection_request_invalid' }
}
return { name: value.name, description: value.description }
}
function mutationBoundary(
request: Request,
dependencies: CollectionRouteDependencies,
requestId: string,
operation: (safeRequest: Request) => Promise<Response>,
) {
return handleAuthRequest(
request,
(safeRequest) => {
if (
safeRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return Promise.resolve(
error(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
return operation(safeRequest)
},
dependencies.publicBaseUrl,
() => error(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
export async function handleListCollections(
request: Request,
dependencies: CollectionRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
const actor = await dependencies.resolveContext(request)
return Response.json(await dependencies.list(actor), {
headers: { 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
}
export function handleCreateCollection(
request: Request,
dependencies: CollectionRouteDependencies,
) {
const requestId = crypto.randomUUID()
return mutationBoundary(
request,
dependencies,
requestId,
async (safeRequest) => {
try {
const actor = await dependencies.resolveContext(safeRequest)
const created = await dependencies.create(
actor,
await parseCreateRequest(safeRequest),
)
return Response.json(created, {
status: 201,
headers: { 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
},
)
}
export function handleCollectionItemMutation(
request: Request,
collectionId: string,
playbookId: string,
mutation: 'add' | 'remove',
dependencies: CollectionRouteDependencies,
) {
const requestId = crypto.randomUUID()
return mutationBoundary(
request,
dependencies,
requestId,
async (safeRequest) => {
try {
if (!uuidPattern.test(collectionId) || !uuidPattern.test(playbookId)) {
return error(
404,
'collection_target_not_found',
'Collection target not found',
requestId,
)
}
const actor = await dependencies.resolveContext(safeRequest)
await dependencies.mutateItem({
actor,
collectionId,
playbookId,
mutation,
})
return new Response(null, { status: 204 })
} catch (caught) {
return mappedError(caught, requestId)
}
},
)
}
@@ -0,0 +1,33 @@
import {
createPersonalPlaybookCollection,
listPersonalPlaybookCollections,
persistPlaybookCollectionItem,
} from '../../../../server/playbook-collections'
import { resolveAuthenticatedWorkspaceContext } from '../../../../server/authenticated-workspace-context'
import {
handleCreateCollection,
handleListCollections,
type CollectionRouteDependencies,
} from './collection-http'
export const dynamic = 'force-dynamic'
export function collectionRouteDependencies(): CollectionRouteDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
list: listPersonalPlaybookCollections,
create: createPersonalPlaybookCollection,
mutateItem: persistPlaybookCollectionItem,
}
}
export function GET(request: Request) {
return handleListCollections(request, collectionRouteDependencies())
}
export function POST(request: Request) {
return handleCreateCollection(request, collectionRouteDependencies())
}
@@ -0,0 +1,572 @@
import type {
ActorContext,
AuthoritativeCompositionResult,
GeneratedRun,
} from '@devrunbook/application'
import { describe, expect, it, vi } from 'vitest'
import {
handleGenerateRun,
handleGetRun,
handleListRuns,
handlePreviewComposition,
type AuthoritativeCompositionHttpService,
type AuthoritativeCompositionRouteDependencies,
} from './composition-http'
const userId = '00000000-0000-4000-8000-000000000001'
const workspaceId = '00000000-0000-4000-8000-000000000002'
const runId = '00000000-0000-4000-8000-000000000003'
const playbookVersionId = '00000000-0000-4000-8000-000000000004'
const digest = 'a'.repeat(64)
const actor: ActorContext = {
userId,
instanceRole: 'user',
workspaceId,
workspaceRole: 'owner',
}
const prompt = '# Fix a bounded bug\n\n## Mission\n\nRepair the failure.\n'
const provenance = [
{
blockId: 'mission',
heading: 'Mission',
startOffset: 0,
endOffset: prompt.length,
sources: ['playbook:root-cause-bugfix@1.0.0', 'user-input:request'],
},
]
const previewResult: AuthoritativeCompositionResult = {
playbookVersionId,
repositoryProfileRevisionId: null,
preview: {
normalizedInput: { request: 'Repair the failure' },
compatibility: {
status: 'unknown',
reasons: ['No repository profile is selected.'],
satisfiedCapabilities: [],
missingCapabilities: [],
accesses: [],
},
resolvedPolicies: {
precedence: ['platform', 'workspace', 'repository', 'playbook', 'user'],
platform: { noArbitraryExecution: true },
repository: {},
appliedGuardrailIds: ['bounded-scope'],
unresolvedConditions: [],
confirmedUnsafeCommandIds: [],
},
resolvedScope: {
includedPaths: ['src'],
excludedPaths: [],
protectedPaths: ['.github'],
generatedPaths: [],
allowableChangeTypes: ['code'],
repositoryWideRead: true,
conflicts: [],
invalidPaths: [],
modificationAllowed: true,
},
renderedPrompt: prompt,
renderDigest: digest,
blocks: [{ id: 'mission', heading: 'Mission', markdown: prompt }],
provenance,
conditionAccesses: [
{
path: 'inputs.request',
found: true,
valueType: 'string',
result: 'true',
},
],
lintFindings: [
{
ruleId: 'PB006',
severity: 'warning',
message: 'No repository profile is selected.',
source: 'compatibility',
controlPath: 'repositoryProfileRevisionId',
},
],
exportReadiness: 'warning',
},
snapshots: {
playbook: {
id: playbookVersionId,
slug: 'root-cause-bugfix',
version: '1.0.0',
digest,
lifecycle: 'validated',
manifest: {},
template: '# template',
},
repositoryProfile: null,
normalizedInput: { request: 'Repair the failure' },
policy: {
workMode: 'execute',
autonomyLevel: 'verify',
compatibility: { status: 'unknown' },
resolvedPolicies: {},
resolvedScope: { includedPaths: ['src'] },
},
provenance,
},
}
const run: GeneratedRun = {
id: runId,
workspaceId,
generatedBy: userId,
sourceDraftId: null,
playbookVersionId,
snapshots: previewResult.snapshots,
lint: {
exportReadiness: 'warning',
findings: previewResult.preview.lintFindings,
},
renderedPrompt: prompt,
renderDigest: digest,
idempotencyKey: 'generation-1',
generatedAt: '2026-07-27T10:00:00.000Z',
}
function service(): AuthoritativeCompositionHttpService {
return {
preview: vi.fn(async () => previewResult),
generate: vi.fn(async () => ({ run, created: true })),
get: vi.fn(async () => run),
listArtifacts: vi.fn(async () => []),
list: vi.fn(async () => ({ items: [run], nextCursor: 'cursor-next' })),
}
}
function dependencies(
overrides: Partial<AuthoritativeCompositionRouteDependencies> = {},
): AuthoritativeCompositionRouteDependencies {
return {
publicBaseUrl: 'https://devrunbook.example',
resolveContext: vi.fn(async () => actor),
service: service(),
...overrides,
}
}
function body(overrides: Record<string, unknown> = {}) {
return JSON.stringify({
playbook: { slug: 'root-cause-bugfix', version: '1.0.0' },
repositoryProfileRevisionId: null,
workMode: 'execute',
autonomyLevel: 'verify',
inputs: { request: 'Repair the failure' },
scopeOverrides: { includedPaths: ['src'] },
outputFormat: 'prompt',
...overrides,
})
}
function post(
path: string,
content = body(),
headers: Record<string, string> = {},
) {
return new Request(`https://devrunbook.example${path}`, {
method: 'POST',
body: content,
headers: {
origin: 'https://devrunbook.example',
'content-type': 'application/json',
...headers,
},
})
}
async function error(response: Response) {
return (await response.json()) as {
error: { code: string; requestId: string; details?: unknown[] }
}
}
describe('authoritative composition HTTP boundary', () => {
it('returns byte-stable typed previews from the same authoritative request', async () => {
const deps = dependencies()
const first = await handlePreviewComposition(
post('/api/v1/compositions/preview'),
deps,
)
const second = await handlePreviewComposition(
post('/api/v1/compositions/preview'),
deps,
)
expect(first.status).toBe(200)
expect(first.headers.get('cache-control')).toBe('no-store')
expect(await first.text()).toBe(await second.text())
expect(deps.service.preview).toHaveBeenCalledWith(actor, {
playbook: { slug: 'root-cause-bugfix', version: '1.0.0' },
repositoryProfileRevisionId: null,
workMode: 'execute',
autonomyLevel: 'verify',
inputs: { request: 'Repair the failure' },
scopeOverrides: { includedPaths: ['src'] },
outputFormat: 'prompt',
})
})
it('projects blocks, provenance, policies, lint and compatibility without authority-bearing internals', async () => {
const response = await handlePreviewComposition(
post('/api/v1/compositions/preview'),
dependencies(),
)
const result = (await response.json()) as Record<string, unknown>
expect(result).toMatchObject({
renderDigest: digest,
exportReadiness: 'warning',
compatibility: { status: 'unknown' },
resolvedScope: { protectedPaths: ['.github'] },
})
expect(result.blocks).toEqual([
{ id: 'mission', heading: 'Mission', markdown: prompt },
])
expect(JSON.stringify(result.provenance)).toContain('user-input')
expect(result).not.toHaveProperty('snapshots')
expect(result).not.toHaveProperty('generatedBy')
})
it('rejects every client attempt to spoof authoritative output', async () => {
for (const field of [
'renderedPrompt',
'renderDigest',
'lintFindings',
'snapshots',
'generatedBy',
]) {
const deps = dependencies()
const response = await handlePreviewComposition(
post('/api/v1/compositions/preview', body({ [field]: 'spoofed' })),
deps,
)
expect(response.status, field).toBe(422)
expect((await error(response)).error.code, field).toBe(
'composition_request_invalid',
)
expect(deps.service.preview, field).not.toHaveBeenCalled()
}
})
it('requires same-origin requests and denies viewer preview or generation', async () => {
const origin = await handlePreviewComposition(
post('/api/v1/compositions/preview', body(), {
origin: 'https://attacker.example',
}),
dependencies(),
)
expect(origin.status).toBe(403)
const deniedService = service()
deniedService.preview = vi.fn(async () => {
throw Object.assign(new Error('private authorization detail'), {
code: 'workspace_access_denied',
})
})
deniedService.generate = vi.fn(async () => {
throw Object.assign(new Error('private authorization detail'), {
code: 'workspace_access_denied',
})
})
const viewer = { ...actor, workspaceRole: 'viewer' } satisfies ActorContext
const deps = dependencies({
resolveContext: vi.fn(async () => viewer),
service: deniedService,
})
const preview = await handlePreviewComposition(
post('/api/v1/compositions/preview'),
deps,
)
const generation = await handleGenerateRun(
post('/api/v1/runs', body(), { 'idempotency-key': 'generation-1' }),
deps,
)
expect(preview.status).toBe(403)
expect(generation.status).toBe(403)
expect(await generation.text()).not.toContain('private authorization')
})
it('returns 201 initially and 200 with a replay header for idempotent generation', async () => {
const createdService = service()
const created = await handleGenerateRun(
post('/api/v1/runs', body(), { 'idempotency-key': 'generation-1' }),
dependencies({ service: createdService }),
)
expect(created.status).toBe(201)
expect(created.headers.get('idempotency-replayed')).toBeNull()
expect(createdService.generate).toHaveBeenCalledWith(
actor,
expect.objectContaining({
playbook: { slug: 'root-cause-bugfix', version: '1.0.0' },
}),
'generation-1',
undefined,
)
const draftId = '00000000-0000-4000-8000-000000000005'
const draftService = service()
const fromDraft = await handleGenerateRun(
post('/api/v1/runs', body(), {
'idempotency-key': 'generation-draft-1',
'x-devrunbook-draft-id': draftId,
}),
dependencies({ service: draftService }),
)
expect(fromDraft.status).toBe(201)
expect(draftService.generate).toHaveBeenCalledWith(
actor,
expect.any(Object),
'generation-draft-1',
draftId,
)
const replayService = service()
replayService.generate = vi.fn(async () => ({ run, created: false }))
const replay = await handleGenerateRun(
post('/api/v1/runs', body(), { 'idempotency-key': 'generation-1' }),
dependencies({ service: replayService }),
)
expect(replay.status).toBe(200)
expect(replay.headers.get('idempotency-replayed')).toBe('true')
expect(await replay.json()).toMatchObject({
id: runId,
renderDigest: digest,
snapshots: { normalizedInput: { request: 'Repair the failure' } },
artifacts: [],
})
})
it('requires an idempotency key and maps conflicts or blocking findings safely', async () => {
const missing = await handleGenerateRun(
post('/api/v1/runs'),
dependencies(),
)
expect(missing.status).toBe(422)
expect((await error(missing)).error.code).toBe(
'generated_run_idempotency_key_invalid',
)
const conflictService = service()
conflictService.generate = vi.fn(async () => {
throw Object.assign(new Error('stored private input'), {
code: 'generated_run_idempotency_conflict',
})
})
const conflict = await handleGenerateRun(
post('/api/v1/runs', body(), { 'idempotency-key': 'generation-1' }),
dependencies({ service: conflictService }),
)
expect(conflict.status).toBe(409)
expect(await conflict.text()).not.toContain('stored private input')
const blockedService = service()
blockedService.generate = vi.fn(async () => {
throw Object.assign(new Error('prompt contained secret-value'), {
code: 'generated_run_lint_blocked',
details: { blockingRuleIds: ['SA001'] },
})
})
const blocked = await handleGenerateRun(
post('/api/v1/runs', body(), { 'idempotency-key': 'generation-2' }),
dependencies({ service: blockedService }),
)
expect(blocked.status).toBe(422)
const blockedBody = await blocked.text()
expect(blockedBody).toContain('SA001')
expect(blockedBody).not.toContain('secret-value')
})
it('rejects a malformed source draft id before generation', async () => {
const draftService = service()
const response = await handleGenerateRun(
post('/api/v1/runs', body(), {
'idempotency-key': 'generation-draft-invalid',
'x-devrunbook-draft-id': 'not-a-uuid',
}),
dependencies({ service: draftService }),
)
expect(response.status).toBe(422)
expect((await error(response)).error.code).toBe(
'composition_request_invalid',
)
expect(draftService.generate).not.toHaveBeenCalled()
})
it('returns an immutable generated-task projection and conceals inaccessible ids', async () => {
const artifactService = service()
artifactService.listArtifacts = vi.fn(async () => [
{
id: '00000000-0000-4000-8000-000000000006',
workspaceId,
runId,
artifactType: 'markdown' as const,
storageKey: 'f'.repeat(64),
filename: 'task.md',
mediaType: 'text/markdown; charset=utf-8',
sizeBytes: 123n,
sha256: 'e'.repeat(64),
expiresAt: '2026-10-25T10:00:00.000Z',
createdAt: '2026-07-27T10:05:00.000Z',
},
])
const deps = dependencies({ service: artifactService })
const response = await handleGetRun(
new Request(`https://devrunbook.example/api/v1/runs/${runId}`),
runId,
deps,
)
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(await response.json()).toMatchObject({
id: runId,
playbookSlug: 'root-cause-bugfix',
playbookVersion: '1.0.0',
playbookDigest: digest,
workMode: 'execute',
autonomyLevel: 'verify',
renderedPrompt: prompt,
renderDigest: digest,
artifacts: [
{
id: '00000000-0000-4000-8000-000000000006',
type: 'markdown',
filename: 'task.md',
sizeBytes: 123,
sha256: 'e'.repeat(64),
},
],
})
expect(artifactService.listArtifacts).toHaveBeenCalledWith(actor, runId)
const invalid = await handleGetRun(
new Request('https://devrunbook.example/api/v1/runs/not-a-uuid'),
'not-a-uuid',
dependencies(),
)
const hiddenService = service()
hiddenService.get = vi.fn(async () => {
throw Object.assign(new Error('cross-workspace run exists'), {
code: 'generated_run_not_found',
})
})
const hidden = await handleGetRun(
new Request(`https://devrunbook.example/api/v1/runs/${runId}`),
runId,
dependencies({ service: hiddenService }),
)
expect(invalid.status).toBe(404)
expect(hidden.status).toBe(404)
expect((await error(invalid)).error.code).toBe('generated_run_not_found')
expect((await error(hidden)).error.code).toBe('generated_run_not_found')
})
it('projects historical direct repository-profile snapshots without failing', async () => {
const historicalRun: GeneratedRun = {
...run,
snapshots: {
...run.snapshots,
repositoryProfile: {
apiVersion: 'devrunbook.io/v1',
kind: 'RepositoryProfile',
metadata: { name: 'historical-repository' },
spec: {},
},
},
}
const historicalService = service()
historicalService.get = vi.fn(async () => historicalRun)
historicalService.list = vi.fn(async () => ({
items: [historicalRun],
nextCursor: null,
}))
const deps = dependencies({ service: historicalService })
const detail = await handleGetRun(
new Request(`https://devrunbook.example/api/v1/runs/${runId}`),
runId,
deps,
)
const history = await handleListRuns(
new Request('https://devrunbook.example/api/v1/runs'),
deps,
)
expect(await detail.json()).toMatchObject({
repositoryName: 'historical-repository',
repositoryProfileRevision: null,
repositoryProfileDigest: null,
})
expect(await history.json()).toMatchObject({
items: [
{
repositoryName: 'historical-repository',
repositoryProfileRevision: null,
repositoryProfileDigest: null,
},
],
})
})
it('lists immutable generated-task summaries with strict filters and pagination', async () => {
const deps = dependencies()
const repositoryId = '00000000-0000-4000-8000-000000000005'
const response = await handleListRuns(
new Request(
`https://devrunbook.example/api/v1/runs?cursor=cursor-1&limit=25&playbookSlug=root-cause-bugfix&repositoryId=${repositoryId}`,
),
deps,
)
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(deps.service.list).toHaveBeenCalledWith(actor, {
cursor: 'cursor-1',
limit: 25,
playbookSlug: 'root-cause-bugfix',
repositoryId,
})
const page = (await response.json()) as {
items: readonly Record<string, unknown>[]
nextCursor: string | null
}
expect(page).toMatchObject({
items: [
{
id: runId,
playbookSlug: 'root-cause-bugfix',
renderDigest: digest,
},
],
nextCursor: 'cursor-next',
})
expect(page.items[0]).not.toHaveProperty('renderedPrompt')
expect(page.items[0]).not.toHaveProperty('snapshots')
expect(page.items[0]).not.toHaveProperty('generatedBy')
expect(page.items[0]).not.toHaveProperty('idempotencyKey')
})
it('rejects unknown, duplicate or malformed run-history query parameters', async () => {
const invalidQueries = [
'unknown=value',
'limit=10&limit=20',
'limit=0',
'limit=1.5',
'playbookSlug=Not-Canonical',
'repositoryId=not-a-uuid',
'cursor=',
]
for (const query of invalidQueries) {
const deps = dependencies()
const response = await handleListRuns(
new Request(`https://devrunbook.example/api/v1/runs?${query}`),
deps,
)
expect(response.status, query).toBe(422)
expect((await error(response)).error.code, query).toBe(
'generated_run_query_invalid',
)
expect(deps.service.list, query).not.toHaveBeenCalled()
}
})
})
@@ -0,0 +1,685 @@
import type {
ActorContext,
AuthoritativeCompositionResult,
GeneratedRun,
GeneratedRunHistoryQuery,
GeneratedRunPage,
GeneratedArtifactMetadata,
StoreGeneratedRunResult,
} from '@devrunbook/application'
import { handleAuthRequest } from '../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
import type { AuthoritativeCompositionHttpRequest } from '../../../../server/authoritative-compositions'
import { parseCompositionRequestBody } from './drafts/composition-draft-http'
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
const playbookSlugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
const draftParserCodes = new Set([
'composition_request_too_large',
'composition_request_body_required',
'composition_content_type_unsupported',
'composition_request_utf8_invalid',
'composition_json_invalid',
'composition_json_duplicate_key',
'composition_request_invalid',
])
export interface AuthoritativeCompositionHttpService {
preview(
actor: ActorContext,
request: AuthoritativeCompositionHttpRequest,
): Promise<AuthoritativeCompositionResult>
generate(
actor: ActorContext,
request: AuthoritativeCompositionHttpRequest,
idempotencyKey: string,
sourceDraftId?: string,
): Promise<StoreGeneratedRunResult>
get(actor: ActorContext, runId: string): Promise<GeneratedRun>
listArtifacts(
actor: ActorContext,
runId: string,
): Promise<readonly GeneratedArtifactMetadata[]>
list(
actor: ActorContext,
query: GeneratedRunHistoryQuery,
): Promise<GeneratedRunPage>
}
export interface AuthoritativeCompositionRouteDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly service: AuthoritativeCompositionHttpService
}
interface ErrorLike {
readonly code: string
readonly message?: string
readonly status?: number
readonly details: Readonly<Record<string, unknown>>
}
function plainObject(value: unknown): value is Record<string, unknown> {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
(Object.getPrototypeOf(value) === Object.prototype ||
Object.getPrototypeOf(value) === null)
)
}
function errorLike(caught: unknown): ErrorLike | null {
if (caught === null || typeof caught !== 'object' || Array.isArray(caught))
return null
const candidate = caught as Readonly<Record<string, unknown>>
if (typeof candidate.code !== 'string') return null
return {
code: candidate.code,
...(typeof candidate.message === 'string'
? { message: candidate.message }
: {}),
...(typeof candidate.status === 'number'
? { status: candidate.status }
: {}),
details: plainObject(candidate.details) ? candidate.details : {},
}
}
function errorResponse(
status: number,
code: string,
message: string,
requestId: string,
details?: readonly Readonly<Record<string, unknown>>[],
headers: Readonly<Record<string, string>> = {},
) {
return Response.json(
{
error: {
code,
message,
requestId,
...(details?.length ? { details } : {}),
},
},
{ status, headers: { 'Cache-Control': 'no-store', ...headers } },
)
}
function mappedError(caught: unknown, requestId: string): Response {
if (caught instanceof AuthenticatedWorkspaceContextError) {
return errorResponse(
caught.code === 'authentication_required' ? 401 : 403,
caught.code,
caught.code === 'authentication_required'
? 'Authentication required'
: 'Access denied',
requestId,
)
}
const error = errorLike(caught)
if (error?.code === 'authentication_required') {
return errorResponse(401, error.code, 'Authentication required', requestId)
}
if (error?.code === 'workspace_access_denied') {
return errorResponse(403, error.code, 'Access denied', requestId)
}
if (
error?.code === 'composition_source_not_found' ||
error?.code === 'generated_run_not_found'
) {
return errorResponse(404, error.code, 'Resource not found', requestId)
}
if (error?.code === 'generated_run_idempotency_conflict') {
return errorResponse(
409,
error.code,
'Idempotency key is already associated with different composition input',
requestId,
)
}
if (error?.code === 'generated_run_lint_blocked') {
const blockingRuleIds = Array.isArray(error.details.blockingRuleIds)
? error.details.blockingRuleIds.filter(
(value): value is string => typeof value === 'string',
)
: []
return errorResponse(
422,
error.code,
'Immutable generation is blocked by composition findings',
requestId,
blockingRuleIds.map((ruleId) => ({
path: 'lintFindings',
rule: ruleId,
message: 'Resolve this blocking finding before generation',
})),
)
}
if (error?.code === 'generated_run_idempotency_key_invalid') {
return errorResponse(
422,
error.code,
'Idempotency-Key must contain 1 to 255 characters without surrounding whitespace',
requestId,
)
}
if (
error?.code === 'generated_run_query_invalid' ||
error?.code === 'generated_run_cursor_invalid' ||
error?.code === 'generated_run_list_limit_invalid' ||
error?.code === 'generated_run_filter_invalid'
) {
return errorResponse(
422,
error.code,
'Generated task history query is invalid',
requestId,
)
}
if (error && draftParserCodes.has(error.code)) {
return errorResponse(
error.code === 'composition_request_too_large' ? 413 : 422,
error.code,
error.code === 'composition_request_too_large'
? 'Composition request is too large'
: 'Composition request is invalid',
requestId,
)
}
return errorResponse(
503,
'composition_service_unavailable',
'Composition service is temporarily unavailable',
requestId,
)
}
function mutationBoundary(
request: Request,
dependencies: AuthoritativeCompositionRouteDependencies,
requestId: string,
operation: (request: Request) => Promise<Response>,
): Promise<Response> {
return handleAuthRequest(
request,
async (sameOriginRequest) => {
if (
sameOriginRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return errorResponse(
403,
'invalid_origin',
'Invalid request origin',
requestId,
)
}
return operation(sameOriginRequest)
},
dependencies.publicBaseUrl,
() =>
errorResponse(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
async function parseAuthoritativeRequest(
request: Request,
): Promise<AuthoritativeCompositionHttpRequest> {
const parsed = await parseCompositionRequestBody(request)
return {
playbook: parsed.playbook,
repositoryProfileRevisionId:
parsed.draft.repositoryProfileRevisionId ?? null,
inputs: parsed.draft.inputs as Readonly<Record<string, unknown>>,
scopeOverrides: parsed.draft.scopeOverrides as Readonly<
Record<string, unknown>
>,
workMode: parsed.draft.workMode,
autonomyLevel: parsed.draft.autonomyLevel,
outputFormat: parsed.draft.outputFormat,
}
}
function lintSource(source: string): string {
if (source === 'playbook') return 'playbook'
if (source === 'repository-profile') return 'repository-profile'
if (source === 'input') return 'user-input'
if (source === 'policy') return 'platform-policy'
return 'composer'
}
function provenanceSource(source: string): {
readonly type: string
readonly reference: string
} {
if (source === 'platform-policy') {
return { type: 'platform-policy', reference: 'platform-v1' }
}
for (const type of [
'playbook',
'repository-profile',
'user-input',
'inferred-default',
] as const) {
if (source.startsWith(`${type}:`)) {
return { type, reference: source.slice(type.length + 1) }
}
}
return { type: 'inferred-default', reference: `composer:${source}` }
}
function safeIdentifier(value: string): string {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9._-]+/gu, '-')
.replace(/^[^a-z]+/u, '')
.slice(0, 120)
return normalized || 'policy'
}
function resolvedPolicies(
result: AuthoritativeCompositionResult,
): readonly Readonly<Record<string, unknown>>[] {
const policies = result.preview.resolvedPolicies
const playbook = result.snapshots.playbook
const slug = String(playbook.slug ?? 'playbook')
const version = String(playbook.version ?? 'unknown')
const repositoryReference = result.repositoryProfileRevisionId ?? 'no-profile'
return [
...Object.entries(policies.platform)
.sort(([left], [right]) => left.localeCompare(right, 'en'))
.map(([id, value]) => ({
id: safeIdentifier(`platform.${id}`),
value,
source: { type: 'platform-policy', reference: 'platform-v1' },
nonOverridable: true,
})),
...Object.entries(policies.repository)
.sort(([left], [right]) => left.localeCompare(right, 'en'))
.map(([id, value]) => ({
id: safeIdentifier(`repository.${id}`),
value,
source: {
type: 'repository-profile',
reference: repositoryReference,
},
nonOverridable: false,
})),
...policies.appliedGuardrailIds.map((id) => ({
id: safeIdentifier(`guardrail.${id}`),
value: true,
source: { type: 'playbook', reference: `${slug}@${version}` },
nonOverridable: false,
})),
...policies.unresolvedConditions.map((id) => ({
id: safeIdentifier(`condition.${id}`),
value: 'fail-closed',
source: { type: 'playbook', reference: `${slug}@${version}` },
nonOverridable: false,
})),
...policies.confirmedUnsafeCommandIds.map((id) => ({
id: safeIdentifier(`command-confirmation.${id}`),
value: true,
source: { type: 'user-input', reference: `command:${id}` },
nonOverridable: false,
})),
]
}
function projectProvenance(
provenance: readonly unknown[],
factAccesses: readonly {
readonly path: string
readonly found: boolean
readonly result: string
}[] = [],
) {
return provenance.flatMap((entry, index) => {
if (!plainObject(entry)) return []
const sources = Array.isArray(entry.sources)
? entry.sources
.filter((source): source is string => typeof source === 'string')
.map(provenanceSource)
: []
if (typeof entry.blockId !== 'string' || sources.length === 0) return []
return [
{
blockId: entry.blockId,
sources,
controlPath: null,
factAccesses:
index === 0
? factAccesses.flatMap((access) => {
return [
{
path: access.path,
outcome:
access.found === false
? 'missing'
: access.result === 'unknown'
? 'type-mismatch'
: 'resolved',
},
]
})
: [],
},
]
})
}
function previewEnvelope(result: AuthoritativeCompositionResult) {
const preview = result.preview
const compatibilitySeverity =
preview.compatibility.status === 'incompatible' ? 'error' : 'warning'
return {
normalizedInput: preview.normalizedInput,
compatibility: {
status: preview.compatibility.status,
findings: preview.compatibility.reasons.map((message, index) => ({
code: safeIdentifier(`compatibility.reason.${index + 1}`),
severity: compatibilitySeverity,
message,
source: result.repositoryProfileRevisionId
? 'repository-profile'
: 'playbook',
controlPath: 'repositoryProfileRevisionId',
capability: preview.compatibility.missingCapabilities[index] ?? null,
})),
},
resolvedPolicies: resolvedPolicies(result),
resolvedScope: {
includedPaths: preview.resolvedScope.includedPaths,
excludedPaths: preview.resolvedScope.excludedPaths,
protectedPaths: preview.resolvedScope.protectedPaths,
allowableChangeTypes: preview.resolvedScope.allowableChangeTypes,
repositoryWideRead: preview.resolvedScope.repositoryWideRead,
},
blocks: preview.blocks,
renderedPrompt: preview.renderedPrompt,
renderDigest: preview.renderDigest,
lintFindings: preview.lintFindings.map((finding) => ({
ruleId: finding.ruleId.toLowerCase(),
severity: finding.severity,
message: finding.message,
source: lintSource(finding.source),
controlPath: finding.controlPath,
})),
exportReadiness: preview.exportReadiness,
provenance: projectProvenance(
preview.provenance,
preview.conditionAccesses,
),
}
}
function requiredRecord(
value: unknown,
label: string,
): Readonly<Record<string, unknown>> {
if (!plainObject(value)) throw new Error(`${label} snapshot is invalid`)
return value
}
function runSummary(run: GeneratedRun) {
const playbook = requiredRecord(run.snapshots.playbook, 'Playbook')
const policy = requiredRecord(run.snapshots.policy, 'Policy')
const repository =
run.snapshots.repositoryProfile === null
? null
: requiredRecord(run.snapshots.repositoryProfile, 'Repository profile')
const profile = repository
? plainObject(repository.profile)
? repository.profile
: repository
: null
const metadata = profile?.metadata
const repositoryMetadata = plainObject(metadata) ? metadata : null
if (
typeof playbook.slug !== 'string' ||
typeof playbook.version !== 'string' ||
typeof playbook.digest !== 'string' ||
typeof policy.workMode !== 'string' ||
typeof policy.autonomyLevel !== 'string'
) {
throw new Error('Generated task snapshots are incomplete')
}
return {
id: run.id,
playbookSlug: playbook.slug,
playbookVersion: playbook.version,
playbookDigest: playbook.digest,
repositoryName:
repositoryMetadata && typeof repositoryMetadata.name === 'string'
? repositoryMetadata.name
: null,
repositoryProfileRevision:
repository && typeof repository.revisionNumber === 'number'
? repository.revisionNumber
: null,
repositoryProfileDigest:
repository && typeof repository.contentDigest === 'string'
? repository.contentDigest
: null,
workMode: policy.workMode,
autonomyLevel: policy.autonomyLevel,
renderDigest: run.renderDigest,
generatedAt: run.generatedAt,
}
}
function runEnvelope(
run: GeneratedRun,
artifacts: readonly GeneratedArtifactMetadata[] = [],
) {
const summary = runSummary(run)
const provenance = projectProvenance(run.snapshots.provenance)
return {
...summary,
renderedPrompt: run.renderedPrompt,
snapshots: {
playbook: run.snapshots.playbook,
repositoryProfile: run.snapshots.repositoryProfile,
normalizedInput: run.snapshots.normalizedInput,
policy: run.snapshots.policy,
provenance,
},
lintFindings: run.lint.findings.map((finding) => ({
ruleId: finding.ruleId.toLowerCase(),
severity: finding.severity,
message: finding.message,
source: lintSource(finding.source),
controlPath: finding.controlPath ?? null,
})),
provenance,
artifacts: artifacts.map((artifact) => ({
id: artifact.id,
runId: artifact.runId,
type: artifact.artifactType,
filename: artifact.filename,
mediaType: artifact.mediaType,
sizeBytes: Number(artifact.sizeBytes),
sha256: artifact.sha256,
createdAt: artifact.createdAt,
expiresAt: artifact.expiresAt,
downloadUrl: `/api/v1/artifacts/${artifact.id}/download`,
})),
}
}
function invalidRunQuery(): never {
throw Object.assign(new Error('Invalid generated task history query'), {
code: 'generated_run_query_invalid',
})
}
function parseRunHistoryQuery(request: Request): GeneratedRunHistoryQuery {
const params = new URL(request.url).searchParams
const allowed = new Set(['cursor', 'limit', 'playbookSlug', 'repositoryId'])
for (const key of params.keys()) {
if (!allowed.has(key) || params.getAll(key).length !== 1) invalidRunQuery()
}
const cursor = params.get('cursor')
const limitValue = params.get('limit')
const playbookSlug = params.get('playbookSlug')
const repositoryId = params.get('repositoryId')
if (
(cursor !== null && (cursor.length < 1 || cursor.length > 500)) ||
(limitValue !== null && !/^[0-9]+$/u.test(limitValue)) ||
(playbookSlug !== null &&
(playbookSlug.length > 120 || !playbookSlugPattern.test(playbookSlug))) ||
(repositoryId !== null && !uuidPattern.test(repositoryId))
) {
invalidRunQuery()
}
const limit = limitValue === null ? 50 : Number(limitValue)
if (!Number.isInteger(limit) || limit < 1 || limit > 100) invalidRunQuery()
return {
...(cursor === null ? {} : { cursor }),
limit,
...(playbookSlug === null ? {} : { playbookSlug }),
...(repositoryId === null ? {} : { repositoryId }),
}
}
function assertRunId(runId: string): void {
if (!uuidPattern.test(runId)) {
throw Object.assign(new Error('Generated task not found'), {
code: 'generated_run_not_found',
})
}
}
function idempotencyKey(request: Request): string {
const key = request.headers.get('idempotency-key')
if (!key || key.length > 255 || key.trim() !== key) {
throw Object.assign(new Error('Invalid idempotency key'), {
code: 'generated_run_idempotency_key_invalid',
})
}
return key
}
function sourceDraftId(request: Request): string | undefined {
const draftId = request.headers.get('x-devrunbook-draft-id')
if (draftId === null) return undefined
if (!uuidPattern.test(draftId)) {
throw Object.assign(new Error('Invalid source draft id'), {
code: 'composition_request_invalid',
details: {
issues: [
{
path: 'headers.x-devrunbook-draft-id',
message: 'X-DevRunbook-Draft-Id must be a UUID',
},
],
},
})
}
return draftId
}
export function handlePreviewComposition(
request: Request,
dependencies: AuthoritativeCompositionRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
return mutationBoundary(
request,
dependencies,
requestId,
async (safeRequest) => {
try {
const actor = await dependencies.resolveContext(safeRequest)
const composition = await parseAuthoritativeRequest(safeRequest)
const result = await dependencies.service.preview(actor, composition)
return Response.json(previewEnvelope(result), {
headers: { 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
},
)
}
export function handleGenerateRun(
request: Request,
dependencies: AuthoritativeCompositionRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
return mutationBoundary(
request,
dependencies,
requestId,
async (safeRequest) => {
try {
const actor = await dependencies.resolveContext(safeRequest)
const key = idempotencyKey(safeRequest)
const draftId = sourceDraftId(safeRequest)
const composition = await parseAuthoritativeRequest(safeRequest)
const result = await dependencies.service.generate(
actor,
composition,
key,
draftId,
)
return Response.json(runEnvelope(result.run), {
status: result.created ? 201 : 200,
headers: {
'Cache-Control': 'no-store',
...(result.created ? {} : { 'Idempotency-Replayed': 'true' }),
},
})
} catch (caught) {
return mappedError(caught, requestId)
}
},
)
}
export async function handleGetRun(
request: Request,
runId: string,
dependencies: AuthoritativeCompositionRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
try {
const actor = await dependencies.resolveContext(request)
assertRunId(runId)
const run = await dependencies.service.get(actor, runId)
const artifacts = await dependencies.service.listArtifacts(actor, runId)
return Response.json(runEnvelope(run, artifacts), {
headers: { 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
}
export async function handleListRuns(
request: Request,
dependencies: AuthoritativeCompositionRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
try {
const actor = await dependencies.resolveContext(request)
const query = parseRunHistoryQuery(request)
const page = await dependencies.service.list(actor, query)
return Response.json(
{
items: page.items.map(runSummary),
nextCursor: page.nextCursor,
},
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (caught) {
return mappedError(caught, requestId)
}
}
@@ -0,0 +1,14 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../server/authenticated-workspace-context'
import { getAuthoritativeCompositionServer } from '../../../../server/authoritative-compositions'
import type { AuthoritativeCompositionRouteDependencies } from './composition-http'
export function authoritativeCompositionRouteDependencies(): AuthoritativeCompositionRouteDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
service: getAuthoritativeCompositionServer(),
}
}
@@ -0,0 +1,33 @@
import {
handleGetCompositionDraft,
handlePatchCompositionDraft,
} from '../composition-draft-http'
import { compositionDraftRouteDependencies } from '../composition-draft-route-dependencies'
export const dynamic = 'force-dynamic'
export function GET(
request: Request,
context: { params: Promise<{ draftId: string }> },
) {
return context.params.then(({ draftId }) =>
handleGetCompositionDraft(
request,
draftId,
compositionDraftRouteDependencies(),
),
)
}
export function PATCH(
request: Request,
context: { params: Promise<{ draftId: string }> },
) {
return context.params.then(({ draftId }) =>
handlePatchCompositionDraft(
request,
draftId,
compositionDraftRouteDependencies(),
),
)
}
@@ -0,0 +1,446 @@
import type { ActorContext, CompositionDraft } from '@devrunbook/application'
import { describe, expect, it, vi } from 'vitest'
import {
handleCreateCompositionDraft,
handleGetCompositionDraft,
handlePatchCompositionDraft,
type CompositionDraftHttpService,
type CompositionDraftRouteDependencies,
} from './composition-draft-http'
const userId = '00000000-0000-4000-8000-000000000001'
const workspaceId = '00000000-0000-4000-8000-000000000002'
const draftId = '00000000-0000-4000-8000-000000000003'
const playbookVersionId = '00000000-0000-4000-8000-000000000004'
const profileRevisionId = '00000000-0000-4000-8000-000000000005'
const actor: ActorContext = {
userId,
instanceRole: 'user',
workspaceId,
workspaceRole: 'owner',
}
const playbook = { slug: 'root-cause-bugfix', version: '1.0.0' }
const draft: CompositionDraft = {
id: draftId,
workspaceId,
playbookVersionId,
repositoryProfileRevisionId: profileRevisionId,
inputs: { summary: 'Fix the bounded failure' },
scopeOverrides: { includedPaths: ['src'] },
policyOverrides: {},
autonomyLevel: 'verify',
workMode: 'execute',
outputFormat: 'prompt',
lastRenderDigest: null,
revision: 1,
createdBy: userId,
createdAt: '2026-07-27T10:00:00.000Z',
updatedAt: '2026-07-27T10:00:00.000Z',
}
function service(): CompositionDraftHttpService {
return {
create: vi.fn(async () => ({
result: { draft, etag: '"draft:1"' },
playbook,
})),
get: vi.fn(async () => ({
result: { draft, etag: '"draft:1"' },
playbook,
})),
patch: vi.fn(async () => ({
result: { draft, etag: '"draft:1"', changed: false },
playbook,
})),
}
}
function dependencies(
overrides: Partial<CompositionDraftRouteDependencies> = {},
): CompositionDraftRouteDependencies {
return {
publicBaseUrl: 'https://devrunbook.example',
resolveContext: vi.fn(async () => actor),
service: service(),
...overrides,
}
}
function request(
path: string,
method: 'POST' | 'PATCH',
body: string | ArrayBuffer,
headers: Record<string, string> = {},
) {
return new Request(`https://devrunbook.example${path}`, {
method,
body,
headers: {
origin: 'https://devrunbook.example',
'content-type': 'application/json',
...headers,
},
})
}
function createBody(overrides: Record<string, unknown> = {}) {
return JSON.stringify({
playbook,
repositoryProfileRevisionId: profileRevisionId,
workMode: 'execute',
autonomyLevel: 'verify',
inputs: { summary: 'Fix the bounded failure' },
scopeOverrides: { includedPaths: ['src'] },
...overrides,
})
}
async function error(response: Response) {
return (await response.json()) as {
error: {
code: string
requestId: string
details?: readonly Record<string, unknown>[]
}
}
}
describe('composition draft HTTP boundary', () => {
it('creates a strict draft and returns the OpenAPI envelope with a strong ETag', async () => {
const deps = dependencies()
const response = await handleCreateCompositionDraft(
request('/api/v1/compositions/drafts', 'POST', createBody()),
deps,
)
expect(response.status).toBe(201)
expect(response.headers.get('etag')).toBe('"draft:1"')
expect(response.headers.get('cache-control')).toBe('no-store')
expect(deps.service.create).toHaveBeenCalledWith(actor, playbook, {
repositoryProfileRevisionId: profileRevisionId,
workMode: 'execute',
autonomyLevel: 'verify',
inputs: { summary: 'Fix the bounded failure' },
scopeOverrides: { includedPaths: ['src'] },
outputFormat: 'prompt',
})
expect(await response.json()).toEqual({
id: draftId,
revision: 1,
request: {
playbook,
repositoryProfileRevisionId: profileRevisionId,
workMode: 'execute',
autonomyLevel: 'verify',
inputs: draft.inputs,
scopeOverrides: draft.scopeOverrides,
outputFormat: 'prompt',
},
lastRenderDigest: null,
createdAt: draft.createdAt,
updatedAt: draft.updatedAt,
})
})
it('reads only the authorized workspace projection and conceals invalid or inaccessible ids', async () => {
const deps = dependencies()
const found = await handleGetCompositionDraft(
new Request(
`https://devrunbook.example/api/v1/compositions/drafts/${draftId}`,
),
draftId,
deps,
)
expect(found.status).toBe(200)
expect(found.headers.get('etag')).toBe('"draft:1"')
expect(deps.service.get).toHaveBeenCalledWith(actor, draftId)
const invalid = await handleGetCompositionDraft(
new Request(
'https://devrunbook.example/api/v1/compositions/drafts/not-a-uuid',
),
'not-a-uuid',
dependencies(),
)
const concealedService = service()
concealedService.get = vi.fn(async () => {
throw Object.assign(new Error('hidden'), {
code: 'composition_draft_not_found',
})
})
const concealed = await handleGetCompositionDraft(
new Request(
`https://devrunbook.example/api/v1/compositions/drafts/${draftId}`,
),
draftId,
dependencies({ service: concealedService }),
)
expect(invalid.status).toBe(404)
expect(concealed.status).toBe(404)
expect((await error(invalid)).error.code).toBe(
'composition_draft_not_found',
)
expect((await error(concealed)).error.code).toBe(
'composition_draft_not_found',
)
})
it('requires same-origin JSON mutations and denies viewer writes safely', async () => {
const crossOrigin = await handleCreateCompositionDraft(
request('/api/v1/compositions/drafts', 'POST', createBody(), {
origin: 'https://attacker.example',
}),
dependencies(),
)
expect(crossOrigin.status).toBe(403)
expect((await error(crossOrigin)).error.code).toBe('invalid_origin')
const wrongType = await handleCreateCompositionDraft(
request('/api/v1/compositions/drafts', 'POST', createBody(), {
'content-type': 'text/plain',
}),
dependencies(),
)
expect(wrongType.status).toBe(422)
expect((await error(wrongType)).error.code).toBe(
'composition_content_type_unsupported',
)
const wrongCharset = await handleCreateCompositionDraft(
request('/api/v1/compositions/drafts', 'POST', createBody(), {
'content-type': 'application/json; charset=iso-8859-1',
}),
dependencies(),
)
expect(wrongCharset.status).toBe(422)
const viewerService = service()
viewerService.create = vi.fn(async () => {
throw Object.assign(new Error('internal authorization detail'), {
code: 'workspace_access_denied',
})
})
const denied = await handleCreateCompositionDraft(
request('/api/v1/compositions/drafts', 'POST', createBody()),
dependencies({
resolveContext: vi.fn(
async () =>
({ ...actor, workspaceRole: 'viewer' }) satisfies ActorContext,
),
service: viewerService,
}),
)
expect(denied.status).toBe(403)
expect(await denied.text()).not.toContain('internal authorization detail')
})
it('rejects duplicate keys, unknown properties, invalid UTF-8 and oversized streams', async () => {
const duplicate = await handleCreateCompositionDraft(
request(
'/api/v1/compositions/drafts',
'POST',
'{"playbook":{"slug":"one","slug":"two","version":"1.0.0"},"workMode":"execute","autonomyLevel":"verify","inputs":{}}',
),
dependencies(),
)
expect(duplicate.status).toBe(422)
expect((await error(duplicate)).error.code).toBe(
'composition_json_duplicate_key',
)
const unknown = await handleCreateCompositionDraft(
request(
'/api/v1/compositions/drafts',
'POST',
createBody({ clientComputedReadiness: 'ready' }),
),
dependencies(),
)
expect(unknown.status).toBe(422)
expect((await error(unknown)).error.code).toBe(
'composition_request_invalid',
)
const invalidUtf8 = await handleCreateCompositionDraft(
request(
'/api/v1/compositions/drafts',
'POST',
new Uint8Array([0xff]).buffer,
),
dependencies(),
)
expect(invalidUtf8.status).toBe(422)
expect((await error(invalidUtf8)).error.code).toBe(
'composition_request_utf8_invalid',
)
const oversizedService = service()
const oversized = await handleCreateCompositionDraft(
request(
'/api/v1/compositions/drafts',
'POST',
new Uint8Array(1_048_577).buffer,
),
dependencies({ service: oversizedService }),
)
expect(oversized.status).toBe(413)
expect((await error(oversized)).error.code).toBe(
'composition_request_too_large',
)
expect(oversizedService.create).not.toHaveBeenCalled()
})
it('validates UUIDs and governed enum values before invoking the service', async () => {
const deps = dependencies()
const badProfile = await handleCreateCompositionDraft(
request(
'/api/v1/compositions/drafts',
'POST',
createBody({ repositoryProfileRevisionId: '../other-workspace' }),
),
deps,
)
expect(badProfile.status).toBe(422)
const badMode = await handleCreateCompositionDraft(
request(
'/api/v1/compositions/drafts',
'POST',
createBody({ workMode: 'run-shell' }),
),
deps,
)
expect(badMode.status).toBe(422)
const traversal = await handleCreateCompositionDraft(
request(
'/api/v1/compositions/drafts',
'POST',
createBody({ scopeOverrides: { includedPaths: ['../secrets'] } }),
),
deps,
)
expect(traversal.status).toBe(422)
expect(deps.service.create).not.toHaveBeenCalled()
})
it('requires a valid If-Match before parsing or patching', async () => {
const deps = dependencies()
const missing = await handlePatchCompositionDraft(
request(
`/api/v1/compositions/drafts/${draftId}`,
'PATCH',
JSON.stringify({ autonomyLevel: 'repair' }),
),
draftId,
deps,
)
expect(missing.status).toBe(428)
expect((await error(missing)).error.code).toBe(
'composition_draft_precondition_required',
)
expect(deps.service.patch).not.toHaveBeenCalled()
const malformedService = service()
const malformed = await handlePatchCompositionDraft(
request(
`/api/v1/compositions/drafts/${draftId}`,
'PATCH',
JSON.stringify({ autonomyLevel: 'repair' }),
{ 'if-match': 'W/"draft:1"' },
),
draftId,
dependencies({ service: malformedService }),
)
expect(malformed.status).toBe(422)
expect(malformedService.patch).not.toHaveBeenCalled()
})
it('returns the current ETag and safe recovery detail for stale patches', async () => {
const staleService = service()
staleService.patch = vi.fn(async () => {
throw Object.assign(new Error('postgres://user:secret@db/private'), {
code: 'composition_draft_conflict',
details: {
currentRevision: 2,
currentEtag: '"draft:2"',
recovery: 'reload-and-review',
secret: 'must not escape',
},
})
})
const response = await handlePatchCompositionDraft(
request(
`/api/v1/compositions/drafts/${draftId}`,
'PATCH',
JSON.stringify({ autonomyLevel: 'repair' }),
{ 'if-match': '"draft:1"' },
),
draftId,
dependencies({ service: staleService }),
)
expect(response.status).toBe(409)
expect(response.headers.get('etag')).toBe('"draft:2"')
const body = await response.text()
expect(body).toContain('reload-and-review')
expect(body).not.toContain('secret')
expect(body).not.toContain('postgres://')
})
it('patches an allowed field and never reflects unexpected failures', async () => {
const deps = dependencies()
const patched = await handlePatchCompositionDraft(
request(
`/api/v1/compositions/drafts/${draftId}`,
'PATCH',
JSON.stringify({ autonomyLevel: 'repair' }),
{ 'if-match': '"draft:1"' },
),
draftId,
deps,
)
expect(patched.status).toBe(200)
expect(deps.service.patch).toHaveBeenCalledWith(
actor,
draftId,
'"draft:1"',
{ autonomyLevel: 'repair' },
)
const digestDeps = dependencies()
const digestPatch = await handlePatchCompositionDraft(
request(
`/api/v1/compositions/drafts/${draftId}`,
'PATCH',
JSON.stringify({ lastRenderDigest: 'b'.repeat(64) }),
{ 'if-match': '"draft:1"' },
),
draftId,
digestDeps,
)
expect(digestPatch.status).toBe(200)
expect(digestDeps.service.patch).toHaveBeenCalledWith(
actor,
draftId,
'"draft:1"',
{ lastRenderDigest: 'b'.repeat(64) },
)
const unavailableService = service()
unavailableService.get = vi.fn(async () => {
throw new Error('postgres://operator:secret@database/internal')
})
const unavailable = await handleGetCompositionDraft(
new Request(
`https://devrunbook.example/api/v1/compositions/drafts/${draftId}`,
),
draftId,
dependencies({ service: unavailableService }),
)
expect(unavailable.status).toBe(503)
const body = await unavailable.text()
expect(body).not.toContain('secret')
expect(JSON.parse(body).error.requestId).toMatch(/^[0-9a-f-]{36}$/u)
})
})
@@ -0,0 +1,909 @@
import type {
ActorContext,
CompositionDraftResult,
PatchCompositionDraftResult,
} from '@devrunbook/application'
import { handleAuthRequest } from '../../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../../server/authenticated-workspace-context'
const maximumDraftBytes = 1_048_576
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
const semverPattern =
/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u
const workModes = new Set(['inspect', 'plan', 'guided', 'execute', 'recovery'])
const autonomyLevels = new Set([
'observe',
'diagnose',
'plan',
'implement',
'verify',
'repair',
])
const outputFormats = new Set(['prompt', 'markdown', 'run-pack'])
const createKeys = new Set([
'playbook',
'repositoryProfileRevisionId',
'workMode',
'autonomyLevel',
'inputs',
'scopeOverrides',
'outputFormat',
])
const patchKeys = new Set([
'repositoryProfileRevisionId',
'workMode',
'autonomyLevel',
'inputs',
'scopeOverrides',
'outputFormat',
'lastRenderDigest',
])
const scopeKeys = new Set([
'includedPaths',
'excludedPaths',
'allowableChangeTypes',
'repositoryWideRead',
])
export interface CompositionPlaybookReference {
readonly slug: string
readonly version: string
}
export interface CompositionDraftWrite {
readonly repositoryProfileRevisionId?: string | null
readonly inputs?: unknown
readonly scopeOverrides?: unknown
readonly autonomyLevel?: string
readonly workMode?: string
readonly outputFormat?: string
readonly lastRenderDigest?: string | null
}
export interface CompositionDraftHttpResult {
readonly result: CompositionDraftResult | PatchCompositionDraftResult
readonly playbook: CompositionPlaybookReference
}
export interface CompositionDraftHttpService {
create(
actor: ActorContext,
playbook: CompositionPlaybookReference,
draft: Required<
Pick<
CompositionDraftWrite,
'inputs' | 'autonomyLevel' | 'workMode' | 'outputFormat'
>
> &
CompositionDraftWrite,
): Promise<CompositionDraftHttpResult>
get(actor: ActorContext, draftId: string): Promise<CompositionDraftHttpResult>
patch(
actor: ActorContext,
draftId: string,
expectedEtag: string,
patch: CompositionDraftWrite,
): Promise<CompositionDraftHttpResult>
}
export interface CompositionDraftRouteDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly service: CompositionDraftHttpService
}
interface SafeDetail {
readonly path: string
readonly rule: string
readonly message: string
readonly currentEtag?: string
readonly currentRevision?: number
readonly recovery?: string
}
class CompositionDraftHttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: readonly SafeDetail[],
readonly headers?: Readonly<Record<string, string>>,
) {
super(message)
}
}
function errorResponse(
status: number,
code: string,
message: string,
requestId: string,
details?: readonly SafeDetail[],
headers: Readonly<Record<string, string>> = {},
) {
return Response.json(
{
error: {
code,
message,
requestId,
...(details?.length ? { details } : {}),
},
},
{ status, headers: { 'Cache-Control': 'no-store', ...headers } },
)
}
function validation(
code: string,
message: string,
path: string,
status = 422,
): never {
throw new CompositionDraftHttpError(status, code, message, [
{ path, rule: code, message },
])
}
function plainObject(value: unknown): value is Record<string, unknown> {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
)
}
function assertExactKeys(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
path: string,
): void {
const unknown = Object.keys(value).find((key) => !allowed.has(key))
if (unknown) {
validation(
'composition_request_invalid',
`Unsupported property: ${unknown}`,
`${path}/${unknown}`,
)
}
}
export function parseStrictJson(text: string): unknown {
let offset = 0
const whitespace = /\s/u
const number = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/uy
function fail(): never {
throw new SyntaxError('Invalid JSON document')
}
function skipWhitespace(): void {
while (offset < text.length && whitespace.test(text[offset]!)) offset += 1
}
function parseString(): string {
if (text[offset] !== '"') fail()
const start = offset
offset += 1
while (offset < text.length) {
const character = text[offset]
if (character === '\\') {
offset += 2
continue
}
offset += 1
if (character === '"') {
return JSON.parse(text.slice(start, offset)) as string
}
}
return fail()
}
function parseValue(): void {
skipWhitespace()
const character = text[offset]
if (character === '{') return parseObject()
if (character === '[') return parseArray()
if (character === '"') {
parseString()
return
}
for (const literal of ['true', 'false', 'null']) {
if (text.startsWith(literal, offset)) {
offset += literal.length
return
}
}
number.lastIndex = offset
const match = number.exec(text)
if (!match) fail()
offset = number.lastIndex
}
function parseObject(): void {
offset += 1
skipWhitespace()
const keys = new Set<string>()
if (text[offset] === '}') {
offset += 1
return
}
while (offset < text.length) {
const key = parseString()
if (keys.has(key)) {
validation(
'composition_json_duplicate_key',
'JSON objects must not contain duplicate keys',
'/',
)
}
keys.add(key)
skipWhitespace()
if (text[offset] !== ':') fail()
offset += 1
parseValue()
skipWhitespace()
if (text[offset] === '}') {
offset += 1
return
}
if (text[offset] !== ',') fail()
offset += 1
skipWhitespace()
}
fail()
}
function parseArray(): void {
offset += 1
skipWhitespace()
if (text[offset] === ']') {
offset += 1
return
}
while (offset < text.length) {
parseValue()
skipWhitespace()
if (text[offset] === ']') {
offset += 1
return
}
if (text[offset] !== ',') fail()
offset += 1
}
fail()
}
try {
parseValue()
skipWhitespace()
if (offset !== text.length) fail()
return JSON.parse(text) as unknown
} catch (error) {
if (error instanceof CompositionDraftHttpError) throw error
validation(
'composition_json_invalid',
'Request body must contain valid JSON',
'/',
)
}
}
async function readBoundedBody(request: Request): Promise<Uint8Array> {
const declaredLength = request.headers.get('content-length')
if (
declaredLength !== null &&
(!/^\d+$/u.test(declaredLength) ||
Number(declaredLength) > maximumDraftBytes)
) {
validation(
'composition_request_too_large',
`Request body must not exceed ${maximumDraftBytes} bytes`,
'/',
413,
)
}
if (!request.body) {
validation(
'composition_request_body_required',
'Request body is required',
'/',
)
}
const reader = request.body!.getReader()
const chunks: Uint8Array[] = []
let length = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
length += value.length
if (length > maximumDraftBytes) {
await reader.cancel()
validation(
'composition_request_too_large',
`Request body must not exceed ${maximumDraftBytes} bytes`,
'/',
413,
)
}
chunks.push(value)
}
const body = new Uint8Array(length)
let bodyOffset = 0
for (const chunk of chunks) {
body.set(chunk, bodyOffset)
bodyOffset += chunk.length
}
return body
}
async function parseJsonBody(request: Request): Promise<unknown> {
const contentType = request.headers.get('content-type')
const [mediaType, ...parameters] =
contentType?.split(';').map((part) => part.trim().toLowerCase()) ?? []
if (
mediaType !== 'application/json' ||
parameters.some((parameter) => parameter !== 'charset=utf-8')
) {
validation(
'composition_content_type_unsupported',
'Composition drafts require application/json',
'headers.content-type',
)
}
let text: string
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(
await readBoundedBody(request),
)
} catch (error) {
if (error instanceof CompositionDraftHttpError) throw error
validation(
'composition_request_utf8_invalid',
'Request body must be valid UTF-8',
'/',
)
}
return parseStrictJson(text)
}
function requiredString(
value: unknown,
path: string,
allowed?: ReadonlySet<string>,
): string {
if (typeof value !== 'string' || value.length === 0) {
validation('composition_request_invalid', 'Value must be a string', path)
}
if (allowed && !allowed.has(value)) {
validation(
'composition_request_invalid',
'Value is not one of the governed options',
path,
)
}
return value
}
function repositoryRevision(value: unknown): string | null {
if (value === null) return null
if (typeof value !== 'string' || !uuidPattern.test(value)) {
validation(
'composition_request_invalid',
'Repository profile revision must be a UUID or null',
'/repositoryProfileRevisionId',
)
}
return value
}
function renderDigest(value: unknown): string | null {
if (value === null) return null
if (typeof value !== 'string' || !/^[a-f0-9]{64}$/u.test(value)) {
validation(
'composition_request_invalid',
'lastRenderDigest must be a lowercase SHA-256 digest or null',
'/lastRenderDigest',
)
}
return value
}
function containsUnsafeControl(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0)!
return (
codePoint <= 0x1f ||
codePoint === 0x7f ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2066 && codePoint <= 0x2069)
)
})
}
function validateScope(value: unknown): Record<string, unknown> {
if (!plainObject(value)) {
validation(
'composition_request_invalid',
'scopeOverrides must be an object',
'/scopeOverrides',
)
}
assertExactKeys(value, scopeKeys, '/scopeOverrides')
for (const key of ['includedPaths', 'excludedPaths'] as const) {
const candidate = value[key]
if (
candidate !== undefined &&
(!Array.isArray(candidate) ||
candidate.length > 100 ||
new Set(candidate).size !== candidate.length ||
candidate.some(
(item) =>
typeof item !== 'string' ||
item.length === 0 ||
item.length > 500 ||
item.startsWith('/') ||
item.includes('\\') ||
item.split('/').includes('..') ||
containsUnsafeControl(item),
))
) {
validation(
'composition_request_invalid',
`${key} must contain unique normalized relative paths`,
`/scopeOverrides/${key}`,
)
}
}
const changeTypes = value.allowableChangeTypes
if (
changeTypes !== undefined &&
(!Array.isArray(changeTypes) ||
changeTypes.length > 30 ||
new Set(changeTypes).size !== changeTypes.length ||
changeTypes.some(
(item) =>
typeof item !== 'string' ||
item.length > 80 ||
!/^[a-z][a-z0-9-]*$/u.test(item),
))
) {
validation(
'composition_request_invalid',
'allowableChangeTypes must contain unique governed identifiers',
'/scopeOverrides/allowableChangeTypes',
)
}
if (
value.repositoryWideRead !== undefined &&
typeof value.repositoryWideRead !== 'boolean'
) {
validation(
'composition_request_invalid',
'repositoryWideRead must be a boolean',
'/scopeOverrides/repositoryWideRead',
)
}
return value
}
export async function parseCompositionRequestBody(request: Request): Promise<{
readonly playbook: CompositionPlaybookReference
readonly draft: Required<
Pick<
CompositionDraftWrite,
'inputs' | 'autonomyLevel' | 'workMode' | 'outputFormat'
>
> &
CompositionDraftWrite
}> {
const value = await parseJsonBody(request)
if (!plainObject(value)) {
validation(
'composition_request_invalid',
'Request body must be an object',
'/',
)
}
assertExactKeys(value, createKeys, '/')
if (!plainObject(value.playbook)) {
validation(
'composition_request_invalid',
'playbook must be an object',
'/playbook',
)
}
assertExactKeys(value.playbook, new Set(['slug', 'version']), '/playbook')
const slug = requiredString(value.playbook.slug, '/playbook/slug')
const version = requiredString(value.playbook.version, '/playbook/version')
if (slug.length > 120 || !slugPattern.test(slug)) {
validation(
'composition_request_invalid',
'playbook.slug must be a governed slug',
'/playbook/slug',
)
}
if (version.length > 120 || !semverPattern.test(version)) {
validation(
'composition_request_invalid',
'playbook.version must be Semantic Versioning',
'/playbook/version',
)
}
if (!plainObject(value.inputs)) {
validation(
'composition_request_invalid',
'inputs must be an object',
'/inputs',
)
}
return {
playbook: { slug, version },
draft: {
...(value.repositoryProfileRevisionId === undefined
? {}
: {
repositoryProfileRevisionId: repositoryRevision(
value.repositoryProfileRevisionId,
),
}),
inputs: value.inputs,
scopeOverrides: validateScope(value.scopeOverrides ?? {}),
workMode: requiredString(value.workMode, '/workMode', workModes),
autonomyLevel: requiredString(
value.autonomyLevel,
'/autonomyLevel',
autonomyLevels,
),
outputFormat:
value.outputFormat === undefined
? 'prompt'
: requiredString(value.outputFormat, '/outputFormat', outputFormats),
},
}
}
async function parsePatchRequest(
request: Request,
): Promise<CompositionDraftWrite> {
const value = await parseJsonBody(request)
if (!plainObject(value) || Object.keys(value).length === 0) {
validation(
'composition_request_invalid',
'Patch body must be a non-empty object',
'/',
)
}
assertExactKeys(value, patchKeys, '/')
return {
...('repositoryProfileRevisionId' in value
? {
repositoryProfileRevisionId: repositoryRevision(
value.repositoryProfileRevisionId,
),
}
: {}),
...('inputs' in value
? plainObject(value.inputs)
? { inputs: value.inputs }
: validation(
'composition_request_invalid',
'inputs must be an object',
'/inputs',
)
: {}),
...('scopeOverrides' in value
? { scopeOverrides: validateScope(value.scopeOverrides) }
: {}),
...('workMode' in value
? { workMode: requiredString(value.workMode, '/workMode', workModes) }
: {}),
...('autonomyLevel' in value
? {
autonomyLevel: requiredString(
value.autonomyLevel,
'/autonomyLevel',
autonomyLevels,
),
}
: {}),
...('outputFormat' in value
? {
outputFormat: requiredString(
value.outputFormat,
'/outputFormat',
outputFormats,
),
}
: {}),
...('lastRenderDigest' in value
? { lastRenderDigest: renderDigest(value.lastRenderDigest) }
: {}),
}
}
function assertDraftId(draftId: string): void {
if (!uuidPattern.test(draftId)) {
throw new CompositionDraftHttpError(
404,
'composition_draft_not_found',
'Composition draft not found',
)
}
}
function assertDraftEtag(etag: string): void {
if (!/^"draft:[1-9]\d*"$/u.test(etag)) {
validation(
'composition_draft_etag_invalid',
'If-Match must contain a strong composition draft ETag',
'headers.if-match',
)
}
}
function draftEnvelope(
result: CompositionDraftHttpResult,
): Readonly<Record<string, unknown>> {
const { draft } = result.result
return {
id: draft.id,
revision: draft.revision,
request: {
playbook: result.playbook,
repositoryProfileRevisionId: draft.repositoryProfileRevisionId,
workMode: draft.workMode,
autonomyLevel: draft.autonomyLevel,
inputs: draft.inputs,
scopeOverrides: draft.scopeOverrides,
outputFormat: draft.outputFormat,
},
lastRenderDigest: draft.lastRenderDigest,
createdAt: draft.createdAt,
updatedAt: draft.updatedAt,
}
}
function applicationError(caught: unknown): {
readonly code: string
readonly details: Readonly<Record<string, unknown>>
} | null {
if (caught === null || typeof caught !== 'object') return null
const candidate = caught as Record<string, unknown>
if (typeof candidate.code !== 'string') return null
return {
code: candidate.code,
details: plainObject(candidate.details) ? candidate.details : {},
}
}
function issueDetails(value: unknown): SafeDetail[] | undefined {
if (!Array.isArray(value)) return undefined
const details = value.flatMap((issue): SafeDetail[] =>
typeof issue === 'string'
? [
{
path: '/',
rule: 'composition_draft_invalid',
message: issue.slice(0, 500),
},
]
: [],
)
return details.length ? details : undefined
}
function mappedError(caught: unknown, requestId: string): Response {
if (caught instanceof CompositionDraftHttpError) {
return errorResponse(
caught.status,
caught.code,
caught.message,
requestId,
caught.details,
caught.headers,
)
}
if (caught instanceof AuthenticatedWorkspaceContextError) {
return errorResponse(
caught.code === 'authentication_required' ? 401 : 403,
caught.code,
caught.code === 'authentication_required'
? 'Authentication required'
: 'Access denied',
requestId,
)
}
const application = applicationError(caught)
if (application?.code === 'authentication_required') {
return errorResponse(
401,
application.code,
'Authentication required',
requestId,
)
}
if (application?.code === 'workspace_access_denied') {
return errorResponse(403, application.code, 'Access denied', requestId)
}
if (application?.code === 'composition_draft_not_found') {
return errorResponse(
404,
application.code,
'Composition draft not found',
requestId,
)
}
if (application?.code === 'composition_draft_conflict') {
const currentEtag =
typeof application.details.currentEtag === 'string' &&
/^"draft:[1-9]\d*"$/u.test(application.details.currentEtag)
? application.details.currentEtag
: undefined
const currentRevision =
typeof application.details.currentRevision === 'number'
? application.details.currentRevision
: undefined
const recovery =
application.details.recovery === 'reload-and-review'
? application.details.recovery
: undefined
return errorResponse(
409,
application.code,
'Composition draft changed; reload and review before saving',
requestId,
[
{
path: 'headers.if-match',
rule: 'etag-conflict',
message: 'The supplied draft ETag is stale',
...(currentEtag ? { currentEtag } : {}),
...(currentRevision ? { currentRevision } : {}),
...(recovery ? { recovery } : {}),
},
],
currentEtag ? { ETag: currentEtag } : {},
)
}
if (
application?.code === 'composition_draft_invalid' ||
application?.code === 'composition_draft_etag_invalid'
) {
return errorResponse(
422,
application.code,
'Composition draft is invalid',
requestId,
issueDetails(application.details.issues),
)
}
return errorResponse(
503,
'composition_draft_service_unavailable',
'Composition draft service is temporarily unavailable',
requestId,
)
}
function mutationBoundary(
request: Request,
dependencies: CompositionDraftRouteDependencies,
requestId: string,
operation: (request: Request) => Promise<Response>,
): Promise<Response> {
return handleAuthRequest(
request,
async (sameOriginRequest) => {
if (
sameOriginRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return errorResponse(
403,
'invalid_origin',
'Invalid request origin',
requestId,
)
}
return operation(sameOriginRequest)
},
dependencies.publicBaseUrl,
() =>
errorResponse(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
export function handleCreateCompositionDraft(
request: Request,
dependencies: CompositionDraftRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
return mutationBoundary(
request,
dependencies,
requestId,
async (safeRequest) => {
try {
const actor = await dependencies.resolveContext(safeRequest)
const parsed = await parseCompositionRequestBody(safeRequest)
const created = await dependencies.service.create(
actor,
parsed.playbook,
parsed.draft,
)
return Response.json(draftEnvelope(created), {
status: 201,
headers: {
ETag: created.result.etag,
'Cache-Control': 'no-store',
},
})
} catch (caught) {
return mappedError(caught, requestId)
}
},
)
}
export async function handleGetCompositionDraft(
request: Request,
draftId: string,
dependencies: CompositionDraftRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
try {
const actor = await dependencies.resolveContext(request)
assertDraftId(draftId)
const result = await dependencies.service.get(actor, draftId)
return Response.json(draftEnvelope(result), {
headers: { ETag: result.result.etag, 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
}
export function handlePatchCompositionDraft(
request: Request,
draftId: string,
dependencies: CompositionDraftRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
return mutationBoundary(
request,
dependencies,
requestId,
async (safeRequest) => {
try {
const actor = await dependencies.resolveContext(safeRequest)
assertDraftId(draftId)
const expectedEtag = safeRequest.headers.get('if-match')
if (expectedEtag === null) {
throw new CompositionDraftHttpError(
428,
'composition_draft_precondition_required',
'If-Match is required',
)
}
assertDraftEtag(expectedEtag)
const patch = await parsePatchRequest(safeRequest)
const result = await dependencies.service.patch(
actor,
draftId,
expectedEtag,
patch,
)
return Response.json(draftEnvelope(result), {
headers: { ETag: result.result.etag, 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
},
)
}
@@ -0,0 +1,14 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../../server/authenticated-workspace-context'
import { getCompositionDraftServer } from '../../../../../server/composition-drafts'
import type { CompositionDraftRouteDependencies } from './composition-draft-http'
export function compositionDraftRouteDependencies(): CompositionDraftRouteDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
service: getCompositionDraftServer(),
}
}
@@ -0,0 +1,11 @@
import { handleCreateCompositionDraft } from './composition-draft-http'
import { compositionDraftRouteDependencies } from './composition-draft-route-dependencies'
export const dynamic = 'force-dynamic'
export function POST(request: Request) {
return handleCreateCompositionDraft(
request,
compositionDraftRouteDependencies(),
)
}
@@ -0,0 +1,11 @@
import { handlePreviewComposition } from '../composition-http'
import { authoritativeCompositionRouteDependencies } from '../composition-route-dependencies'
export const dynamic = 'force-dynamic'
export function POST(request: Request) {
return handlePreviewComposition(
request,
authoritativeCompositionRouteDependencies(),
)
}
@@ -0,0 +1,166 @@
import type { ActorContext } from '@devrunbook/application'
import { describe, expect, it, vi } from 'vitest'
import { AuthenticatedWorkspaceContextError } from '../../../../../server/authenticated-workspace-context'
import {
handleFavoriteMutation,
type FavoriteRouteDependencies,
} from './favorite-route'
const origin = 'https://runbook.example.test'
const playbookId = '00000000-0000-4000-8000-000000000003'
const actor: ActorContext = {
userId: '00000000-0000-4000-8000-000000000001',
workspaceId: '00000000-0000-4000-8000-000000000002',
instanceRole: 'user',
workspaceRole: 'viewer',
}
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu
async function expectError(response: Response, status: number, code: string) {
expect(response.status).toBe(status)
expect(response.headers.get('content-type')).toContain('application/json')
expect(await response.json()).toEqual({
error: {
code,
message: expect.any(String),
requestId: expect.stringMatching(uuid),
},
})
}
function request(
method: 'PUT' | 'DELETE',
requestOrigin: string | null = origin,
) {
const headers = new Headers()
if (requestOrigin) headers.set('origin', requestOrigin)
return new Request(`${origin}/api/v1/favorites/${playbookId}`, {
method,
headers,
})
}
function dependencies(
overrides: Partial<FavoriteRouteDependencies> = {},
): FavoriteRouteDependencies {
return {
publicBaseUrl: origin,
resolveContext: vi.fn(async () => actor),
mutate: vi.fn(async () => undefined),
...overrides,
}
}
describe('favorite mutation route', () => {
it.each([
['PUT', 'add'],
['DELETE', 'remove'],
] as const)(
'maps %s to an idempotent %s mutation',
async (method, mutation) => {
const context = dependencies()
const response = await handleFavoriteMutation(
request(method),
playbookId,
mutation,
context,
)
expect(response.status).toBe(204)
expect(await response.text()).toBe('')
expect(context.mutate).toHaveBeenCalledWith({
actor,
playbookId,
mutation,
})
},
)
it.each([[null], ['https://attacker.example.test']])(
'rejects a missing or foreign mutation origin: %s',
async (requestOrigin) => {
const context = dependencies()
const response = await handleFavoriteMutation(
request('PUT', requestOrigin),
playbookId,
'add',
context,
)
await expectError(response, 403, 'invalid_origin')
expect(context.resolveContext).not.toHaveBeenCalled()
},
)
it.each([
['authentication_required', 401],
['workspace_access_denied', 403],
] as const)('maps %s to a safe %s response', async (code, status) => {
const context = dependencies({
resolveContext: vi.fn(async () => {
throw new AuthenticatedWorkspaceContextError(code)
}),
})
const response = await handleFavoriteMutation(
request('PUT'),
playbookId,
'add',
context,
)
const body = await response.clone().text()
await expectError(response, status, code)
expect(body).not.toContain(actor.workspaceId)
})
it('conflates invalid, inaccessible, and missing playbook IDs as not found', async () => {
const inaccessible = dependencies({
mutate: vi.fn(async () => {
throw { code: 'playbook_not_found', detail: 'private workspace id' }
}),
})
const invalidResponse = await handleFavoriteMutation(
request('PUT'),
'not-a-uuid',
'add',
dependencies(),
)
const inaccessibleResponse = await handleFavoriteMutation(
request('PUT'),
playbookId,
'add',
inaccessible,
)
await expectError(invalidResponse, 404, 'playbook_not_found')
await expectError(inaccessibleResponse, 404, 'playbook_not_found')
})
it('redacts unexpected persistence failures', async () => {
const context = dependencies({
mutate: vi.fn(async () => {
throw new Error('postgresql://operator:secret@database/internal')
}),
})
const response = await handleFavoriteMutation(
request('DELETE'),
playbookId,
'remove',
context,
)
const responseBody = await response.json()
const body = JSON.stringify(responseBody)
expect(response.status).toBe(503)
expect(responseBody).toEqual({
error: {
code: 'favorite_service_unavailable',
message: 'Favorite service unavailable',
requestId: expect.stringMatching(uuid),
},
})
expect(body).not.toContain('secret')
expect(body).not.toContain('postgresql')
})
})
@@ -0,0 +1,103 @@
import type {
ActorContext,
PlaybookFavoriteMutation,
} from '@devrunbook/application'
import { handleAuthRequest } from '../../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../../server/authenticated-workspace-context'
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu
export interface FavoriteRouteDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly mutate: (input: {
readonly actor: Pick<ActorContext, 'userId' | 'workspaceId'>
readonly playbookId: string
readonly mutation: PlaybookFavoriteMutation
}) => Promise<void>
}
function error(
status: number,
code: string,
message: string,
requestId: string,
) {
return Response.json({ error: { code, message, requestId } }, { status })
}
function errorCode(value: unknown): unknown {
return value !== null && typeof value === 'object' && 'code' in value
? value.code
: undefined
}
export function handleFavoriteMutation(
request: Request,
playbookId: string,
mutation: PlaybookFavoriteMutation,
dependencies: FavoriteRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
if (
sameOriginRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return error(403, 'invalid_origin', 'Invalid request origin', requestId)
}
if (!uuidPattern.test(playbookId)) {
return error(404, 'playbook_not_found', 'Playbook not found', requestId)
}
try {
const actor = await dependencies.resolveContext(sameOriginRequest)
await dependencies.mutate({ actor, playbookId, mutation })
return new Response(null, { status: 204 })
} catch (caught) {
const code = errorCode(caught)
if (
caught instanceof AuthenticatedWorkspaceContextError &&
code === 'authentication_required'
) {
return error(
401,
'authentication_required',
'Authentication required',
requestId,
)
}
if (
caught instanceof AuthenticatedWorkspaceContextError &&
code === 'workspace_access_denied'
) {
return error(
403,
'workspace_access_denied',
'Access denied',
requestId,
)
}
if (code === 'playbook_not_found') {
return error(
404,
'playbook_not_found',
'Playbook not found',
requestId,
)
}
return error(
503,
'favorite_service_unavailable',
'Favorite service unavailable',
requestId,
)
}
},
dependencies.publicBaseUrl,
() => error(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
@@ -0,0 +1,30 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../../server/authenticated-workspace-context'
import { persistPlaybookFavorite } from '../../../../../server/playbook-favorites'
import {
handleFavoriteMutation,
type FavoriteRouteDependencies,
} from './favorite-route'
export const dynamic = 'force-dynamic'
function dependencies(): FavoriteRouteDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
mutate: persistPlaybookFavorite,
}
}
type RouteContext = { params: Promise<{ playbookId: string }> }
export async function PUT(request: Request, context: RouteContext) {
const { playbookId } = await context.params
return handleFavoriteMutation(request, playbookId, 'add', dependencies())
}
export async function DELETE(request: Request, context: RouteContext) {
const { playbookId } = await context.params
return handleFavoriteMutation(request, playbookId, 'remove', dependencies())
}
@@ -0,0 +1,128 @@
import { assertJsonValue } from '@devrunbook/content'
import { z } from 'zod'
import { completeInstanceSetup } from '@/server/instance-service'
import { readSetupJson, SetupRequestTooLargeError } from '@/setup/setup-request'
import {
assertNonSecretConfiguration,
authorizeBootstrap,
} from '../../../../../setup/setup-policy'
export const dynamic = 'force-dynamic'
const setupRequest = z.strictObject({
bootstrapToken: z.string(),
instanceName: z.string().trim().min(1).max(100),
publicBaseUrl: z.url(),
owner: z.strictObject({
email: z.email(),
displayName: z.string().trim().min(1).max(100),
password: z.string().min(12).max(128),
}),
configuration: z.record(z.string(), z.unknown()).optional().default({}),
})
function errorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object' || !('code' in error))
return undefined
return typeof error.code === 'string' ? error.code : undefined
}
export async function POST(request: Request) {
let parsed: z.infer<typeof setupRequest>
try {
parsed = setupRequest.parse(await readSetupJson(request))
assertNonSecretConfiguration(parsed.configuration)
assertJsonValue(parsed.configuration, 'configuration')
} catch (error) {
if (error instanceof SetupRequestTooLargeError) {
return Response.json(
{
error: {
code: 'request_too_large',
message: error.message,
},
},
{ status: 413 },
)
}
return Response.json(
{
error: {
code: 'validation_failed',
message: 'Setup request is invalid',
details: error instanceof Error ? [error.message] : [],
},
},
{ status: 422 },
)
}
if (
!authorizeBootstrap(
request,
parsed.bootstrapToken,
process.env.BOOTSTRAP_TOKEN,
)
) {
return Response.json(
{
error: {
code: 'bootstrap_denied',
message: 'Bootstrap authorization failed',
},
},
{ status: 403 },
)
}
try {
await completeInstanceSetup({
instanceName: parsed.instanceName,
publicBaseUrl: parsed.publicBaseUrl,
owner: {
email: parsed.owner.email.trim().toLowerCase(),
displayName: parsed.owner.displayName,
password: parsed.owner.password,
},
configuration: parsed.configuration,
})
return Response.json(
{
state: 'ready',
setupRequired: false,
schemaVersion: '0001',
applicationVersion: '0.1.0',
warnings: [],
},
{ status: 201 },
)
} catch (error) {
const code = errorCode(error)
if (code === 'setup_already_complete' || code === 'setup_in_progress') {
return Response.json(
{ error: { code, message: 'Setup cannot be completed in this state' } },
{ status: 409 },
)
}
if (
code === 'catalog_import_incomplete' ||
code === 'playbook_identity_conflict' ||
code === 'playbook_version_conflict'
) {
return Response.json(
{ error: { code, message: 'Built-in catalog validation failed' } },
{ status: 422 },
)
}
return Response.json(
{
error: {
code: 'setup_failed',
message: 'Setup could not be completed',
},
},
{ status: 500 },
)
}
}
@@ -0,0 +1,24 @@
import { readInstanceStatus } from '@/server/instance-service'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
const status = await readInstanceStatus(
process.env.MAINTENANCE_MODE === 'true',
)
return Response.json({
...status,
applicationVersion: '0.1.0',
warnings: [],
})
} catch {
return Response.json({
state: 'recovery_required',
setupRequired: true,
schemaVersion: 'unknown',
applicationVersion: '0.1.0',
warnings: ['Database state is unavailable'],
})
}
}
@@ -0,0 +1,15 @@
import { handleImportGiteaRepository } from '../../../integration-http'
import { giteaIntegrationRouteDependencies } from '../../../integration-route-dependencies'
export const dynamic = 'force-dynamic'
type RouteContext = { params: Promise<{ integrationId: string }> }
export async function POST(request: Request, context: RouteContext) {
const { integrationId } = await context.params
return handleImportGiteaRepository(
request,
integrationId,
giteaIntegrationRouteDependencies(),
)
}
@@ -0,0 +1,15 @@
import { handleDiscoverGiteaRepositories } from '../../integration-http'
import { giteaIntegrationRouteDependencies } from '../../integration-route-dependencies'
export const dynamic = 'force-dynamic'
type RouteContext = { params: Promise<{ integrationId: string }> }
export async function GET(request: Request, context: RouteContext) {
const { integrationId } = await context.params
return handleDiscoverGiteaRepositories(
request,
integrationId,
giteaIntegrationRouteDependencies(),
)
}
@@ -0,0 +1,15 @@
import { handleRotateGiteaSecret } from '../../integration-http'
import { giteaIntegrationRouteDependencies } from '../../integration-route-dependencies'
export const dynamic = 'force-dynamic'
type RouteContext = { params: Promise<{ integrationId: string }> }
export async function POST(request: Request, context: RouteContext) {
const { integrationId } = await context.params
return handleRotateGiteaSecret(
request,
integrationId,
giteaIntegrationRouteDependencies(),
)
}
@@ -0,0 +1,27 @@
import {
handleDeleteGiteaIntegration,
handleGetGiteaIntegration,
} from '../integration-http'
import { giteaIntegrationRouteDependencies } from '../integration-route-dependencies'
export const dynamic = 'force-dynamic'
type RouteContext = { params: Promise<{ integrationId: string }> }
export async function GET(request: Request, context: RouteContext) {
const { integrationId } = await context.params
return handleGetGiteaIntegration(
request,
integrationId,
giteaIntegrationRouteDependencies(),
)
}
export async function DELETE(request: Request, context: RouteContext) {
const { integrationId } = await context.params
return handleDeleteGiteaIntegration(
request,
integrationId,
giteaIntegrationRouteDependencies(),
)
}
@@ -0,0 +1,15 @@
import { handleTestGiteaIntegration } from '../../integration-http'
import { giteaIntegrationRouteDependencies } from '../../integration-route-dependencies'
export const dynamic = 'force-dynamic'
type RouteContext = { params: Promise<{ integrationId: string }> }
export async function POST(request: Request, context: RouteContext) {
const { integrationId } = await context.params
return handleTestGiteaIntegration(
request,
integrationId,
giteaIntegrationRouteDependencies(),
)
}
@@ -0,0 +1,312 @@
import { describe, expect, it, vi } from 'vitest'
import type {
ActorContext,
SafeGiteaIntegration,
} from '@devrunbook/application'
import {
handleCreateGiteaIntegration,
handleDeleteGiteaIntegration,
handleDiscoverGiteaRepositories,
handleGetGiteaIntegration,
handleImportGiteaRepository,
handleListGiteaIntegrations,
handleRotateGiteaSecret,
handleTestGiteaIntegration,
type GiteaIntegrationRouteDependencies,
} from './integration-http'
const integrationId = '00000000-0000-4000-8000-000000000001'
const actor: ActorContext = {
userId: '00000000-0000-4000-8000-000000000002',
instanceRole: 'user',
workspaceId: '00000000-0000-4000-8000-000000000003',
workspaceRole: 'owner',
}
function integration(): SafeGiteaIntegration {
return {
id: integrationId,
workspaceId: actor.workspaceId,
displayName: 'Primary Gitea',
baseUrl: 'https://git.example.test',
status: 'healthy',
capabilities: { 'repository-list': 'supported' },
serverVersion: '1.24.7',
remoteIdentity: { id: '4', login: 'owner' },
healthCode: null,
lastCheckedAt: '2026-07-27T10:00:00.000Z',
secretLastFour: 'cdef',
createdAt: '2026-07-27T10:00:00.000Z',
updatedAt: '2026-07-27T10:00:00.000Z',
}
}
function dependencies(): GiteaIntegrationRouteDependencies {
return {
publicBaseUrl: 'https://runbooks.example.test',
resolveContext: vi.fn(async () => actor),
service: {
list: vi.fn(async () => [integration()]),
get: vi.fn(async () => integration()),
create: vi.fn(async () => integration()),
test: vi.fn(async () => ({
normalizedBaseUrl: 'https://git.example.test',
status: 'healthy' as const,
serverVersion: '1.24.7',
remoteIdentity: { id: '4', login: 'owner' },
capabilities: { 'repository-list': 'supported' as const },
healthCode: null,
warnings: [],
})),
discover: vi.fn(async () => ({
items: [
{
externalId: '42',
owner: 'team',
name: 'service',
defaultBranch: 'main',
archived: false,
private: true,
permissions: { pull: true, push: false, admin: false },
},
],
nextCursor: null,
})),
importRepository: vi.fn(async () => ({
repositoryId: '00000000-0000-4000-8000-000000000010',
snapshotId: '00000000-0000-4000-8000-000000000011',
jobId: '00000000-0000-4000-8000-000000000012',
})),
rotate: vi.fn(async () => integration()),
delete: vi.fn(async () => undefined),
},
}
}
function mutationRequest(
path: string,
body?: unknown,
headers?: Readonly<Record<string, string>>,
) {
return new Request(`https://runbooks.example.test${path}`, {
method: 'POST',
headers: {
origin: 'https://runbooks.example.test',
'content-type': 'application/json',
...headers,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
}
describe('Gitea HTTP boundary', () => {
it('returns only safe integration projections for list and detail', async () => {
const deps = dependencies()
const unsafe = {
...integration(),
token: 'must-not-leak',
secret: { ciphertext: 'must-not-leak' },
}
vi.mocked(deps.service.list).mockResolvedValue([unsafe])
vi.mocked(deps.service.get).mockResolvedValue(unsafe)
const list = await handleListGiteaIntegrations(
new Request('https://runbooks.example.test/api/v1/integrations/gitea'),
deps,
)
const detail = await handleGetGiteaIntegration(
new Request(
`https://runbooks.example.test/api/v1/integrations/gitea/${integrationId}`,
),
integrationId,
deps,
)
expect(await list.text()).not.toMatch(/must-not-leak|ciphertext|token/u)
expect(await detail.text()).not.toMatch(/must-not-leak|ciphertext|token/u)
expect(list.headers.get('cache-control')).toBe('no-store')
})
it('accepts an exact same-origin create body but never reflects the token', async () => {
const deps = dependencies()
const response = await handleCreateGiteaIntegration(
mutationRequest('/api/v1/integrations/gitea', {
displayName: 'Primary Gitea',
baseUrl: 'https://git.example.test',
token: 'top-secret-token',
requestTimeoutMs: 12_000,
}),
deps,
)
expect(response.status).toBe(201)
expect(await response.text()).not.toContain('top-secret-token')
expect(deps.service.create).toHaveBeenCalledWith(
actor,
expect.objectContaining({ token: 'top-secret-token' }),
)
})
it('rejects cross-origin, unsupported fields and oversized bodies safely', async () => {
const deps = dependencies()
const crossOrigin = await handleCreateGiteaIntegration(
mutationRequest(
'/api/v1/integrations/gitea',
{ displayName: 'x', baseUrl: 'https://git.test', token: 'secret' },
{ origin: 'https://attacker.test' },
),
deps,
)
expect(crossOrigin.status).toBe(403)
const unsupported = await handleCreateGiteaIntegration(
mutationRequest('/api/v1/integrations/gitea', {
displayName: 'x',
baseUrl: 'https://git.test',
token: 'secret',
admin: true,
}),
deps,
)
expect(unsupported.status).toBe(422)
expect(await unsupported.text()).not.toContain('secret')
const oversized = await handleCreateGiteaIntegration(
mutationRequest(
'/api/v1/integrations/gitea',
{ displayName: 'x', baseUrl: 'https://git.test', token: 'secret' },
{ 'content-length': '20000' },
),
deps,
)
expect(oversized.status).toBe(413)
})
it('projects connection test state and validates discovery query bounds', async () => {
const deps = dependencies()
const tested = await handleTestGiteaIntegration(
mutationRequest(`/api/v1/integrations/gitea/${integrationId}/test`),
integrationId,
deps,
)
expect(tested.status).toBe(200)
expect(await tested.json()).toMatchObject({
status: 'healthy',
capabilities: { 'repository-list': 'supported' },
})
const discovered = await handleDiscoverGiteaRepositories(
new Request(
`https://runbooks.example.test/api/v1/integrations/gitea/${integrationId}/repositories?limit=50`,
),
integrationId,
deps,
)
expect(discovered.status).toBe(200)
expect(await discovered.json()).toMatchObject({
items: [{ externalId: '42', permissions: { pull: true } }],
})
const invalid = await handleDiscoverGiteaRepositories(
new Request(
`https://runbooks.example.test/api/v1/integrations/gitea/${integrationId}/repositories?limit=101`,
),
integrationId,
deps,
)
expect(invalid.status).toBe(422)
})
it('rotates write-only credentials and protects deletion with same-origin handling', async () => {
const deps = dependencies()
const rotated = await handleRotateGiteaSecret(
mutationRequest(
`/api/v1/integrations/gitea/${integrationId}/rotate-secret`,
{ token: 'replacement-secret' },
),
integrationId,
deps,
)
expect(rotated.status).toBe(200)
expect(await rotated.text()).not.toContain('replacement-secret')
const deleted = await handleDeleteGiteaIntegration(
new Request(
`https://runbooks.example.test/api/v1/integrations/gitea/${integrationId}`,
{
method: 'DELETE',
headers: { origin: 'https://runbooks.example.test' },
},
),
integrationId,
deps,
)
expect(deleted.status).toBe(204)
})
it('imports an exact opaque repository identity with a safe accepted response', async () => {
const deps = dependencies()
vi.mocked(deps.service.importRepository!).mockResolvedValue({
repositoryId: '00000000-0000-4000-8000-000000000010',
snapshotId: '00000000-0000-4000-8000-000000000011',
jobId: '00000000-0000-4000-8000-000000000012',
token: 'must-not-leak',
} as Awaited<ReturnType<NonNullable<typeof deps.service.importRepository>>>)
const imported = await handleImportGiteaRepository(
mutationRequest(
`/api/v1/integrations/gitea/${integrationId}/repositories/import`,
{ externalId: '42' },
),
integrationId,
deps,
)
expect(imported.status).toBe(202)
expect(imported.headers.get('cache-control')).toBe('no-store')
const responseBody = await imported.json()
expect(responseBody).toEqual({
repositoryId: '00000000-0000-4000-8000-000000000010',
snapshotId: '00000000-0000-4000-8000-000000000011',
jobId: '00000000-0000-4000-8000-000000000012',
})
expect(JSON.stringify(responseBody)).not.toContain('must-not-leak')
expect(deps.service.importRepository).toHaveBeenCalledWith(
actor,
integrationId,
'42',
)
})
it('rejects unsafe repository import requests before invoking the service', async () => {
const deps = dependencies()
const crossOrigin = await handleImportGiteaRepository(
mutationRequest(
`/api/v1/integrations/gitea/${integrationId}/repositories/import`,
{ externalId: '42' },
{ origin: 'https://attacker.test' },
),
integrationId,
deps,
)
const unsupported = await handleImportGiteaRepository(
mutationRequest(
`/api/v1/integrations/gitea/${integrationId}/repositories/import`,
{ externalId: '42', owner: 'team' },
),
integrationId,
deps,
)
const oversized = await handleImportGiteaRepository(
mutationRequest(
`/api/v1/integrations/gitea/${integrationId}/repositories/import`,
{ externalId: '42' },
{ 'content-length': '20000' },
),
integrationId,
deps,
)
expect(crossOrigin.status).toBe(403)
expect(unsupported.status).toBe(422)
expect(oversized.status).toBe(413)
expect(deps.service.importRepository).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,632 @@
import type {
ActorContext,
ExternalGiteaRepositoryPage,
GiteaProbeResult,
SafeGiteaIntegration,
} from '@devrunbook/application'
import { handleAuthRequest } from '../../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../../server/authenticated-workspace-context'
const maximumJsonBytes = 16_384
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
export interface GiteaIntegrationHttpService {
list(actor: ActorContext): Promise<readonly SafeGiteaIntegration[]>
get(actor: ActorContext, integrationId: string): Promise<SafeGiteaIntegration>
create(
actor: ActorContext,
input: {
readonly displayName: string
readonly baseUrl: string
readonly token: string
readonly allowPrivateHttp?: boolean
readonly requestTimeoutMs?: number
},
): Promise<SafeGiteaIntegration>
test(actor: ActorContext, integrationId: string): Promise<GiteaProbeResult>
discover(
actor: ActorContext,
integrationId: string,
query: { readonly cursor: string | null; readonly limit: number },
): Promise<ExternalGiteaRepositoryPage>
importRepository?(
actor: ActorContext,
integrationId: string,
externalId: string,
): Promise<{
readonly repositoryId: string
readonly snapshotId: string
readonly jobId: string
}>
rotate(
actor: ActorContext,
integrationId: string,
token: string,
): Promise<SafeGiteaIntegration>
delete(actor: ActorContext, integrationId: string): Promise<void>
}
export interface GiteaIntegrationRouteDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly service: GiteaIntegrationHttpService
}
class GiteaHttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly path?: string,
) {
super(message)
}
}
function errorResponse(
status: number,
code: string,
message: string,
requestId: string,
path?: string,
) {
return Response.json(
{
error: {
code,
message,
requestId,
...(path
? {
details: [
{
path,
rule: code,
message,
},
],
}
: {}),
},
},
{ status, headers: { 'Cache-Control': 'no-store' } },
)
}
function invalid(code: string, message: string, path: string): never {
throw new GiteaHttpError(422, code, message, path)
}
function mediaType(request: Request): string {
return (
request.headers
.get('content-type')
?.split(';', 1)[0]!
.trim()
.toLowerCase() ?? ''
)
}
async function readJson(request: Request): Promise<Record<string, unknown>> {
if (mediaType(request) !== 'application/json') {
throw new GiteaHttpError(
415,
'content_type_unsupported',
'This endpoint requires application/json',
'headers.content-type',
)
}
const declared = request.headers.get('content-length')
if (
declared !== null &&
(!/^\d+$/u.test(declared) || Number(declared) > maximumJsonBytes)
) {
throw new GiteaHttpError(
413,
'request_too_large',
`Request body must not exceed ${maximumJsonBytes} bytes`,
'/',
)
}
if (!request.body)
invalid('request_body_required', 'Request body is required', '/')
const reader = request.body!.getReader()
const chunks: Uint8Array[] = []
let length = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
length += value.length
if (length > maximumJsonBytes) {
await reader.cancel()
throw new GiteaHttpError(
413,
'request_too_large',
`Request body must not exceed ${maximumJsonBytes} bytes`,
'/',
)
}
chunks.push(value)
}
const body = new Uint8Array(length)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.length
}
let parsed: unknown
try {
parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body))
} catch {
invalid(
'request_json_invalid',
'Request body must be valid UTF-8 JSON',
'/',
)
}
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed) ||
Object.getPrototypeOf(parsed) !== Object.prototype
) {
invalid('request_json_invalid', 'Request body must be a JSON object', '/')
}
return parsed as Record<string, unknown>
}
function exactKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
): void {
const allowed = new Set([...required, ...optional])
for (const key of Object.keys(value)) {
if (!allowed.has(key)) {
invalid(
'request_field_unsupported',
`Unsupported request field: ${key}`,
`/${key}`,
)
}
}
for (const key of required) {
if (!(key in value))
invalid('request_field_required', `${key} is required`, `/${key}`)
}
}
function createInput(value: Record<string, unknown>) {
exactKeys(
value,
['displayName', 'baseUrl', 'token'],
['allowPrivateHttp', 'requestTimeoutMs'],
)
if (typeof value.displayName !== 'string')
invalid(
'request_field_invalid',
'displayName must be a string',
'/displayName',
)
if (typeof value.baseUrl !== 'string')
invalid('request_field_invalid', 'baseUrl must be a string', '/baseUrl')
if (typeof value.token !== 'string')
invalid('request_field_invalid', 'token must be a string', '/token')
if (
value.allowPrivateHttp !== undefined &&
typeof value.allowPrivateHttp !== 'boolean'
) {
invalid(
'request_field_invalid',
'allowPrivateHttp must be a boolean',
'/allowPrivateHttp',
)
}
if (
value.requestTimeoutMs !== undefined &&
typeof value.requestTimeoutMs !== 'number'
) {
invalid(
'request_field_invalid',
'requestTimeoutMs must be a number',
'/requestTimeoutMs',
)
}
return {
displayName: value.displayName,
baseUrl: value.baseUrl,
token: value.token,
...(value.allowPrivateHttp === undefined
? {}
: { allowPrivateHttp: value.allowPrivateHttp }),
...(value.requestTimeoutMs === undefined
? {}
: { requestTimeoutMs: value.requestTimeoutMs }),
}
}
function rotateInput(value: Record<string, unknown>): string {
exactKeys(value, ['token'])
if (typeof value.token !== 'string')
invalid('request_field_invalid', 'token must be a string', '/token')
return value.token
}
function repositoryImportInput(value: Record<string, unknown>): string {
exactKeys(value, ['externalId'])
if (
typeof value.externalId !== 'string' ||
value.externalId.length < 1 ||
value.externalId.length > 255
) {
invalid(
'request_field_invalid',
'externalId must contain 1 to 255 characters',
'/externalId',
)
}
return value.externalId
}
function assertId(value: string): void {
if (!uuidPattern.test(value)) {
throw new GiteaHttpError(
404,
'gitea_integration_not_found',
'Gitea integration not found',
)
}
}
function safeIntegration(value: SafeGiteaIntegration) {
return {
id: value.id,
type: 'gitea' as const,
displayName: value.displayName,
baseUrl: value.baseUrl,
status: value.status,
capabilities: value.capabilities,
serverVersion: value.serverVersion,
remoteIdentity: value.remoteIdentity,
healthCode: value.healthCode,
lastCheckedAt: value.lastCheckedAt,
secretLastFour: value.secretLastFour,
}
}
function applicationError(
value: unknown,
): { code: string; message: string } | null {
if (value === null || typeof value !== 'object') return null
const item = value as Record<string, unknown>
return typeof item.code === 'string' && typeof item.message === 'string'
? { code: item.code, message: item.message }
: null
}
function mappedError(caught: unknown, requestId: string): Response {
if (caught instanceof GiteaHttpError) {
return errorResponse(
caught.status,
caught.code,
caught.message,
requestId,
caught.path,
)
}
if (caught instanceof AuthenticatedWorkspaceContextError) {
return errorResponse(
caught.code === 'authentication_required' ? 401 : 403,
caught.code,
caught.code === 'authentication_required'
? 'Authentication required'
: 'Access denied',
requestId,
)
}
const application = applicationError(caught)
if (application?.code === 'authentication_required')
return errorResponse(
401,
application.code,
'Authentication required',
requestId,
)
if (application?.code === 'workspace_access_denied')
return errorResponse(403, application.code, 'Access denied', requestId)
if (application?.code === 'gitea_integration_not_found')
return errorResponse(
404,
application.code,
'Gitea integration not found',
requestId,
)
if (application?.code === 'gitea_repository_not_found')
return errorResponse(
404,
application.code,
'Gitea repository not found',
requestId,
)
if (application?.code === 'gitea_integration_disabled')
return errorResponse(
409,
application.code,
'The Gitea integration is disabled',
requestId,
)
if (application?.code === 'gitea_integration_invalid')
return errorResponse(422, application.code, application.message, requestId)
if (application?.code === 'gitea_connection_failed')
return errorResponse(
422,
application.code,
'The Gitea connection could not be verified',
requestId,
)
return errorResponse(
503,
'gitea_service_unavailable',
'Gitea integration service is temporarily unavailable',
requestId,
)
}
async function actor(
request: Request,
dependencies: GiteaIntegrationRouteDependencies,
): Promise<ActorContext> {
return dependencies.resolveContext(request)
}
function mutation(
request: Request,
dependencies: GiteaIntegrationRouteDependencies,
requestId: string,
operation: (request: Request) => Promise<Response>,
) {
return handleAuthRequest(
request,
async (sameOriginRequest) => {
if (
sameOriginRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return errorResponse(
403,
'invalid_origin',
'Invalid request origin',
requestId,
)
}
return operation(sameOriginRequest)
},
dependencies.publicBaseUrl,
() =>
errorResponse(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
export async function handleListGiteaIntegrations(
request: Request,
dependencies: GiteaIntegrationRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
const result = await dependencies.service.list(
await actor(request, dependencies),
)
return Response.json(result.map(safeIntegration), {
headers: { 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
}
export function handleCreateGiteaIntegration(
request: Request,
dependencies: GiteaIntegrationRouteDependencies,
) {
const requestId = crypto.randomUUID()
return mutation(request, dependencies, requestId, async (safeRequest) => {
try {
const result = await dependencies.service.create(
await actor(safeRequest, dependencies),
createInput(await readJson(safeRequest)),
)
return Response.json(safeIntegration(result), {
status: 201,
headers: { 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
})
}
export async function handleGetGiteaIntegration(
request: Request,
integrationId: string,
dependencies: GiteaIntegrationRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
assertId(integrationId)
return Response.json(
safeIntegration(
await dependencies.service.get(
await actor(request, dependencies),
integrationId,
),
),
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (caught) {
return mappedError(caught, requestId)
}
}
export function handleTestGiteaIntegration(
request: Request,
integrationId: string,
dependencies: GiteaIntegrationRouteDependencies,
) {
const requestId = crypto.randomUUID()
return mutation(request, dependencies, requestId, async (safeRequest) => {
try {
assertId(integrationId)
const result = await dependencies.service.test(
await actor(safeRequest, dependencies),
integrationId,
)
return Response.json({
status: result.status,
serverVersion: result.serverVersion,
capabilities: result.capabilities,
healthCode: result.healthCode,
warnings: result.warnings,
})
} catch (caught) {
return mappedError(caught, requestId)
}
})
}
export async function handleDiscoverGiteaRepositories(
request: Request,
integrationId: string,
dependencies: GiteaIntegrationRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
assertId(integrationId)
const parameters = new URL(request.url).searchParams
for (const key of parameters.keys()) {
if (key !== 'cursor' && key !== 'limit')
invalid(
'gitea_query_invalid',
`Unsupported query parameter: ${key}`,
`query.${key}`,
)
}
if (
parameters.getAll('cursor').length > 1 ||
parameters.getAll('limit').length > 1
)
invalid(
'gitea_query_invalid',
'Query parameters may be supplied once',
'query',
)
const cursor = parameters.get('cursor')
if (cursor !== null && (cursor.length === 0 || cursor.length > 500))
invalid(
'gitea_query_invalid',
'cursor must contain 1 to 500 characters',
'query.cursor',
)
const rawLimit = parameters.get('limit') ?? '50'
if (
!/^\d+$/u.test(rawLimit) ||
Number(rawLimit) < 1 ||
Number(rawLimit) > 100
)
invalid(
'gitea_query_invalid',
'limit must be an integer from 1 through 100',
'query.limit',
)
const result = await dependencies.service.discover(
await actor(request, dependencies),
integrationId,
{ cursor, limit: Number(rawLimit) },
)
return Response.json(result, { headers: { 'Cache-Control': 'no-store' } })
} catch (caught) {
return mappedError(caught, requestId)
}
}
export function handleRotateGiteaSecret(
request: Request,
integrationId: string,
dependencies: GiteaIntegrationRouteDependencies,
) {
const requestId = crypto.randomUUID()
return mutation(request, dependencies, requestId, async (safeRequest) => {
try {
assertId(integrationId)
const result = await dependencies.service.rotate(
await actor(safeRequest, dependencies),
integrationId,
rotateInput(await readJson(safeRequest)),
)
return Response.json(safeIntegration(result), {
headers: { 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
})
}
export function handleDeleteGiteaIntegration(
request: Request,
integrationId: string,
dependencies: GiteaIntegrationRouteDependencies,
) {
const requestId = crypto.randomUUID()
return mutation(request, dependencies, requestId, async (safeRequest) => {
try {
assertId(integrationId)
await dependencies.service.delete(
await actor(safeRequest, dependencies),
integrationId,
)
return new Response(null, { status: 204 })
} catch (caught) {
return mappedError(caught, requestId)
}
})
}
export function handleImportGiteaRepository(
request: Request,
integrationId: string,
dependencies: GiteaIntegrationRouteDependencies,
) {
const requestId = crypto.randomUUID()
return mutation(request, dependencies, requestId, async (safeRequest) => {
try {
assertId(integrationId)
if (!dependencies.service.importRepository) {
throw new GiteaHttpError(
503,
'gitea_service_unavailable',
'Gitea repository import is temporarily unavailable',
)
}
const result = await dependencies.service.importRepository(
await actor(safeRequest, dependencies),
integrationId,
repositoryImportInput(await readJson(safeRequest)),
)
return Response.json(
{
repositoryId: result.repositoryId,
snapshotId: result.snapshotId,
jobId: result.jobId,
},
{
status: 202,
headers: { 'Cache-Control': 'no-store' },
},
)
} catch (caught) {
return mappedError(caught, requestId)
}
})
}
@@ -0,0 +1,14 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../../server/authenticated-workspace-context'
import { getGiteaIntegrationServer } from '../../../../../server/gitea-integrations'
import type { GiteaIntegrationRouteDependencies } from './integration-http'
export function giteaIntegrationRouteDependencies(): GiteaIntegrationRouteDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
service: getGiteaIntegrationServer(),
}
}
@@ -0,0 +1,21 @@
import {
handleCreateGiteaIntegration,
handleListGiteaIntegrations,
} from './integration-http'
import { giteaIntegrationRouteDependencies } from './integration-route-dependencies'
export const dynamic = 'force-dynamic'
export function GET(request: Request) {
return handleListGiteaIntegrations(
request,
giteaIntegrationRouteDependencies(),
)
}
export function POST(request: Request) {
return handleCreateGiteaIntegration(
request,
giteaIntegrationRouteDependencies(),
)
}
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest'
import type { ActorContext } from '@devrunbook/application'
import {
handleAcceptInvitation,
handleCreateInvitation,
type InvitationHttpDependencies,
} from './invitation-http'
const origin = 'https://runbook.example.test'
const actor: ActorContext = {
userId: '00000000-0000-4000-8000-000000000001',
workspaceId: '00000000-0000-4000-8000-000000000002',
workspaceRole: 'owner',
instanceRole: 'instance_owner',
}
function dependencies(
overrides: Partial<InvitationHttpDependencies> = {},
): InvitationHttpDependencies {
return {
publicBaseUrl: origin,
resolveActor: vi.fn().mockResolvedValue(actor),
create: vi.fn().mockResolvedValue({
id: '00000000-0000-4000-8000-000000000003',
email: 'new@example.test',
expiresAt: new Date('2026-07-28T12:00:00.000Z'),
inviteUrl: `${origin}/accept-invitation#token=secret`,
}),
consume: vi.fn().mockResolvedValue(true),
...overrides,
}
}
function post(path: string, body: unknown, requestOrigin = origin) {
return new Request(`${origin}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin: requestOrigin },
body: JSON.stringify(body),
})
}
describe('invitation HTTP boundary', () => {
it('creates an invitation for an instance administrator', async () => {
const deps = dependencies()
const response = await handleCreateInvitation(
post('/api/v1/invitations', {
email: 'new@example.test',
instanceRole: 'user',
}),
deps,
)
expect(response.status).toBe(201)
expect(await response.json()).toMatchObject({ email: 'new@example.test' })
})
it('rejects cross-origin requests and non-administrators', async () => {
const deps = dependencies()
const crossOrigin = await handleCreateInvitation(
post('/api/v1/invitations', {}, 'https://attacker.test'),
deps,
)
expect(crossOrigin.status).toBe(403)
expect(deps.resolveActor).not.toHaveBeenCalled()
const denied = await handleCreateInvitation(
post('/api/v1/invitations', {
email: 'new@example.test',
instanceRole: 'user',
}),
dependencies({
resolveActor: vi
.fn()
.mockResolvedValue({ ...actor, instanceRole: 'user' }),
}),
)
expect(denied.status).toBe(403)
})
it('accepts an exact single-use token shape and keeps failures generic', async () => {
const deps = dependencies()
const response = await handleAcceptInvitation(
post('/api/v1/auth/invitations/accept', {
token: 'a'.repeat(43),
displayName: 'New User',
password: 'correct horse battery staple',
passwordConfirmation: 'correct horse battery staple',
}),
deps,
)
expect(response.status).toBe(200)
expect(deps.consume).toHaveBeenCalledWith(
expect.objectContaining({ rawToken: 'a'.repeat(43) }),
)
const failed = await handleAcceptInvitation(
post('/api/v1/auth/invitations/accept', {
token: 'short',
displayName: 'X',
password: 'short',
passwordConfirmation: 'short',
}),
deps,
)
expect(failed.status).toBe(400)
expect(JSON.stringify(await failed.json())).not.toContain('short')
})
})
@@ -0,0 +1,231 @@
import type {
ActorContext,
InvitationInstanceRole,
InvitationWorkspaceRole,
} from '@devrunbook/application'
import { handleAuthRequest } from '../../../../auth/csrf'
const maximumBodyBytes = 8_192
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
const tokenPattern = /^[A-Za-z0-9_-]{32,512}$/u
export interface InvitationHttpDependencies {
readonly publicBaseUrl: string
readonly resolveActor: (request: Request) => Promise<ActorContext>
readonly create: (input: {
readonly actor: ActorContext
readonly email: string
readonly instanceRole: InvitationInstanceRole
readonly workspaceId: string | null
readonly workspaceRole: InvitationWorkspaceRole | null
}) => Promise<{
readonly id: string
readonly email: string
readonly expiresAt: Date
readonly inviteUrl: string
}>
readonly consume: (input: {
readonly rawToken: string
readonly displayName: string
readonly password: string
}) => Promise<boolean>
}
function error(
status: number,
code: string,
message: string,
requestId: string,
) {
return Response.json({ error: { code, message, requestId } }, { status })
}
async function readJson(
request: Request,
): Promise<Record<string, unknown> | null> {
if (
request.headers.get('content-type')?.split(';', 1)[0]?.trim() !==
'application/json'
)
return null
const declared = request.headers.get('content-length')
if (declared !== null && Number(declared) > maximumBodyBytes) return null
if (!request.body) return null
const reader = request.body.getReader()
const decoder = new TextDecoder()
let bytes = 0
let text = ''
try {
while (true) {
const chunk = await reader.read()
if (chunk.done) break
bytes += chunk.value.byteLength
if (bytes > maximumBodyBytes) {
await reader.cancel()
return null
}
text += decoder.decode(chunk.value, { stream: true })
}
text += decoder.decode()
} finally {
reader.releaseLock()
}
try {
const parsed: unknown = JSON.parse(text)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
}
function parseCreate(body: Record<string, unknown> | null) {
if (!body) return null
const allowed = new Set([
'email',
'instanceRole',
'workspaceId',
'workspaceRole',
])
if (Object.keys(body).some((key) => !allowed.has(key))) return null
if (
typeof body.email !== 'string' ||
body.email.length > 320 ||
!emailPattern.test(body.email) ||
(body.instanceRole !== 'instance_admin' && body.instanceRole !== 'user')
)
return null
const workspaceId = body.workspaceId ?? null
const workspaceRole = body.workspaceRole ?? null
if (
(workspaceId !== null &&
(typeof workspaceId !== 'string' || !uuidPattern.test(workspaceId))) ||
(workspaceRole !== null &&
!['owner', 'editor', 'viewer'].includes(String(workspaceRole))) ||
(workspaceId === null) !== (workspaceRole === null)
)
return null
return {
email: body.email,
instanceRole: body.instanceRole as InvitationInstanceRole,
workspaceId: workspaceId as string | null,
workspaceRole: workspaceRole as InvitationWorkspaceRole | null,
}
}
function parseAccept(body: Record<string, unknown> | null) {
if (
!body ||
Object.keys(body).sort().join(',') !==
'displayName,password,passwordConfirmation,token'
)
return null
if (
typeof body.token !== 'string' ||
!tokenPattern.test(body.token) ||
typeof body.displayName !== 'string' ||
body.displayName.trim().length < 1 ||
body.displayName.length > 120 ||
typeof body.password !== 'string' ||
body.password.length < 12 ||
body.password.length > 128 ||
body.passwordConfirmation !== body.password
)
return null
return {
rawToken: body.token,
displayName: body.displayName.trim(),
password: body.password,
}
}
export function handleCreateInvitation(
request: Request,
dependencies: InvitationHttpDependencies,
) {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
try {
const actor = await dependencies.resolveActor(sameOriginRequest)
if (
!['instance_owner', 'instance_admin'].includes(actor.instanceRole)
) {
return error(
403,
'instance_administration_denied',
'Instance administration is not permitted',
requestId,
)
}
const parsed = parseCreate(await readJson(sameOriginRequest))
if (!parsed)
return error(
422,
'validation_failed',
'Invitation input is invalid',
requestId,
)
const invitation = await dependencies.create({ actor, ...parsed })
return Response.json(
{ ...invitation, expiresAt: invitation.expiresAt.toISOString() },
{ status: 201 },
)
} catch {
return error(
409,
'invitation_unavailable',
'Invitation could not be created',
requestId,
)
}
},
dependencies.publicBaseUrl,
() => error(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
export function handleAcceptInvitation(
request: Request,
dependencies: InvitationHttpDependencies,
) {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
const parsed = parseAccept(await readJson(sameOriginRequest))
if (!parsed)
return error(
400,
'invitation_failed',
'The invitation is invalid or expired',
requestId,
)
try {
if (!(await dependencies.consume(parsed))) {
return error(
400,
'invitation_failed',
'The invitation is invalid or expired',
requestId,
)
}
return Response.json({ success: true })
} catch {
return error(
400,
'invitation_failed',
'The invitation is invalid or expired',
requestId,
)
}
},
dependencies.publicBaseUrl,
() => error(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
@@ -0,0 +1,26 @@
import {
createInvitation,
consumeInvitation,
resolveInvitationActor,
} from '../../../../server/invitations'
import {
handleCreateInvitation,
type InvitationHttpDependencies,
} from './invitation-http'
export const dynamic = 'force-dynamic'
function dependencies(): InvitationHttpDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveActor: resolveInvitationActor,
create: createInvitation,
consume: consumeInvitation,
}
}
export function POST(request: Request) {
return handleCreateInvitation(request, dependencies())
}
@@ -0,0 +1,15 @@
import { handleRetryJob } from '../../../operations-http'
import { operationsRouteDependencies } from '../../../operations-route-dependencies'
export const dynamic = 'force-dynamic'
export async function POST(
request: Request,
context: { params: Promise<{ jobId: string }> },
) {
return handleRetryJob(
request,
(await context.params).jobId,
operationsRouteDependencies(),
)
}
@@ -0,0 +1,15 @@
import { handleGetJob } from '../../operations-http'
import { operationsRouteDependencies } from '../../operations-route-dependencies'
export const dynamic = 'force-dynamic'
export async function GET(
request: Request,
context: { params: Promise<{ jobId: string }> },
) {
return handleGetJob(
request,
(await context.params).jobId,
operationsRouteDependencies(),
)
}
+8
View File
@@ -0,0 +1,8 @@
import { handleListJobs } from '../operations-http'
import { operationsRouteDependencies } from '../operations-route-dependencies'
export const dynamic = 'force-dynamic'
export function GET(request: Request) {
return handleListJobs(request, operationsRouteDependencies())
}
@@ -0,0 +1,162 @@
import type { ActorContext } from '@devrunbook/application'
import { describe, expect, it, vi } from 'vitest'
import type { OperationsServer } from '../../../server/operations'
import {
handleGetJob,
handleListAuditEvents,
handleListJobs,
handleRetryJob,
type OperationsHttpDependencies,
} from './operations-http'
const actor: ActorContext = {
userId: '10000000-0000-4000-8000-000000000001',
instanceRole: 'user',
workspaceId: '20000000-0000-4000-8000-000000000001',
workspaceRole: 'editor',
}
const jobId = '30000000-0000-4000-8000-000000000001'
const now = new Date('2026-07-27T12:00:00.000Z')
function dependencies(): OperationsHttpDependencies & {
service: OperationsServer
} {
return {
publicBaseUrl: 'https://runbook.example.test',
resolveContext: vi.fn(async () => actor),
service: {
listJobs: vi.fn(async () => ({
items: [
{
id: jobId,
workspaceId: actor.workspaceId,
type: 'gitea.repository-snapshot',
state: 'failed' as const,
progress: {},
attemptCount: 3,
maxAttempts: 3,
errorCode: 'job_retry_exhausted',
errorDetail: 'Retry limit reached',
retryable: true,
createdAt: now,
updatedAt: now,
},
],
nextCursor: 'next',
})),
getJob: vi.fn(async () => ({
id: jobId,
workspaceId: actor.workspaceId,
type: 'gitea.repository-snapshot',
state: 'failed' as const,
progress: {},
attemptCount: 3,
maxAttempts: 3,
errorCode: 'job_retry_exhausted',
errorDetail: 'Retry limit reached',
retryable: true,
createdAt: now,
updatedAt: now,
})),
retryJob: vi.fn(async () => undefined),
listAuditEvents: vi.fn(async () => ({
items: [
{
id: '40000000-0000-4000-8000-000000000001',
occurredAt: now,
actorUserId: actor.userId,
workspaceId: actor.workspaceId,
action: 'job.retry_requested',
resourceType: 'job',
resourceId: jobId,
outcome: 'success' as const,
metadata: { priorErrorCode: 'job_retry_exhausted' },
},
],
nextCursor: null,
})),
systemStatus: vi.fn(async () => ({
appVersion: '0.1.0',
schemaMigrationCount: 9,
databaseBytes: 1,
artifactBytes: 0,
artifactDiskTotalBytes: 100,
artifactDiskAvailableBytes: 80,
failedJobs: 1,
lastGiteaSyncAt: null,
lastObservedBackupAt: null,
})),
},
}
}
describe('operations HTTP', () => {
it('returns paginated safe jobs and exact detail without payload or lease data', async () => {
const target = dependencies()
const list = await handleListJobs(
new Request(
'https://runbook.example.test/api/v1/jobs?state=failed&limit=10',
),
target,
)
const detail = await handleGetJob(
new Request(`https://runbook.example.test/api/v1/jobs/${jobId}`),
jobId,
target,
)
expect(list.status).toBe(200)
expect(await list.json()).toMatchObject({
items: [{ id: jobId, retryable: true }],
nextCursor: 'next',
})
expect(JSON.stringify(await detail.json())).not.toContain('leaseOwner')
expect(target.service.listJobs).toHaveBeenCalledWith(actor, {
limit: 10,
state: 'failed',
})
})
it('rejects cross-origin retries and accepts an authorized same-origin retry', async () => {
const target = dependencies()
const blocked = await handleRetryJob(
new Request(`https://runbook.example.test/api/v1/jobs/${jobId}/retry`, {
method: 'POST',
headers: { Origin: 'https://attacker.example' },
}),
jobId,
target,
)
expect(blocked.status).toBe(403)
expect(target.service.retryJob).not.toHaveBeenCalled()
const accepted = await handleRetryJob(
new Request(`https://runbook.example.test/api/v1/jobs/${jobId}/retry`, {
method: 'POST',
headers: { Origin: 'https://runbook.example.test' },
}),
jobId,
target,
)
expect(accepted.status).toBe(202)
expect(target.service.retryJob).toHaveBeenCalledWith(actor, jobId)
})
it('serializes authorized append-only audit history', async () => {
const target = dependencies()
const response = await handleListAuditEvents(
new Request(
`https://runbook.example.test/api/v1/audit-events?action=job.retry_requested&workspaceId=${actor.workspaceId}`,
),
target,
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
items: [{ action: 'job.retry_requested', occurredAt: now.toISOString() }],
})
expect(target.service.listAuditEvents).toHaveBeenCalledWith(actor, {
action: 'job.retry_requested',
workspaceId: actor.workspaceId,
})
})
})
+223
View File
@@ -0,0 +1,223 @@
import type { JobState, OperationsActor } from '@devrunbook/application'
import { handleAuthRequest } from '../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../server/authenticated-workspace-context'
import type { OperationsServer } from '../../../server/operations'
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu
const states = new Set<JobState>([
'queued',
'running',
'succeeded',
'failed',
'cancelled',
])
export interface OperationsHttpDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<OperationsActor>
readonly service: OperationsServer
}
function error(
status: number,
code: string,
message: string,
requestId: string,
) {
return Response.json({ error: { code, message, requestId } }, { status })
}
function code(value: unknown): unknown {
return value !== null && typeof value === 'object' && 'code' in value
? value.code
: undefined
}
function jsonJob(item: Awaited<ReturnType<OperationsServer['getJob']>>) {
return item
? {
...item,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
}
: null
}
async function withErrors(
request: Request,
dependencies: OperationsHttpDependencies,
operation: (actor: OperationsActor) => Promise<Response>,
): Promise<Response> {
const requestId = crypto.randomUUID()
try {
return await operation(await dependencies.resolveContext(request))
} catch (caught) {
const errorCode = code(caught)
if (
caught instanceof AuthenticatedWorkspaceContextError &&
errorCode === 'authentication_required'
) {
return error(
401,
'authentication_required',
'Authentication required',
requestId,
)
}
if (
errorCode === 'workspace_access_denied' ||
errorCode === 'operations_access_denied'
) {
return error(403, 'operations_access_denied', 'Access denied', requestId)
}
if (errorCode === 'operations_job_not_found') {
return error(404, 'operations_job_not_found', 'Job not found', requestId)
}
if (errorCode === 'operations_job_not_retryable') {
return error(
409,
'operations_job_not_retryable',
'Job is not retryable',
requestId,
)
}
if (
errorCode === 'operations_cursor_invalid' ||
errorCode === 'operations_limit_invalid'
) {
return error(
400,
String(errorCode),
'Invalid operations query',
requestId,
)
}
return error(
503,
'operations_unavailable',
'Operations service unavailable',
requestId,
)
}
}
export function handleListJobs(
request: Request,
dependencies: OperationsHttpDependencies,
): Promise<Response> {
const url = new URL(request.url)
const rawState = url.searchParams.get('state')
const rawLimit = url.searchParams.get('limit')
if (rawState && !states.has(rawState as JobState)) {
return Promise.resolve(
error(
400,
'operations_state_invalid',
'Invalid job state',
crypto.randomUUID(),
),
)
}
return withErrors(request, dependencies, async (actor) => {
const result = await dependencies.service.listJobs(actor, {
...(url.searchParams.get('cursor')
? { cursor: url.searchParams.get('cursor')! }
: {}),
...(rawLimit ? { limit: Number(rawLimit) } : {}),
...(rawState ? { state: rawState as JobState } : {}),
})
return Response.json({
items: result.items.map((item) => jsonJob(item)),
nextCursor: result.nextCursor,
})
})
}
export function handleGetJob(
request: Request,
jobId: string,
dependencies: OperationsHttpDependencies,
): Promise<Response> {
if (!uuidPattern.test(jobId)) {
return Promise.resolve(
error(
404,
'operations_job_not_found',
'Job not found',
crypto.randomUUID(),
),
)
}
return withErrors(request, dependencies, async (actor) => {
const item = await dependencies.service.getJob(actor, jobId)
return item
? Response.json(jsonJob(item))
: error(
404,
'operations_job_not_found',
'Job not found',
crypto.randomUUID(),
)
})
}
export function handleRetryJob(
request: Request,
jobId: string,
dependencies: OperationsHttpDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
if (!uuidPattern.test(jobId)) {
return Promise.resolve(
error(404, 'operations_job_not_found', 'Job not found', requestId),
)
}
return handleAuthRequest(
request,
async (sameOriginRequest) => {
if (
sameOriginRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return error(403, 'invalid_origin', 'Invalid request origin', requestId)
}
return withErrors(sameOriginRequest, dependencies, async (actor) => {
await dependencies.service.retryJob(actor, jobId)
return Response.json({ jobId }, { status: 202 })
})
},
dependencies.publicBaseUrl,
() => error(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
export function handleListAuditEvents(
request: Request,
dependencies: OperationsHttpDependencies,
): Promise<Response> {
const url = new URL(request.url)
const rawLimit = url.searchParams.get('limit')
return withErrors(request, dependencies, async (actor) => {
const result = await dependencies.service.listAuditEvents(actor, {
...(url.searchParams.get('cursor')
? { cursor: url.searchParams.get('cursor')! }
: {}),
...(rawLimit ? { limit: Number(rawLimit) } : {}),
...(url.searchParams.get('action')
? { action: url.searchParams.get('action')! }
: {}),
...(url.searchParams.get('workspaceId')
? { workspaceId: url.searchParams.get('workspaceId')! }
: {}),
})
return Response.json({
items: result.items.map((item) => ({
...item,
occurredAt: item.occurredAt.toISOString(),
})),
nextCursor: result.nextCursor,
})
})
}
@@ -0,0 +1,14 @@
import { resolveAuthenticatedOperationsContext } from '../../../server/authenticated-operations-context'
import { getOperationsServer } from '../../../server/operations'
import type { OperationsHttpDependencies } from './operations-http'
export function operationsRouteDependencies(): OperationsHttpDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedOperationsContext,
service: getOperationsServer(),
}
}
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest'
import { ContentValidationError } from '@devrunbook/content'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
import {
handlePlaybookImport,
type PlaybookImportRouteDependencies,
} from './playbook-import-http'
const publicBaseUrl = 'https://devrunbook.example'
const actor = {
userId: '00000000-0000-4000-8000-000000000001',
instanceRole: 'user' as const,
workspaceId: '00000000-0000-4000-8000-000000000002',
workspaceRole: 'editor' as const,
}
function request(body = new Uint8Array([1, 2, 3]), headers = {}) {
return new Request(`${publicBaseUrl}/api/v1/playbook-imports`, {
method: 'POST',
headers: {
'content-type': 'application/zip',
origin: publicBaseUrl,
...headers,
},
body,
})
}
function dependencies(): PlaybookImportRouteDependencies {
return {
publicBaseUrl,
resolveContext: vi.fn(async () => actor),
service: {
import: vi.fn(async () => ({
draft: {
playbookId: '00000000-0000-4000-8000-000000000004',
versionId: '00000000-0000-4000-8000-000000000003',
slug: 'private-example',
semanticVersion: '1.0.0',
title: 'Private example',
lifecycle: 'draft',
draftRevision: 1,
draftDigest: 'a'.repeat(64),
publishedAt: null,
updatedAt: '2026-07-27T12:00:00.000Z',
},
etag: `"playbook-draft:1:${'a'.repeat(64)}"`,
archiveSha256: 'b'.repeat(64),
})),
},
}
}
describe('playbook import HTTP boundary', () => {
it('returns an evidence-neutral draft with ETag and safe location', async () => {
const response = await handlePlaybookImport(request(), dependencies())
expect(response.status).toBe(201)
expect(response.headers.get('etag')).toContain('playbook-draft:1')
expect(response.headers.get('location')).toMatch(/^\/prompt-lab\//u)
await expect(response.json()).resolves.toMatchObject({
lifecycle: 'draft',
slug: 'private-example',
})
})
it('rejects wrong origin, content type and oversized declared bodies', async () => {
const wrongOrigin = request(new Uint8Array([1]), {
origin: 'https://attacker.example',
})
expect(
(await handlePlaybookImport(wrongOrigin, dependencies())).status,
).toBe(403)
expect(
(
await handlePlaybookImport(
request(new Uint8Array([1]), { 'content-type': 'text/plain' }),
dependencies(),
)
).status,
).toBe(422)
expect(
(
await handlePlaybookImport(
request(new Uint8Array([1]), { 'content-length': '99999999' }),
dependencies(),
)
).status,
).toBe(413)
})
it('returns structured field-level validation issues', async () => {
const deps = dependencies()
deps.service.import = vi.fn(async () => {
throw new ContentValidationError('Invalid package', [
{
path: '/metadata/title',
code: 'required',
message: 'title is required',
remediation: 'Add a title.',
},
])
})
const response = await handlePlaybookImport(request(), deps)
expect(response.status).toBe(422)
await expect(response.json()).resolves.toMatchObject({
error: {
code: 'playbook_validation_failed',
details: [{ path: '/metadata/title', code: 'required' }],
},
})
})
it('does not reveal workspace existence on authentication failure', async () => {
const deps = {
...dependencies(),
resolveContext: vi.fn(async () => {
throw new AuthenticatedWorkspaceContextError('workspace_access_denied')
}),
}
const response = await handlePlaybookImport(request(), deps)
expect(response.status).toBe(403)
await expect(response.json()).resolves.toMatchObject({
error: { code: 'workspace_access_denied', message: 'Access denied' },
})
})
})
@@ -0,0 +1,238 @@
import type { ActorContext } from '@devrunbook/application'
import { PlaybookPackageArchiveError } from '@devrunbook/artifacts'
import { ContentValidationError } from '@devrunbook/content'
import { handleAuthRequest } from '../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
const defaultMaximumArchiveBytes = 11 * 1024 * 1024
export interface PlaybookImportHttpService {
import(
actor: ActorContext,
archive: Uint8Array,
): Promise<{
readonly draft: {
readonly playbookId: string
readonly versionId: string
readonly slug: string
readonly semanticVersion: string
readonly title: string
readonly lifecycle: string
readonly draftRevision: number
readonly draftDigest: string
readonly publishedAt: string | null
readonly updatedAt: string
}
readonly etag: string
readonly archiveSha256: string
}>
}
export interface PlaybookImportRouteDependencies {
readonly publicBaseUrl: string
readonly maximumArchiveBytes?: number
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly service: PlaybookImportHttpService
}
function errorResponse(
status: number,
code: string,
message: string,
requestId: string,
details?: unknown,
): Response {
return Response.json(
{
error: {
code,
message,
requestId,
...(details === undefined ? {} : { details }),
},
},
{
status,
headers: {
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
},
)
}
function mappedError(error: unknown, requestId: string): Response {
if (error instanceof AuthenticatedWorkspaceContextError) {
return errorResponse(
error.code === 'authentication_required' ? 401 : 403,
error.code,
error.code === 'authentication_required'
? 'Authentication required'
: 'Access denied',
requestId,
)
}
if (error instanceof PlaybookPackageArchiveError) {
return errorResponse(
error.code === 'playbook_archive_limit' ? 413 : 422,
error.code,
'Playbook package archive verification failed',
requestId,
[{ path: error.path, message: error.message }],
)
}
if (error instanceof ContentValidationError) {
return errorResponse(
422,
'playbook_validation_failed',
'The playbook package failed validation',
requestId,
error.issues,
)
}
const code =
error !== null && typeof error === 'object' && 'code' in error
? String(error.code)
: ''
if (code === 'workspace_access_denied') {
return errorResponse(403, code, 'Access denied', requestId)
}
if (code === 'private_playbook_draft_conflict') {
return errorResponse(
409,
code,
'This private playbook identity or version already exists',
requestId,
)
}
if (code === 'private_playbook_package_invalid') {
return errorResponse(
422,
code,
'The private playbook package is invalid',
requestId,
)
}
return errorResponse(
503,
'playbook_import_unavailable',
'Playbook import is temporarily unavailable',
requestId,
)
}
export async function readPlaybookArchiveBody(
request: Request,
maximumBytes: number,
): Promise<Uint8Array> {
if (request.headers.get('content-type') !== 'application/zip') {
throw new PlaybookPackageArchiveError(
'playbook_archive_invalid',
'archive',
'Content-Type must be application/zip',
)
}
const declared = request.headers.get('content-length')
if (
declared !== null &&
(!/^[0-9]+$/u.test(declared) || Number(declared) > maximumBytes)
) {
throw new PlaybookPackageArchiveError(
'playbook_archive_limit',
'archive',
'compressed archive limit exceeded',
)
}
if (!request.body) {
throw new PlaybookPackageArchiveError(
'playbook_archive_invalid',
'archive',
'is required',
)
}
const reader = request.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > maximumBytes) {
await reader.cancel()
throw new PlaybookPackageArchiveError(
'playbook_archive_limit',
'archive',
'compressed archive limit exceeded',
)
}
chunks.push(value)
}
const archive = new Uint8Array(size)
let offset = 0
for (const chunk of chunks) {
archive.set(chunk, offset)
offset += chunk.byteLength
}
return archive
}
export function handlePlaybookImport(
request: Request,
dependencies: PlaybookImportRouteDependencies,
): Promise<Response> {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
if (
sameOriginRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return errorResponse(
403,
'invalid_origin',
'Invalid request origin',
requestId,
)
}
try {
const actor = await dependencies.resolveContext(sameOriginRequest)
const archive = await readPlaybookArchiveBody(
sameOriginRequest,
dependencies.maximumArchiveBytes ?? defaultMaximumArchiveBytes,
)
const imported = await dependencies.service.import(actor, archive)
return Response.json(
{
playbookId: imported.draft.playbookId,
versionId: imported.draft.versionId,
slug: imported.draft.slug,
semanticVersion: imported.draft.semanticVersion,
title: imported.draft.title,
lifecycle: imported.draft.lifecycle,
draftRevision: imported.draft.draftRevision,
draftDigest: imported.draft.draftDigest,
publishedAt: imported.draft.publishedAt,
updatedAt: imported.draft.updatedAt,
archiveSha256: imported.archiveSha256,
},
{
status: 201,
headers: {
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
ETag: imported.etag,
Location: `/prompt-lab/${imported.draft.versionId}`,
},
},
)
} catch (error) {
return mappedError(error, requestId)
}
},
dependencies.publicBaseUrl,
() =>
errorResponse(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
@@ -0,0 +1,14 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../server/authenticated-workspace-context'
import { getPrivatePlaybookServer } from '../../../../server/private-playbooks'
import type { PlaybookImportRouteDependencies } from './playbook-import-http'
export function playbookImportRouteDependencies(): PlaybookImportRouteDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
service: getPrivatePlaybookServer(),
}
}
@@ -0,0 +1,8 @@
import { handlePlaybookImport } from './playbook-import-http'
import { playbookImportRouteDependencies } from './playbook-import-route-dependencies'
export const dynamic = 'force-dynamic'
export function POST(request: Request) {
return handlePlaybookImport(request, playbookImportRouteDependencies())
}
@@ -0,0 +1,116 @@
import { getPlaybookDetail } from '../../../../../lib/built-in-playbooks'
import {
AuthenticatedWorkspaceContextError,
resolveAuthenticatedWorkspaceContext,
} from '../../../../../server/authenticated-workspace-context'
export const dynamic = 'force-dynamic'
function object(value: unknown): Readonly<Record<string, unknown>> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Readonly<Record<string, unknown>>)
: {}
}
function string(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function strings(value: unknown): readonly string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: []
}
function stacks(spec: Readonly<Record<string, unknown>>): readonly string[] {
const compatibility = object(spec.compatibility)
return [
'languages',
'frameworks',
'packageManagers',
'databases',
'deploymentTypes',
].flatMap((field) => strings(compatibility[field]))
}
export async function GET(
request: Request,
context: { params: Promise<{ slug: string }> },
) {
const requestId = crypto.randomUUID()
try {
const actor = await resolveAuthenticatedWorkspaceContext(request)
const { slug } = await context.params
const detail = await getPlaybookDetail(slug, {
workspaceId: actor.workspaceId,
userId: actor.userId,
})
if (!detail?.current) {
return Response.json(
{
error: {
code: 'playbook_not_found',
message: 'Playbook not found',
requestId,
},
},
{ status: 404 },
)
}
const manifest = object(detail.current.manifest)
const metadata = object(manifest.metadata)
const spec = object(manifest.spec)
const autonomy = object(spec.autonomy)
const quality = object(manifest.quality)
return Response.json({
id: detail.id,
slug: detail.slug,
title: string(metadata.title),
summary: string(metadata.summary),
category: string(metadata.category),
source: detail.source,
currentVersion: detail.current.version,
lifecycle: detail.current.lifecycle,
riskTier: string(metadata.riskTier),
type: string(spec.type),
defaultMode: string(spec.defaultMode),
defaultAutonomy: string(autonomy.default),
supportedModes: strings(spec.modes),
autonomyMin: string(autonomy.min),
autonomyMax: string(autonomy.max),
stacks: stacks(spec),
qualityStatus: string(quality.reviewStatus),
publishedAt: detail.current.publishedAt,
favorite: detail.favorite,
tags: strings(metadata.tags),
matchReasons: [],
digest: detail.current.digest,
current: detail.current,
versions: detail.versions,
})
} catch (error) {
if (error instanceof AuthenticatedWorkspaceContextError) {
const status = error.code === 'authentication_required' ? 401 : 403
return Response.json(
{
error: {
code: error.code,
message: error.message,
requestId,
},
},
{ status },
)
}
return Response.json(
{
error: {
code: 'catalog_unavailable',
message: 'The playbook catalog is temporarily unavailable',
requestId,
},
},
{ status: 503 },
)
}
}
@@ -0,0 +1,21 @@
import { handlePublishPrivatePlaybook } from '../../../../../private-playbooks/private-playbook-quality-http'
import { privatePlaybookQualityRouteDependencies } from '../../../../../private-playbooks/private-playbook-quality-route-dependencies'
export const dynamic = 'force-dynamic'
interface RouteContext {
readonly params: Promise<{
readonly slug: string
readonly version: string
}>
}
export async function POST(request: Request, context: RouteContext) {
const { slug: playbookId, version } = await context.params
return handlePublishPrivatePlaybook(
request,
playbookId,
version,
privatePlaybookQualityRouteDependencies(),
)
}
@@ -0,0 +1,59 @@
import { getPlaybookVersion } from '../../../../../../../lib/built-in-playbooks'
import {
AuthenticatedWorkspaceContextError,
resolveAuthenticatedWorkspaceContext,
} from '../../../../../../../server/authenticated-workspace-context'
export const dynamic = 'force-dynamic'
export async function GET(
request: Request,
context: { params: Promise<{ slug: string; version: string }> },
) {
const requestId = crypto.randomUUID()
try {
const actor = await resolveAuthenticatedWorkspaceContext(request)
const { slug, version } = await context.params
const playbookVersion = await getPlaybookVersion(slug, version, {
workspaceId: actor.workspaceId,
userId: actor.userId,
})
if (!playbookVersion) {
return Response.json(
{
error: {
code: 'playbook_version_not_found',
message: 'Playbook version not found',
requestId,
},
},
{ status: 404 },
)
}
return Response.json(playbookVersion)
} catch (error) {
if (error instanceof AuthenticatedWorkspaceContextError) {
const status = error.code === 'authentication_required' ? 401 : 403
return Response.json(
{
error: {
code: error.code,
message: error.message,
requestId,
},
},
{ status },
)
}
return Response.json(
{
error: {
code: 'catalog_unavailable',
message: 'The playbook catalog is temporarily unavailable',
requestId,
},
},
{ status: 503 },
)
}
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { parsePlaybookQuery, PlaybookQueryError } from './playbook-query'
describe('playbook list query', () => {
it('parses repeated and comma-separated governed filters', () => {
expect(
parsePlaybookQuery(
'https://runbook.example.test/api/v1/playbooks?q=root%20cause&category=implementation,audit&riskTier=moderate&lifecycle=reviewed&source=built_in',
),
).toEqual({
catalog: {
q: 'root cause',
category: ['implementation', 'audit'],
riskTier: ['moderate'],
lifecycle: ['reviewed'],
source: ['built_in'],
},
limit: 50,
})
})
it('parses the complete governed filter, sort and pagination contract', () => {
expect(
parsePlaybookQuery(
'https://runbook.example.test/api/v1/playbooks?type=guided&mode=execute&autonomy=verify&stack=TypeScript&quality=technical-reviewed&favorite=true&sort=updated&limit=12&cursor=b2Zmc2V0OjEy',
),
).toEqual({
catalog: {
type: ['guided'],
mode: ['execute'],
autonomy: ['verify'],
stack: ['TypeScript'],
quality: ['technical-reviewed'],
favorite: true,
sort: 'updated',
},
cursor: 'b2Zmc2V0OjEy',
limit: 12,
})
})
it.each([
'riskTier=unknown',
'lifecycle=published',
'source=remote_registry',
'unexpected=value',
`q=${'x'.repeat(201)}`,
'cursor=not+base64',
'type=unknown',
'mode=unknown',
'autonomy=unknown',
'quality=unknown',
'favorite=yes',
'sort=oldest',
'limit=0',
'limit=101',
])('rejects invalid or unsupported query input: %s', (query) => {
expect(() =>
parsePlaybookQuery(
`https://runbook.example.test/api/v1/playbooks?${query}`,
),
).toThrow(PlaybookQueryError)
})
})
@@ -0,0 +1,179 @@
import type {
PlaybookAutonomy,
PlaybookCatalogQuery,
PlaybookLifecycle,
PlaybookMode,
PlaybookQualityStatus,
PlaybookRiskTier,
PlaybookSort,
PlaybookSource,
PlaybookType,
} from '@devrunbook/db'
export class PlaybookQueryError extends Error {}
const allowedParameters = new Set([
'q',
'category',
'type',
'mode',
'autonomy',
'riskTier',
'stack',
'lifecycle',
'quality',
'source',
'favorite',
'sort',
'cursor',
'limit',
])
const playbookTypes = new Set<PlaybookType>(['quick', 'guided', 'run-pack'])
const modes = new Set<PlaybookMode>([
'inspect',
'plan',
'guided',
'execute',
'recovery',
])
const autonomies = new Set<PlaybookAutonomy>([
'observe',
'diagnose',
'plan',
'implement',
'verify',
'repair',
])
const riskTiers = new Set<PlaybookRiskTier>([
'low',
'moderate',
'high',
'critical',
])
const lifecycles = new Set<PlaybookLifecycle>([
'draft',
'reviewed',
'validated',
'battle-tested',
'deprecated',
])
const sources = new Set<PlaybookSource>(['built_in', 'private', 'imported'])
const qualities = new Set<PlaybookQualityStatus>([
'unreviewed',
'editorial-reviewed',
'technical-reviewed',
'evaluation-backed',
])
const sorts = new Set<PlaybookSort>([
'relevance',
'updated',
'title',
'quality',
])
export interface ParsedPlaybookQuery {
readonly catalog: PlaybookCatalogQuery
readonly cursor?: string
readonly limit: number
}
export function encodePlaybookCursor(offset: number): string {
return Buffer.from(`offset:${offset}`, 'utf8').toString('base64url')
}
export function decodePlaybookCursor(cursor: string | undefined): number {
if (!cursor) return 0
const match = /^offset:(\d+)$/u.exec(
Buffer.from(cursor, 'base64url').toString('utf8'),
)
const offset = match ? Number(match[1]) : Number.NaN
if (!Number.isSafeInteger(offset) || offset < 0) {
throw new PlaybookQueryError('cursor is malformed')
}
return offset
}
function values(parameters: URLSearchParams, name: string): string[] {
return parameters
.getAll(name)
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean)
}
function enums<T extends string>(
parameters: URLSearchParams,
name: string,
allowed: ReadonlySet<T>,
): T[] | undefined {
const parsed = values(parameters, name)
if (parsed.some((value) => !allowed.has(value as T))) {
throw new PlaybookQueryError(`${name} contains an unsupported value`)
}
return parsed.length > 0 ? (parsed as T[]) : undefined
}
export function parsePlaybookQuery(url: string): ParsedPlaybookQuery {
const parameters = new URL(url).searchParams
for (const name of parameters.keys()) {
if (!allowedParameters.has(name)) {
throw new PlaybookQueryError(`Unsupported query parameter: ${name}`)
}
}
const q = parameters.get('q')?.trim()
if (q && q.length > 200)
throw new PlaybookQueryError('q must be at most 200 characters')
const category = values(parameters, 'category')
const stack = values(parameters, 'stack')
if ([...category, ...stack].some((value) => value.length > 80)) {
throw new PlaybookQueryError(
'category and stack values must be at most 80 characters',
)
}
const type = enums(parameters, 'type', playbookTypes)
const mode = enums(parameters, 'mode', modes)
const autonomy = enums(parameters, 'autonomy', autonomies)
const riskTier = enums(parameters, 'riskTier', riskTiers)
const lifecycle = enums(parameters, 'lifecycle', lifecycles)
const quality = enums(parameters, 'quality', qualities)
const source = enums(parameters, 'source', sources)
const sortValues = values(parameters, 'sort')
if (
sortValues.length > 1 ||
(sortValues[0] && !sorts.has(sortValues[0] as PlaybookSort))
) {
throw new PlaybookQueryError('sort contains an unsupported value')
}
const favoriteValues = values(parameters, 'favorite')
if (
favoriteValues.length > 1 ||
(favoriteValues[0] && !['true', 'false'].includes(favoriteValues[0]))
) {
throw new PlaybookQueryError('favorite must be true or false')
}
const cursor = parameters.get('cursor')?.trim()
if (cursor && !/^[A-Za-z0-9_-]{1,500}$/u.test(cursor)) {
throw new PlaybookQueryError('cursor is malformed')
}
if (cursor) decodePlaybookCursor(cursor)
const rawLimit = parameters.get('limit')
const limit = rawLimit === null ? 50 : Number(rawLimit)
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new PlaybookQueryError('limit must be an integer from 1 to 100')
}
const catalog: PlaybookCatalogQuery = {
...(q ? { q } : {}),
...(category.length > 0 ? { category } : {}),
...(type ? { type } : {}),
...(mode ? { mode } : {}),
...(autonomy ? { autonomy } : {}),
...(riskTier ? { riskTier } : {}),
...(stack.length > 0 ? { stack } : {}),
...(lifecycle ? { lifecycle } : {}),
...(quality ? { quality } : {}),
...(source ? { source } : {}),
...(favoriteValues[0] === 'true' ? { favorite: true } : {}),
...(sortValues[0] ? { sort: sortValues[0] as PlaybookSort } : {}),
}
return { catalog, ...(cursor ? { cursor } : {}), limit }
}
@@ -0,0 +1,117 @@
import type { ActorContext } from '@devrunbook/application'
import type { PlaybookCatalogSummary } from '@devrunbook/db'
import { describe, expect, it, vi } from 'vitest'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
import {
handleListPlaybooks,
type ListPlaybooksRouteDependencies,
} from './route'
const actor: ActorContext = {
userId: '00000000-0000-4000-8000-000000000001',
workspaceId: '00000000-0000-4000-8000-000000000002',
instanceRole: 'user',
workspaceRole: 'viewer',
}
function item(index: number): PlaybookCatalogSummary {
return {
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
slug: `playbook-${index}`,
title: `Playbook ${index}`,
summary: 'A governed outcome.',
category: 'audit',
source: 'built_in',
currentVersion: '1.0.0',
lifecycle: 'validated',
riskTier: 'low',
type: 'guided',
defaultMode: 'inspect',
defaultAutonomy: 'diagnose',
supportedModes: ['inspect'],
autonomyMin: 'observe',
autonomyMax: 'verify',
stacks: ['typescript'],
qualityStatus: 'technical-reviewed',
publishedAt: new Date('2026-01-01T00:00:00Z'),
favorite: false,
tags: ['audit'],
matchReasons: [],
digest: String(index).padStart(64, '0'),
}
}
function dependencies(
overrides: Partial<ListPlaybooksRouteDependencies> = {},
): ListPlaybooksRouteDependencies {
return {
resolveContext: vi.fn(async () => actor),
search: vi.fn(async (query) =>
Object.keys(query).length ? [item(1), item(2)] : [item(1), item(2)],
),
...overrides,
}
}
describe('playbook list route', () => {
it('authorizes, scopes filters and returns deterministic cursor pagination', async () => {
const context = dependencies()
const response = await handleListPlaybooks(
new Request(
'https://runbook.example.test/api/v1/playbooks?type=guided&limit=1',
),
context,
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.items).toHaveLength(1)
expect(body.nextCursor).toBeTypeOf('string')
expect(body.search).toEqual({ status: 'ready', total: 2 })
expect(context.search).toHaveBeenNthCalledWith(
1,
{ type: ['guided'] },
{ workspaceId: actor.workspaceId, userId: actor.userId },
)
})
it('authenticates before reporting query validation details', async () => {
const response = await handleListPlaybooks(
new Request(
'https://runbook.example.test/api/v1/playbooks?unsupported=true',
),
dependencies({
resolveContext: vi.fn(async () => {
throw new AuthenticatedWorkspaceContextError(
'authentication_required',
)
}),
}),
)
expect(response.status).toBe(401)
expect(await response.json()).toMatchObject({
error: { code: 'authentication_required' },
})
})
it('returns governed validation and degraded catalog states', async () => {
const invalid = await handleListPlaybooks(
new Request('https://runbook.example.test/api/v1/playbooks?limit=0'),
dependencies(),
)
const degraded = await handleListPlaybooks(
new Request('https://runbook.example.test/api/v1/playbooks'),
dependencies({
search: vi.fn(async () => {
throw new Error('postgresql://operator:secret@database/internal')
}),
}),
)
expect(invalid.status).toBe(422)
expect(degraded.status).toBe(503)
expect(JSON.stringify(await degraded.json())).not.toContain('secret')
})
})
+137
View File
@@ -0,0 +1,137 @@
import type { ActorContext } from '@devrunbook/application'
import { searchPlaybooks } from '../../../../lib/built-in-playbooks'
import {
AuthenticatedWorkspaceContextError,
resolveAuthenticatedWorkspaceContext,
} from '../../../../server/authenticated-workspace-context'
import {
decodePlaybookCursor,
encodePlaybookCursor,
parsePlaybookQuery,
PlaybookQueryError,
} from './playbook-query'
export const dynamic = 'force-dynamic'
function facets(
items: readonly {
category: string
riskTier: string
lifecycle: string
source: string
type: string
supportedModes: readonly string[]
autonomyMin: string
autonomyMax: string
stacks: readonly string[]
qualityStatus: string
}[],
) {
const count = (values: readonly string[]): Readonly<Record<string, number>> =>
Object.fromEntries(
[...new Set(values)]
.sort()
.map((value) => [
value,
values.filter((item) => item === value).length,
]),
)
return {
category: count(items.map((item) => item.category)),
type: count(items.map((item) => item.type)),
mode: count(items.flatMap((item) => item.supportedModes)),
autonomy: count(
items.flatMap((item) => [item.autonomyMin, item.autonomyMax]),
),
riskTier: count(items.map((item) => item.riskTier)),
stack: count(items.flatMap((item) => item.stacks)),
lifecycle: count(items.map((item) => item.lifecycle)),
quality: count(items.map((item) => item.qualityStatus)),
source: count(items.map((item) => item.source)),
}
}
function errorResponse(
status: number,
code: string,
message: string,
requestId: string,
details?: readonly Readonly<Record<string, string>>[],
) {
return Response.json(
{
error: {
code,
message,
requestId,
...(details ? { details } : {}),
},
},
{ status },
)
}
export interface ListPlaybooksRouteDependencies {
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly search: typeof searchPlaybooks
}
const productionDependencies: ListPlaybooksRouteDependencies = {
resolveContext: resolveAuthenticatedWorkspaceContext,
search: searchPlaybooks,
}
export async function handleListPlaybooks(
request: Request,
dependencies: ListPlaybooksRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
const context = await dependencies.resolveContext(request)
const parsed = parsePlaybookQuery(request.url)
const scope = { workspaceId: context.workspaceId, userId: context.userId }
const [matching, accessible] = await Promise.all([
dependencies.search(parsed.catalog, scope),
dependencies.search({}, scope),
])
const offset = decodePlaybookCursor(parsed.cursor)
if (offset > matching.length) {
throw new PlaybookQueryError('cursor is outside the result set')
}
const items = matching.slice(offset, offset + parsed.limit)
const nextOffset = offset + items.length
const nextCursor =
nextOffset < matching.length ? encodePlaybookCursor(nextOffset) : null
return Response.json({
items,
nextCursor,
facets: facets(accessible),
search: { status: 'ready', total: matching.length },
})
} catch (error) {
if (error instanceof PlaybookQueryError) {
return errorResponse(
422,
'validation_failed',
'Playbook query is invalid',
requestId,
[{ path: 'query', rule: 'governed-query', message: error.message }],
)
}
if (error instanceof AuthenticatedWorkspaceContextError) {
const status = error.code === 'authentication_required' ? 401 : 403
return errorResponse(status, error.code, error.message, requestId)
}
return errorResponse(
503,
'catalog_unavailable',
'The playbook catalog is temporarily unavailable',
requestId,
)
}
}
export async function GET(request: Request) {
return handleListPlaybooks(request, productionDependencies)
}
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { POST } from './route'
const origin = 'https://runbook.example.test'
afterEach(() => vi.unstubAllEnvs())
function request(body: unknown, requestOrigin = origin) {
return new Request(`${origin}/api/v1/presentation/locale`, {
method: 'POST',
headers: {
'content-type': 'application/json',
origin: requestOrigin,
},
body: JSON.stringify(body),
})
}
describe('public locale preference', () => {
it('sets one secure, HTTP-only locale cookie without requiring an account', async () => {
vi.stubEnv('PUBLIC_BASE_URL', origin)
const response = await POST(request({ locale: 'nl' }))
expect(response.status).toBe(204)
expect(response.headers.get('set-cookie')).toContain(
'devrunbook_locale=nl; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly; Secure',
)
})
it('rejects unsupported locales and cross-origin writes', async () => {
vi.stubEnv('PUBLIC_BASE_URL', origin)
await expect(POST(request({ locale: 'fr' }))).resolves.toMatchObject({
status: 422,
})
await expect(
POST(request({ locale: 'en' }, 'https://attacker.example.test')),
).resolves.toMatchObject({ status: 403 })
})
})
@@ -0,0 +1,34 @@
import { z } from 'zod'
import { handleAuthRequest } from '../../../../../auth/csrf'
import { supportedLocales } from '../../../../../components/presentation/presentation-model'
export const dynamic = 'force-dynamic'
const requestSchema = z.strictObject({ locale: z.enum(supportedLocales) })
export function POST(request: Request) {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return handleAuthRequest(
request,
async () => {
try {
const { locale } = requestSchema.parse(await request.json())
const secure = new URL(publicBaseUrl).protocol === 'https:'
return new Response(null, {
status: 204,
headers: {
'Set-Cookie': `devrunbook_locale=${locale}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure ? '; Secure' : ''}`,
},
})
} catch {
return Response.json(
{ error: { code: 'locale_preference_invalid' } },
{ status: 422 },
)
}
},
publicBaseUrl,
)
}
@@ -0,0 +1,17 @@
import { handleExportPrivatePlaybook } from '../../private-playbook-http'
import { privatePlaybookRouteDependencies } from '../../private-playbook-route-dependencies'
export const dynamic = 'force-dynamic'
interface RouteContext {
readonly params: Promise<{ readonly versionId: string }>
}
export async function GET(request: Request, context: RouteContext) {
const { versionId } = await context.params
return handleExportPrivatePlaybook(
request,
versionId,
privatePlaybookRouteDependencies(),
)
}
@@ -0,0 +1,17 @@
import { handleReviewPrivatePlaybook } from '../../private-playbook-quality-http'
import { privatePlaybookQualityRouteDependencies } from '../../private-playbook-quality-route-dependencies'
export const dynamic = 'force-dynamic'
interface RouteContext {
readonly params: Promise<{ readonly versionId: string }>
}
export async function POST(request: Request, context: RouteContext) {
const { versionId } = await context.params
return handleReviewPrivatePlaybook(
request,
versionId,
privatePlaybookQualityRouteDependencies(),
)
}
@@ -0,0 +1,29 @@
import {
handleGetPrivatePlaybook,
handleUpdatePrivatePlaybook,
} from '../private-playbook-http'
import { privatePlaybookRouteDependencies } from '../private-playbook-route-dependencies'
export const dynamic = 'force-dynamic'
interface RouteContext {
readonly params: Promise<{ readonly versionId: string }>
}
export async function GET(request: Request, context: RouteContext) {
const { versionId } = await context.params
return handleGetPrivatePlaybook(
request,
versionId,
privatePlaybookRouteDependencies(),
)
}
export async function PUT(request: Request, context: RouteContext) {
const { versionId } = await context.params
return handleUpdatePrivatePlaybook(
request,
versionId,
privatePlaybookRouteDependencies(),
)
}
@@ -0,0 +1,17 @@
import { handleCreateNextPrivatePlaybookVersion } from '../../private-playbook-quality-http'
import { privatePlaybookQualityRouteDependencies } from '../../private-playbook-quality-route-dependencies'
export const dynamic = 'force-dynamic'
interface RouteContext {
readonly params: Promise<{ readonly versionId: string }>
}
export async function POST(request: Request, context: RouteContext) {
const { versionId } = await context.params
return handleCreateNextPrivatePlaybookVersion(
request,
versionId,
privatePlaybookQualityRouteDependencies(),
)
}
@@ -0,0 +1,214 @@
import { createHash } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'
import type { PrivatePlaybookDraft } from '@devrunbook/application'
import {
handleExportPrivatePlaybook,
handleGetPrivatePlaybook,
handleListPrivatePlaybooks,
handleUpdatePrivatePlaybook,
type PrivatePlaybookRouteDependencies,
} from './private-playbook-http'
const publicBaseUrl = 'https://devrunbook.example'
const versionId = '00000000-0000-4000-8000-000000000003'
const actor = {
userId: '00000000-0000-4000-8000-000000000001',
instanceRole: 'user' as const,
workspaceId: '00000000-0000-4000-8000-000000000002',
workspaceRole: 'editor' as const,
}
const etag = `"playbook-draft:1:${'a'.repeat(64)}"`
function draft(): PrivatePlaybookDraft {
const content = new TextEncoder().encode('# Mission\n')
return {
playbookId: '00000000-0000-4000-8000-000000000004',
versionId,
logicalId: 'private-example',
slug: 'private-example',
semanticVersion: '1.0.0',
title: 'Private example',
lifecycle: 'draft',
draftRevision: 1,
draftDigest: 'a'.repeat(64),
publishedAt: null,
updatedAt: '2026-07-27T12:00:00.000Z',
packageApiVersion: 'devrunbook.io/v1alpha1',
summary: 'Summary',
category: 'Authoring',
riskTier: 'low',
packageJson: {},
templateText: '# Mission\n',
files: [
{
path: 'prompt.md',
role: 'template',
mediaType: 'text/markdown',
content,
sizeBytes: content.byteLength,
sha256: createHash('sha256').update(content).digest('hex'),
digest: true,
exportByDefault: true,
},
],
}
}
function dependencies(): PrivatePlaybookRouteDependencies {
const value = draft()
return {
publicBaseUrl,
resolveContext: vi.fn(async () => actor),
service: {
list: vi.fn(async () => [value]),
get: vi.fn(async () => ({ draft: value, etag })),
update: vi.fn(async () => ({
draft: { ...value, draftRevision: 2 },
etag: `"playbook-draft:2:${'a'.repeat(64)}"`,
archiveSha256: 'b'.repeat(64),
})),
updateFiles: vi.fn(async () => ({
draft: { ...value, draftRevision: 2 },
etag: `"playbook-draft:2:${'a'.repeat(64)}"`,
packageDigest: 'a'.repeat(64),
})),
export: vi.fn(async () => ({
bytes: new Uint8Array([80, 75, 5, 6]),
sha256: 'c'.repeat(64),
})),
},
}
}
function get(path = '') {
return new Request(`${publicBaseUrl}/api/v1/private-playbooks${path}`)
}
function put(headers: Record<string, string> = {}) {
return new Request(`${publicBaseUrl}/api/v1/private-playbooks/${versionId}`, {
method: 'PUT',
headers: {
origin: publicBaseUrl,
'content-type': 'application/zip',
...headers,
},
body: new Uint8Array([1, 2, 3]),
})
}
describe('private playbook HTTP boundary', () => {
it('lists workspace drafts and returns safe editable file content', async () => {
const deps = dependencies()
const listed = await handleListPrivatePlaybooks(get(), deps)
expect(listed.status).toBe(200)
await expect(listed.json()).resolves.toMatchObject({
items: [{ versionId, lifecycle: 'draft' }],
})
const detail = await handleGetPrivatePlaybook(
get(`/${versionId}`),
versionId,
deps,
)
expect(detail.headers.get('etag')).toBe(etag)
await expect(detail.json()).resolves.toMatchObject({
files: [{ path: 'prompt.md', encoding: 'utf8', content: '# Mission\n' }],
})
})
it('requires optimistic concurrency and same-origin archive updates', async () => {
const deps = dependencies()
expect(
(await handleUpdatePrivatePlaybook(put(), versionId, deps)).status,
).toBe(428)
const updated = await handleUpdatePrivatePlaybook(
put({ 'if-match': etag }),
versionId,
deps,
)
expect(updated.status).toBe(200)
expect(updated.headers.get('etag')).toContain('playbook-draft:2')
expect(deps.service.update).toHaveBeenCalledWith(
actor,
versionId,
etag,
expect.any(Uint8Array),
)
})
it('accepts a complete editor inventory with canonical binary encoding', async () => {
const deps = dependencies()
const request = new Request(
`${publicBaseUrl}/api/v1/private-playbooks/${versionId}`,
{
method: 'PUT',
headers: {
origin: publicBaseUrl,
'content-type': 'application/json',
'if-match': etag,
},
body: JSON.stringify({
files: [
{
path: 'playbook.yaml',
role: 'manifest',
encoding: 'utf8',
content: 'kind: PlaybookPackage\n',
},
{
path: 'fixture.bin',
role: 'resource',
encoding: 'base64',
content: 'AAE=',
},
],
}),
},
)
const response = await handleUpdatePrivatePlaybook(request, versionId, deps)
expect(response.status).toBe(200)
expect(deps.service.updateFiles).toHaveBeenCalledWith(
actor,
versionId,
etag,
expect.arrayContaining([
expect.objectContaining({
path: 'fixture.bin',
content: new Uint8Array([0, 1]),
}),
]),
)
})
it('exports an authorized ZIP with a deterministic digest header', async () => {
const response = await handleExportPrivatePlaybook(
get(`/${versionId}/export`),
versionId,
dependencies(),
)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/zip')
expect(response.headers.get('x-devrunbook-archive-sha256')).toBe(
'c'.repeat(64),
)
expect(new Uint8Array(await response.arrayBuffer())).toEqual(
new Uint8Array([80, 75, 5, 6]),
)
})
it('uses a generic not-found response for malformed identifiers', async () => {
const response = await handleGetPrivatePlaybook(
get('/not-an-id'),
'not-an-id',
dependencies(),
)
expect(response.status).toBe(404)
await expect(response.json()).resolves.toMatchObject({
error: { code: 'private_playbook_not_found' },
})
})
})
@@ -0,0 +1,432 @@
import type {
ActorContext,
PrivatePlaybookDraft,
PrivatePlaybookDraftSummary,
} from '@devrunbook/application'
import { PlaybookPackageArchiveError } from '@devrunbook/artifacts'
import { ContentValidationError } from '@devrunbook/content'
import type { PlaybookPackageFileRecord } from '@devrunbook/content'
import { handleAuthRequest } from '../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
import { readPlaybookArchiveBody } from '../playbook-imports/playbook-import-http'
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
export interface PrivatePlaybookHttpService {
list(actor: ActorContext): Promise<readonly PrivatePlaybookDraftSummary[]>
get(
actor: ActorContext,
versionId: string,
): Promise<{ readonly draft: PrivatePlaybookDraft; readonly etag: string }>
update(
actor: ActorContext,
versionId: string,
expectedEtag: string,
archive: Uint8Array,
): Promise<{
readonly draft: PrivatePlaybookDraft
readonly etag: string
readonly archiveSha256: string
}>
updateFiles(
actor: ActorContext,
versionId: string,
expectedEtag: string,
files: readonly PlaybookPackageFileRecord[],
): Promise<{
readonly draft: PrivatePlaybookDraft
readonly etag: string
readonly packageDigest: string
}>
export(
actor: ActorContext,
versionId: string,
): Promise<{ readonly bytes: Uint8Array; readonly sha256: string }>
}
export interface PrivatePlaybookRouteDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly service: PrivatePlaybookHttpService
}
function response(
status: number,
code: string,
message: string,
requestId: string,
details?: unknown,
) {
return Response.json(
{
error: {
code,
message,
requestId,
...(details === undefined ? {} : { details }),
},
},
{ status, headers: { 'Cache-Control': 'no-store' } },
)
}
function codeOf(error: unknown): string {
return error !== null && typeof error === 'object' && 'code' in error
? String(error.code)
: ''
}
function mappedError(error: unknown, requestId: string): Response {
if (error instanceof AuthenticatedWorkspaceContextError) {
return response(
error.code === 'authentication_required' ? 401 : 403,
error.code,
error.code === 'authentication_required'
? 'Authentication required'
: 'Access denied',
requestId,
)
}
if (error instanceof PlaybookPackageArchiveError) {
return response(
error.code === 'playbook_archive_limit' ? 413 : 422,
error.code,
'Playbook package archive verification failed',
requestId,
[{ path: error.path, message: error.message }],
)
}
if (error instanceof ContentValidationError) {
return response(
422,
'playbook_validation_failed',
'The playbook package failed validation',
requestId,
error.issues,
)
}
const code = codeOf(error)
if (code === 'workspace_access_denied')
return response(403, code, 'Access denied', requestId)
if (code === 'private_playbook_not_found')
return response(404, code, 'Private playbook not found', requestId)
if (code === 'private_playbook_draft_conflict')
return response(
409,
code,
'The draft changed; reload and review before saving',
requestId,
)
if (code === 'private_playbook_etag_invalid')
return response(422, code, 'The supplied ETag is invalid', requestId)
if (code === 'private_playbook_published_immutable')
return response(
409,
code,
'Published versions are immutable; create a new version',
requestId,
)
return response(
503,
'private_playbook_service_unavailable',
'Private playbooks are temporarily unavailable',
requestId,
)
}
function assertVersionId(versionId: string): void {
if (!uuidPattern.test(versionId)) {
throw Object.assign(new Error('Private playbook not found'), {
code: 'private_playbook_not_found',
})
}
}
async function readBoundedBody(
request: Request,
maximumBytes: number,
): Promise<Uint8Array> {
const declared = request.headers.get('content-length')
if (
declared !== null &&
(!/^\d+$/u.test(declared) || Number(declared) > maximumBytes)
) {
throw new PlaybookPackageArchiveError(
'playbook_archive_limit',
'body',
'request body limit exceeded',
)
}
if (!request.body)
throw new PlaybookPackageArchiveError(
'playbook_archive_invalid',
'body',
'is required',
)
const reader = request.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > maximumBytes) {
await reader.cancel()
throw new PlaybookPackageArchiveError(
'playbook_archive_limit',
'body',
'request body limit exceeded',
)
}
chunks.push(value)
}
const body = new Uint8Array(size)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
return body
}
function plainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function invalidEditorBody(message: string, path: string): never {
throw new ContentValidationError('Invalid editor package', [
{ path, code: 'editor_request_invalid', message, remediation: message },
])
}
function base64Bytes(value: string, path: string): Uint8Array {
if (
value.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
value,
)
) {
invalidEditorBody('Binary file content must be canonical base64.', path)
}
const decoded = Buffer.from(value, 'base64')
if (decoded.toString('base64') !== value) {
invalidEditorBody('Binary file content must be canonical base64.', path)
}
return new Uint8Array(decoded)
}
async function parseEditorFiles(
request: Request,
): Promise<readonly PlaybookPackageFileRecord[]> {
const raw = await readBoundedBody(request, 10 * 1024 * 1024)
let parsed: unknown
try {
parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw))
} catch {
invalidEditorBody('Request body must be valid UTF-8 JSON.', '/')
}
if (!plainObject(parsed) || Object.keys(parsed).join(',') !== 'files') {
invalidEditorBody('Request must contain only a files array.', '/')
}
if (!Array.isArray(parsed.files) || parsed.files.length > 201) {
invalidEditorBody('files must contain at most 201 entries.', '/files')
}
return parsed.files.map((candidate, index) => {
const path = `/files/${index}`
if (!plainObject(candidate))
invalidEditorBody('File must be an object.', path)
const keys = Object.keys(candidate).sort().join(',')
if (keys !== 'content,encoding,path,role') {
invalidEditorBody(
'File must contain exactly path, role, encoding and content.',
path,
)
}
if (
typeof candidate.path !== 'string' ||
typeof candidate.role !== 'string' ||
typeof candidate.content !== 'string' ||
(candidate.encoding !== 'utf8' && candidate.encoding !== 'base64')
) {
invalidEditorBody('File fields have invalid types.', path)
}
return {
path: candidate.path,
role: candidate.role,
content:
candidate.encoding === 'base64'
? base64Bytes(candidate.content, `${path}/content`)
: candidate.content,
}
})
}
function encodedContent(content: Uint8Array): {
readonly encoding: 'utf8' | 'base64'
readonly content: string
} {
try {
return {
encoding: 'utf8',
content: new TextDecoder('utf-8', { fatal: true }).decode(content),
}
} catch {
return {
encoding: 'base64',
content: Buffer.from(content).toString('base64'),
}
}
}
function detail(draft: PrivatePlaybookDraft) {
return {
playbookId: draft.playbookId,
versionId: draft.versionId,
logicalId: draft.logicalId,
slug: draft.slug,
semanticVersion: draft.semanticVersion,
title: draft.title,
summary: draft.summary,
lifecycle: draft.lifecycle,
riskTier: draft.riskTier,
category: draft.category,
packageApiVersion: draft.packageApiVersion,
draftRevision: draft.draftRevision,
draftDigest: draft.draftDigest,
publishedAt: draft.publishedAt,
updatedAt: draft.updatedAt,
templateText: draft.templateText,
files: draft.files.map((file) => ({
path: file.path,
role: file.role,
mediaType: file.mediaType,
sizeBytes: file.sizeBytes,
sha256: file.sha256,
digest: file.digest,
exportByDefault: file.exportByDefault,
...encodedContent(file.content),
})),
}
}
export async function handleListPrivatePlaybooks(
request: Request,
dependencies: PrivatePlaybookRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
const actor = await dependencies.resolveContext(request)
return Response.json(
{ items: await dependencies.service.list(actor) },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (error) {
return mappedError(error, requestId)
}
}
export async function handleGetPrivatePlaybook(
request: Request,
versionId: string,
dependencies: PrivatePlaybookRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
assertVersionId(versionId)
const actor = await dependencies.resolveContext(request)
const result = await dependencies.service.get(actor, versionId)
return Response.json(detail(result.draft), {
headers: { ETag: result.etag, 'Cache-Control': 'no-store' },
})
} catch (error) {
return mappedError(error, requestId)
}
}
export function handleUpdatePrivatePlaybook(
request: Request,
versionId: string,
dependencies: PrivatePlaybookRouteDependencies,
) {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
try {
assertVersionId(versionId)
const expectedEtag = sameOriginRequest.headers.get('if-match')
if (expectedEtag === null)
return response(
428,
'private_playbook_precondition_required',
'If-Match is required',
requestId,
)
const actor = await dependencies.resolveContext(sameOriginRequest)
const contentType =
sameOriginRequest.headers
.get('content-type')
?.split(';', 1)[0]
?.trim()
.toLowerCase() ?? ''
const result =
contentType === 'application/json'
? await dependencies.service.updateFiles(
actor,
versionId,
expectedEtag,
await parseEditorFiles(sameOriginRequest),
)
: await dependencies.service.update(
actor,
versionId,
expectedEtag,
await readPlaybookArchiveBody(
sameOriginRequest,
11 * 1024 * 1024,
),
)
return Response.json(detail(result.draft), {
headers: {
ETag: result.etag,
...('archiveSha256' in result
? { 'X-DevRunbook-Archive-SHA256': result.archiveSha256 }
: { 'X-DevRunbook-Package-Digest': result.packageDigest }),
'Cache-Control': 'no-store',
},
})
} catch (error) {
return mappedError(error, requestId)
}
},
dependencies.publicBaseUrl,
() => response(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
export async function handleExportPrivatePlaybook(
request: Request,
versionId: string,
dependencies: PrivatePlaybookRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
assertVersionId(versionId)
const actor = await dependencies.resolveContext(request)
const result = await dependencies.service.export(actor, versionId)
return new Response(result.bytes.slice().buffer, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="playbook-${versionId}.zip"`,
'Content-Length': String(result.bytes.byteLength),
'X-Content-Type-Options': 'nosniff',
'X-DevRunbook-Archive-SHA256': result.sha256,
'Cache-Control': 'no-store',
},
})
} catch (error) {
return mappedError(error, requestId)
}
}
@@ -0,0 +1,180 @@
import { describe, expect, it, vi } from 'vitest'
import {
handleCreateNextPrivatePlaybookVersion,
handlePublishPrivatePlaybook,
handleReviewPrivatePlaybook,
type PrivatePlaybookQualityRouteDependencies,
} from './private-playbook-quality-http'
const publicBaseUrl = 'https://devrunbook.example'
const versionId = '00000000-0000-4000-8000-000000000003'
const playbookId = '00000000-0000-4000-8000-000000000004'
const digest = 'a'.repeat(64)
const etag = `"playbook-draft:1:${digest}"`
const actor = {
userId: '00000000-0000-4000-8000-000000000001',
instanceRole: 'user' as const,
workspaceId: '00000000-0000-4000-8000-000000000002',
workspaceRole: 'editor' as const,
}
function dependencies(): PrivatePlaybookQualityRouteDependencies {
return {
publicBaseUrl,
resolveContext: vi.fn(async () => actor),
service: {
review: vi.fn(async () => ({
digest,
recorded: true,
lint: { exportReadiness: 'ready', findings: [] },
})),
publishByIdentity: vi.fn(async () => ({
version: {
playbookId,
versionId,
slug: 'private-example',
semanticVersion: '1.0.0',
title: 'Private example',
lifecycle: 'reviewed',
draftRevision: 1,
draftDigest: digest,
publishedAt: '2026-07-27T12:00:00.000Z',
updatedAt: '2026-07-27T12:00:00.000Z',
},
etag,
})),
nextVersion: vi.fn(async (_actor, _sourceVersionId, semanticVersion) => ({
draft: { versionId, semanticVersion, slug: 'private-example' },
etag,
})),
},
}
}
function post(path: string, body: unknown, headers = {}) {
return new Request(`${publicBaseUrl}${path}`, {
method: 'POST',
headers: {
origin: publicBaseUrl,
'content-type': 'application/json',
'if-match': etag,
...headers,
},
body: JSON.stringify(body),
})
}
describe('private playbook review and publication HTTP boundaries', () => {
it('records an exact-digest editorial review', async () => {
const deps = dependencies()
const response = await handleReviewPrivatePlaybook(
post(`/api/v1/private-playbooks/${versionId}/review`, {
limitationsDocumented: true,
unresolvedSafetyRegression: false,
note: 'Reviewed safety and limitations.',
}),
versionId,
deps,
)
expect(response.status).toBe(200)
expect(deps.service.review).toHaveBeenCalledWith(
actor,
versionId,
etag,
expect.objectContaining({ limitationsDocumented: true }),
)
})
it('publishes by immutable identity and returns its ETag', async () => {
const deps = dependencies()
const response = await handlePublishPrivatePlaybook(
post(`/api/v1/playbooks/${playbookId}/versions/1.0.0/publish`, {
lifecycle: 'reviewed',
}),
playbookId,
'1.0.0',
deps,
)
expect(response.status).toBe(200)
expect(response.headers.get('etag')).toBe(etag)
expect(deps.service.publishByIdentity).toHaveBeenCalledWith(
actor,
playbookId,
'1.0.0',
etag,
'reviewed',
)
})
it('creates a new mutable version from an immutable published version', async () => {
const deps = dependencies()
const response = await handleCreateNextPrivatePlaybookVersion(
post(`/api/v1/private-playbooks/${versionId}/versions`, {
semanticVersion: '1.1.0',
}),
versionId,
deps,
)
expect(response.status).toBe(201)
expect(response.headers.get('location')).toBe(
`/api/v1/private-playbooks/${versionId}`,
)
expect(deps.service.nextVersion).toHaveBeenCalledWith(
actor,
versionId,
'1.1.0',
)
})
it('rejects missing preconditions, excess fields and stale evidence safely', async () => {
const missing = await handleReviewPrivatePlaybook(
post(
`/api/v1/private-playbooks/${versionId}/review`,
{
limitationsDocumented: true,
unresolvedSafetyRegression: false,
note: 'Reviewed.',
},
{ 'if-match': '' },
),
versionId,
dependencies(),
)
expect(missing.status).toBe(422)
const invalid = await handlePublishPrivatePlaybook(
post(`/api/v1/playbooks/${playbookId}/versions/1.0.0/publish`, {
lifecycle: 'reviewed',
evidence: 'client-claim',
}),
playbookId,
'1.0.0',
dependencies(),
)
expect(invalid.status).toBe(422)
const deps = dependencies()
deps.service.publishByIdentity = vi.fn(async () => {
throw Object.assign(new Error('insufficient'), {
code: 'private_playbook_quality_evidence_insufficient',
details: { requirements: [{ id: 'blocking-lint', satisfied: false }] },
})
})
const blocked = await handlePublishPrivatePlaybook(
post(`/api/v1/playbooks/${playbookId}/versions/1.0.0/publish`, {
lifecycle: 'reviewed',
}),
playbookId,
'1.0.0',
deps,
)
expect(blocked.status).toBe(422)
await expect(blocked.json()).resolves.toMatchObject({
error: {
code: 'private_playbook_quality_evidence_insufficient',
details: { requirements: [{ id: 'blocking-lint' }] },
},
})
})
})
@@ -0,0 +1,389 @@
import type { ActorContext } from '@devrunbook/application'
import { handleAuthRequest } from '../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
const semverPattern =
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u
export interface PrivatePlaybookQualityHttpService {
review(
actor: ActorContext,
versionId: string,
expectedEtag: string,
input: {
readonly limitationsDocumented: boolean
readonly unresolvedSafetyRegression: boolean
readonly note: string
},
): Promise<unknown>
publishByIdentity(
actor: ActorContext,
playbookId: string,
semanticVersion: string,
expectedEtag: string,
lifecycle: 'reviewed' | 'validated' | 'deprecated',
): Promise<{
readonly version: {
readonly playbookId: string
readonly versionId: string
readonly slug: string
readonly semanticVersion: string
readonly title: string
readonly lifecycle: string
readonly draftRevision: number
readonly draftDigest: string
readonly publishedAt: string | null
readonly updatedAt: string
}
readonly etag: string
}>
nextVersion(
actor: ActorContext,
sourceVersionId: string,
semanticVersion: string,
): Promise<{
readonly draft: {
readonly versionId: string
readonly semanticVersion: string
readonly slug: string
}
readonly etag: string
}>
}
export interface PrivatePlaybookQualityRouteDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly service: PrivatePlaybookQualityHttpService
}
function errorResponse(
status: number,
code: string,
message: string,
requestId: string,
details?: unknown,
) {
return Response.json(
{
error: {
code,
message,
requestId,
...(details === undefined ? {} : { details }),
},
},
{ status, headers: { 'Cache-Control': 'no-store' } },
)
}
function plainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
async function jsonBody(request: Request): Promise<Record<string, unknown>> {
if (
request.headers.get('content-type')?.split(';', 1)[0]?.trim() !==
'application/json'
) {
throw Object.assign(new Error('Content-Type must be application/json'), {
code: 'private_playbook_request_invalid',
})
}
const declared = Number(request.headers.get('content-length') ?? '0')
if (Number.isFinite(declared) && declared > 16_384) {
throw Object.assign(new Error('Request body is too large'), {
code: 'private_playbook_request_too_large',
})
}
if (!request.body)
throw Object.assign(new Error('Request body is required'), {
code: 'private_playbook_request_invalid',
})
const reader = request.body.getReader()
const chunks: Uint8Array[] = []
let length = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
length += value.byteLength
if (length > 16_384) {
await reader.cancel()
throw Object.assign(new Error('Request body is too large'), {
code: 'private_playbook_request_too_large',
})
}
chunks.push(value)
}
const body = new Uint8Array(length)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
let parsed: unknown
try {
parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body))
} catch {
throw Object.assign(new Error('Request body must be valid JSON'), {
code: 'private_playbook_request_invalid',
})
}
if (!plainObject(parsed)) {
throw Object.assign(new Error('Request body must be an object'), {
code: 'private_playbook_request_invalid',
})
}
return parsed
}
function applicationError(error: unknown) {
return error !== null && typeof error === 'object' && 'code' in error
? {
code: String(error.code),
details:
'details' in error && plainObject(error.details)
? error.details
: undefined,
}
: null
}
function mappedError(error: unknown, requestId: string): Response {
if (error instanceof AuthenticatedWorkspaceContextError) {
return errorResponse(
error.code === 'authentication_required' ? 401 : 403,
error.code,
error.code === 'authentication_required'
? 'Authentication required'
: 'Access denied',
requestId,
)
}
const application = applicationError(error)
if (application?.code === 'workspace_access_denied')
return errorResponse(403, application.code, 'Access denied', requestId)
if (application?.code === 'private_playbook_not_found')
return errorResponse(
404,
application.code,
'Private playbook not found',
requestId,
)
if (application?.code === 'private_playbook_request_too_large')
return errorResponse(
413,
application.code,
'Request body is too large',
requestId,
)
if (
application?.code === 'private_playbook_quality_conflict' ||
application?.code === 'private_playbook_publish_conflict' ||
application?.code === 'private_playbook_published_immutable'
) {
return errorResponse(
409,
application.code,
'The draft changed; reload and review before continuing',
requestId,
application.details,
)
}
if (
application?.code === 'private_playbook_quality_evidence_insufficient' ||
application?.code === 'private_playbook_changelog_required' ||
application?.code === 'private_playbook_review_invalid' ||
application?.code === 'private_playbook_version_invalid' ||
application?.code === 'private_playbook_request_invalid' ||
application?.code === 'private_playbook_etag_invalid'
) {
return errorResponse(
422,
application.code,
'Private playbook evidence or request is invalid',
requestId,
application.details,
)
}
return errorResponse(
503,
'private_playbook_quality_unavailable',
'Private playbook quality service is temporarily unavailable',
requestId,
)
}
export function handleCreateNextPrivatePlaybookVersion(
request: Request,
sourceVersionId: string,
dependencies: PrivatePlaybookQualityRouteDependencies,
) {
return mutation(request, dependencies, async (safeRequest, actor) => {
if (!uuidPattern.test(sourceVersionId))
throw Object.assign(new Error('Not found'), {
code: 'private_playbook_not_found',
})
const body = await jsonBody(safeRequest)
if (
Object.keys(body).join(',') !== 'semanticVersion' ||
typeof body.semanticVersion !== 'string'
) {
throw Object.assign(new Error('Version request is invalid'), {
code: 'private_playbook_request_invalid',
})
}
const result = await dependencies.service.nextVersion(
actor,
sourceVersionId,
body.semanticVersion,
)
return Response.json(
{
versionId: result.draft.versionId,
semanticVersion: result.draft.semanticVersion,
slug: result.draft.slug,
},
{
status: 201,
headers: {
ETag: result.etag,
Location: `/api/v1/private-playbooks/${encodeURIComponent(result.draft.versionId)}`,
'Cache-Control': 'no-store',
},
},
)
})
}
function mutation(
request: Request,
dependencies: PrivatePlaybookQualityRouteDependencies,
operation: (request: Request, actor: ActorContext) => Promise<Response>,
) {
const requestId = crypto.randomUUID()
return handleAuthRequest(
request,
async (sameOriginRequest) => {
try {
if (
sameOriginRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return errorResponse(
403,
'invalid_origin',
'Invalid request origin',
requestId,
)
}
const actor = await dependencies.resolveContext(sameOriginRequest)
return await operation(sameOriginRequest, actor)
} catch (error) {
return mappedError(error, requestId)
}
},
dependencies.publicBaseUrl,
() =>
errorResponse(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
function expectedEtag(request: Request): string {
const value = request.headers.get('if-match')
if (!value)
throw Object.assign(new Error('If-Match is required'), {
code: 'private_playbook_etag_invalid',
})
return value
}
export function handleReviewPrivatePlaybook(
request: Request,
versionId: string,
dependencies: PrivatePlaybookQualityRouteDependencies,
) {
return mutation(request, dependencies, async (safeRequest, actor) => {
if (!uuidPattern.test(versionId))
throw Object.assign(new Error('Not found'), {
code: 'private_playbook_not_found',
})
const body = await jsonBody(safeRequest)
if (
Object.keys(body).sort().join(',') !==
'limitationsDocumented,note,unresolvedSafetyRegression' ||
typeof body.limitationsDocumented !== 'boolean' ||
typeof body.unresolvedSafetyRegression !== 'boolean' ||
typeof body.note !== 'string'
) {
throw Object.assign(new Error('Review request is invalid'), {
code: 'private_playbook_request_invalid',
})
}
const result = await dependencies.service.review(
actor,
versionId,
expectedEtag(safeRequest),
{
limitationsDocumented: body.limitationsDocumented,
unresolvedSafetyRegression: body.unresolvedSafetyRegression,
note: body.note,
},
)
return Response.json(result, {
headers: { 'Cache-Control': 'no-store' },
})
})
}
export function handlePublishPrivatePlaybook(
request: Request,
playbookId: string,
semanticVersion: string,
dependencies: PrivatePlaybookQualityRouteDependencies,
) {
return mutation(request, dependencies, async (safeRequest, actor) => {
if (!uuidPattern.test(playbookId) || !semverPattern.test(semanticVersion))
throw Object.assign(new Error('Not found'), {
code: 'private_playbook_not_found',
})
const body = await jsonBody(safeRequest)
if (
Object.keys(body).join(',') !== 'lifecycle' ||
(body.lifecycle !== 'reviewed' &&
body.lifecycle !== 'validated' &&
body.lifecycle !== 'deprecated')
) {
throw Object.assign(new Error('Publication request is invalid'), {
code: 'private_playbook_request_invalid',
})
}
const result = await dependencies.service.publishByIdentity(
actor,
playbookId,
semanticVersion,
expectedEtag(safeRequest),
body.lifecycle,
)
return Response.json(
{
playbookId: result.version.playbookId,
versionId: result.version.versionId,
slug: result.version.slug,
semanticVersion: result.version.semanticVersion,
title: result.version.title,
lifecycle: result.version.lifecycle,
draftRevision: result.version.draftRevision,
draftDigest: result.version.draftDigest,
publishedAt: result.version.publishedAt,
updatedAt: result.version.updatedAt,
},
{
headers: { ETag: result.etag, 'Cache-Control': 'no-store' },
},
)
})
}
@@ -0,0 +1,14 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../server/authenticated-workspace-context'
import { getPrivatePlaybookServer } from '../../../../server/private-playbooks'
import type { PrivatePlaybookQualityRouteDependencies } from './private-playbook-quality-http'
export function privatePlaybookQualityRouteDependencies(): PrivatePlaybookQualityRouteDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
service: getPrivatePlaybookServer(),
}
}
@@ -0,0 +1,14 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../server/authenticated-workspace-context'
import { getPrivatePlaybookServer } from '../../../../server/private-playbooks'
import type { PrivatePlaybookRouteDependencies } from './private-playbook-http'
export function privatePlaybookRouteDependencies(): PrivatePlaybookRouteDependencies {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
service: getPrivatePlaybookServer(),
}
}
@@ -0,0 +1,8 @@
import { handleListPrivatePlaybooks } from './private-playbook-http'
import { privatePlaybookRouteDependencies } from './private-playbook-route-dependencies'
export const dynamic = 'force-dynamic'
export function GET(request: Request) {
return handleListPrivatePlaybooks(request, privatePlaybookRouteDependencies())
}
@@ -0,0 +1,65 @@
import { simpleFlowEvents } from '@devrunbook/application'
import { z } from 'zod'
import { handleAuthRequest } from '../../../../../auth/csrf'
import { resolveAuthenticatedWorkspaceContext } from '../../../../../server/authenticated-workspace-context'
import { getProductMetricServer } from '../../../../../server/product-metrics'
export const dynamic = 'force-dynamic'
const requestSchema = z.strictObject({
event: z.enum(simpleFlowEvents),
taskSlug: z.string().max(100).optional(),
durationMs: z.number().int().min(0).max(86_400_000).optional(),
})
export async function POST(request: Request) {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return handleAuthRequest(
request,
async () => {
try {
const actor = await resolveAuthenticatedWorkspaceContext(request)
const parsed = requestSchema.parse(await request.json())
await getProductMetricServer().record(actor, {
event: parsed.event,
...(parsed.taskSlug === undefined
? {}
: { taskSlug: parsed.taskSlug }),
...(parsed.durationMs === undefined
? {}
: { durationMs: parsed.durationMs }),
})
return new Response(null, { status: 204 })
} catch (error) {
const code =
error !== null && typeof error === 'object' && 'code' in error
? error.code
: undefined
return Response.json(
{
error: {
code:
code === 'authentication_required'
? 'authentication_required'
: code === 'workspace_access_denied'
? 'workspace_access_denied'
: 'product_metric_invalid',
message: 'Product metric was not accepted',
},
},
{
status:
code === 'authentication_required'
? 401
: code === 'workspace_access_denied'
? 403
: 422,
},
)
}
},
publicBaseUrl,
)
}
@@ -0,0 +1,17 @@
import { handleExportRepositoryProfile } from '../../../repository-http'
import { repositoryRouteDependencies } from '../../../repository-route-dependencies'
export const dynamic = 'force-dynamic'
export function GET(
request: Request,
context: { params: Promise<{ repositoryId: string }> },
) {
return context.params.then(({ repositoryId }) =>
handleExportRepositoryProfile(
request,
repositoryId,
repositoryRouteDependencies(),
),
)
}
@@ -0,0 +1,33 @@
import {
handleGetRepositoryProfile,
handlePutRepositoryProfile,
} from '../../repository-http'
import { repositoryRouteDependencies } from '../../repository-route-dependencies'
export const dynamic = 'force-dynamic'
export function GET(
request: Request,
context: { params: Promise<{ repositoryId: string }> },
) {
return context.params.then(({ repositoryId }) =>
handleGetRepositoryProfile(
request,
repositoryId,
repositoryRouteDependencies(),
),
)
}
export function PUT(
request: Request,
context: { params: Promise<{ repositoryId: string }> },
) {
return context.params.then(({ repositoryId }) =>
handlePutRepositoryProfile(
request,
repositoryId,
repositoryRouteDependencies(),
),
)
}
@@ -0,0 +1,19 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../../../server/authenticated-workspace-context'
import { getGiteaIntegrationServer } from '../../../../../../server/gitea-integrations'
import { handleRepositoryRefresh } from '../../repository-refresh-http'
export const dynamic = 'force-dynamic'
export async function POST(
request: Request,
context: { readonly params: Promise<{ repositoryId: string }> },
) {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
const { repositoryId } = await context.params
return handleRepositoryRefresh(request, repositoryId, {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
service: getGiteaIntegrationServer(),
})
}
@@ -0,0 +1,13 @@
import { handleGetRepository } from '../repository-http'
import { repositoryRouteDependencies } from '../repository-route-dependencies'
export const dynamic = 'force-dynamic'
export function GET(
request: Request,
context: { params: Promise<{ repositoryId: string }> },
) {
return context.params.then(({ repositoryId }) =>
handleGetRepository(request, repositoryId, repositoryRouteDependencies()),
)
}
@@ -0,0 +1,15 @@
import { resolveAuthenticatedWorkspaceContext } from '../../../../../server/authenticated-workspace-context'
import { getGiteaIntegrationServer } from '../../../../../server/gitea-integrations'
import { handleRepositoryRefresh } from '../repository-refresh-http'
export const dynamic = 'force-dynamic'
export function POST(request: Request) {
const publicBaseUrl = process.env.PUBLIC_BASE_URL
if (!publicBaseUrl) throw new Error('PUBLIC_BASE_URL is required')
return handleRepositoryRefresh(request, null, {
publicBaseUrl,
resolveContext: resolveAuthenticatedWorkspaceContext,
service: getGiteaIntegrationServer(),
})
}
@@ -0,0 +1,413 @@
import type {
ActorContext,
RepositoryProfileRevisionResult,
RepositorySummary,
} from '@devrunbook/application'
import { describe, expect, it, vi } from 'vitest'
import {
handleCreateRepository,
handleExportRepositoryProfile,
handleGetRepository,
handleGetRepositoryProfile,
handleListRepositories,
handlePutRepositoryProfile,
type RepositoryHttpService,
type RepositoryRouteDependencies,
} from './repository-http'
const repositoryId = '00000000-0000-4000-8000-000000000003'
const digest = 'a'.repeat(64)
const etag = `"profile:1:${digest}"`
const actor: ActorContext = {
userId: '00000000-0000-4000-8000-000000000001',
instanceRole: 'user',
workspaceId: '00000000-0000-4000-8000-000000000002',
workspaceRole: 'owner',
}
const profile = {
apiVersion: 'devrunbook.io/v1alpha1',
kind: 'RepositoryProfile',
metadata: {
name: 'Example repository',
revision: 1,
source: 'manual',
contentDigest: digest,
},
spec: {
repositoryType: 'single-app',
stack: {
languages: [],
frameworks: [],
packageManagers: [],
databases: [],
deploymentTypes: [],
testFrameworks: [],
},
commands: [],
paths: {
applicationRoots: ['src'],
testRoots: ['test'],
documentationRoots: ['docs'],
generated: ['dist'],
protected: ['.github'],
excluded: ['node_modules'],
},
policies: {
preserveBackwardCompatibility: true,
newDependencies: 'justify',
gitWrite: 'none',
migrations: 'plan-only',
documentationRequired: true,
networkAccess: 'forbidden',
productionDataAccess: 'forbidden',
},
},
} as const
const repository: RepositorySummary = {
id: repositoryId,
displayName: 'Example repository',
sourceType: 'manual',
defaultBranch: null,
archived: false,
currentProfileRevision: 1,
lastSnapshotAt: null,
createdAt: '2026-07-27T00:00:00.000Z',
updatedAt: '2026-07-27T00:00:00.000Z',
}
const revision = {
id: '00000000-0000-4000-8000-000000000004',
repositoryId,
revisionNumber: 1,
profile,
contentDigest: digest,
createdBy: actor.userId,
createdAt: '2026-07-27T00:00:00.000Z',
} as RepositoryProfileRevisionResult['revision']
function service(): RepositoryHttpService {
return {
list: vi.fn(async () => ({ items: [repository], nextCursor: null })),
get: vi.fn(async () => repository),
create: vi.fn(async () => ({ repository, revision, etag })),
getProfile: vi.fn(async () => ({ revision, etag })),
putProfile: vi.fn(async () => ({ revision, etag, created: true })),
exportProfile: vi.fn(async (_actor, _id, format) => {
const contentType: 'application/json' | 'application/yaml' =
format === 'json' ? 'application/json' : 'application/yaml'
return {
contentType,
body: format === 'json' ? '{}\n' : 'kind: RepositoryProfile\n',
etag,
}
}),
}
}
function dependencies(
overrides: Partial<RepositoryRouteDependencies> = {},
): RepositoryRouteDependencies {
return {
publicBaseUrl: 'https://devrunbook.example',
resolveContext: vi.fn(async () => actor),
service: service(),
...overrides,
}
}
function mutationRequest(
path: string,
method: 'POST' | 'PUT',
body: string | ArrayBuffer,
headers: Record<string, string> = {},
) {
return new Request(`https://devrunbook.example${path}`, {
method,
body,
headers: {
origin: 'https://devrunbook.example',
'content-type': 'application/json',
...headers,
},
})
}
async function error(response: Response) {
return (await response.json()) as {
error: { code: string; requestId: string; details?: unknown[] }
}
}
describe('repository HTTP boundary', () => {
it('authenticates before parsing a strict bounded list query', async () => {
const deps = dependencies()
const response = await handleListRepositories(
new Request(
'https://devrunbook.example/api/v1/repositories?limit=25&source=manual&archived=false&q=Example',
),
deps,
)
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(deps.resolveContext).toHaveBeenCalledOnce()
expect(deps.service.list).toHaveBeenCalledWith(actor, {
limit: 25,
source: 'manual',
archived: false,
q: 'Example',
})
const invalid = await handleListRepositories(
new Request(
'https://devrunbook.example/api/v1/repositories?limit=1&limit=2',
),
deps,
)
expect(invalid.status).toBe(422)
expect((await error(invalid)).error.code).toBe('repository_query_invalid')
})
it('returns the exact create envelope and rejects duplicate JSON keys', async () => {
const deps = dependencies()
const response = await handleCreateRepository(
mutationRequest(
'/api/v1/repositories',
'POST',
JSON.stringify({
displayName: 'Example',
initialProfile: {
...profile,
metadata: { ...profile.metadata, source: 'gitea' },
},
}),
),
deps,
)
expect(response.status).toBe(201)
expect(response.headers.get('etag')).toBe(etag)
expect(deps.service.create).toHaveBeenCalledWith(
actor,
'Example',
expect.objectContaining({
metadata: expect.objectContaining({ source: 'manual' }),
}),
)
expect(await response.json()).toEqual({
repository,
currentProfile: {
repositoryId,
revision: 1,
contentDigest: digest,
createdAt: revision.createdAt,
profile,
},
})
const duplicate = await handleCreateRepository(
mutationRequest(
'/api/v1/repositories',
'POST',
'{"displayName":"Example","initialProfile":{"kind":"one","kind":"two"}}',
),
deps,
)
expect(duplicate.status).toBe(422)
expect((await error(duplicate)).error.code).toBe(
'repository_profile_invalid',
)
})
it('imports raw JSON and YAML profiles atomically with imported provenance', async () => {
const jsonDependencies = dependencies()
const rawJson = {
...profile,
metadata: { ...profile.metadata, name: 'JSON import', source: 'manual' },
}
const jsonResponse = await handleCreateRepository(
mutationRequest('/api/v1/repositories', 'POST', JSON.stringify(rawJson)),
jsonDependencies,
)
expect(jsonResponse.status).toBe(201)
expect(jsonDependencies.service.create).toHaveBeenCalledWith(
actor,
'JSON import',
expect.objectContaining({
metadata: expect.objectContaining({ source: 'imported' }),
}),
)
const yamlDependencies = dependencies()
const yamlResponse = await handleCreateRepository(
mutationRequest(
'/api/v1/repositories',
'POST',
[
'apiVersion: devrunbook.io/v1alpha1',
'kind: RepositoryProfile',
'metadata:',
' name: YAML import',
' revision: 1',
' source: manual',
'spec: {}',
'',
].join('\n'),
{ 'content-type': 'application/yaml' },
),
yamlDependencies,
)
expect(yamlResponse.status).toBe(201)
expect(yamlDependencies.service.create).toHaveBeenCalledWith(
actor,
'YAML import',
expect.objectContaining({
metadata: expect.objectContaining({ source: 'imported' }),
}),
)
})
it('requires an exact same-origin header for mutations', async () => {
const request = mutationRequest('/api/v1/repositories', 'POST', '{}', {
origin: 'https://attacker.example',
})
const response = await handleCreateRepository(request, dependencies())
expect(response.status).toBe(403)
expect((await error(response)).error.code).toBe('invalid_origin')
})
it('returns indistinguishable not-found responses for invalid and absent ids', async () => {
const invalid = await handleGetRepository(
new Request('https://devrunbook.example/api/v1/repositories/not-a-uuid'),
'not-a-uuid',
dependencies(),
)
const absentService = service()
absentService.get = vi.fn(async () => {
throw {
code: 'repository_not_found',
message: 'Repository not found',
}
})
const absent = await handleGetRepository(
new Request(
`https://devrunbook.example/api/v1/repositories/${repositoryId}`,
),
repositoryId,
dependencies({ service: absentService }),
)
expect(invalid.status).toBe(404)
expect(absent.status).toBe(404)
expect((await error(invalid)).error.code).toBe('repository_not_found')
expect((await error(absent)).error.code).toBe('repository_not_found')
})
it('serves an immutable profile envelope with its strong ETag', async () => {
const response = await handleGetRepositoryProfile(
new Request(
`https://devrunbook.example/api/v1/repositories/${repositoryId}/profile`,
),
repositoryId,
dependencies(),
)
expect(response.status).toBe(200)
expect(response.headers.get('etag')).toBe(etag)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(await response.json()).toMatchObject({ repositoryId, revision: 1 })
})
it('enforces If-Match before profile parsing and reports safe conflicts', async () => {
const missing = await handlePutRepositoryProfile(
mutationRequest(
`/api/v1/repositories/${repositoryId}/profile`,
'PUT',
JSON.stringify(profile),
),
repositoryId,
dependencies(),
)
expect(missing.status).toBe(428)
const conflictService = service()
const conflictError = Object.assign(new Error('sensitive internal text'), {
code: 'repository_profile_conflict',
details: {
currentRevision: 2,
currentEtag: `"profile:2:${'b'.repeat(64)}"`,
recovery: 'reload-and-review',
secret: 'not exposed',
},
})
conflictService.putProfile = vi.fn(async () => {
throw conflictError
})
const conflict = await handlePutRepositoryProfile(
mutationRequest(
`/api/v1/repositories/${repositoryId}/profile`,
'PUT',
JSON.stringify(profile),
{ 'if-match': etag },
),
repositoryId,
dependencies({ service: conflictService }),
)
expect(conflict.status).toBe(409)
const body = await conflict.text()
expect(body).toContain('reload-and-review')
expect(body).not.toContain('sensitive internal text')
expect(body).not.toContain('not exposed')
})
it('bounds streamed profile bodies to one MiB', async () => {
const response = await handlePutRepositoryProfile(
mutationRequest(
`/api/v1/repositories/${repositoryId}/profile`,
'PUT',
new Uint8Array(1_048_577).buffer,
{ 'if-match': etag },
),
repositoryId,
dependencies(),
)
expect(response.status).toBe(422)
expect((await error(response)).error.code).toBe(
'repository_profile_too_large',
)
})
it('exports deterministic JSON or YAML with fixed safe filenames', async () => {
const yaml = await handleExportRepositoryProfile(
new Request(
`https://devrunbook.example/api/v1/repositories/${repositoryId}/profile/export`,
),
repositoryId,
dependencies(),
)
expect(yaml.headers.get('content-disposition')).toBe(
'attachment; filename="repository-profile.yaml"',
)
expect(yaml.headers.get('etag')).toBe(etag)
const invalid = await handleExportRepositoryProfile(
new Request(
`https://devrunbook.example/api/v1/repositories/${repositoryId}/profile/export?format=xml`,
),
repositoryId,
dependencies(),
)
expect(invalid.status).toBe(422)
})
it('never reflects unknown service errors', async () => {
const unavailable = service()
unavailable.list = vi.fn(async () => {
throw new Error('postgres://user:secret@database/private')
})
const response = await handleListRepositories(
new Request('https://devrunbook.example/api/v1/repositories'),
dependencies({ service: unavailable }),
)
expect(response.status).toBe(503)
const body = await response.text()
expect(body).not.toContain('secret')
expect(JSON.parse(body).error.requestId).toMatch(/^[0-9a-f-]{36}$/u)
})
})
@@ -0,0 +1,741 @@
import type {
ActorContext,
AppendRepositoryProfileRevisionResult,
RepositoryListQuery,
RepositoryPage,
RepositoryProfileRevisionResult,
RepositorySummary,
} from '@devrunbook/application'
import {
parseRepositoryProfile,
RepositoryProfileImportError,
} from '@devrunbook/repository-intel'
import { handleAuthRequest } from '../../../../auth/csrf'
import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context'
const maximumProfileBytes = 1_048_576
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
const listQueryKeys = new Set(['cursor', 'limit', 'source', 'archived', 'q'])
export interface RepositoryHttpService {
list(actor: ActorContext, query: RepositoryListQuery): Promise<RepositoryPage>
get(actor: ActorContext, repositoryId: string): Promise<RepositorySummary>
create(
actor: ActorContext,
displayName: string,
profileDraft: unknown,
): Promise<{
readonly repository: RepositorySummary
readonly revision: RepositoryProfileRevisionResult['revision']
readonly etag: string
}>
getProfile(
actor: ActorContext,
repositoryId: string,
): Promise<RepositoryProfileRevisionResult>
putProfile(
actor: ActorContext,
repositoryId: string,
expectedEtag: string,
profileDraft: unknown,
): Promise<AppendRepositoryProfileRevisionResult>
exportProfile(
actor: ActorContext,
repositoryId: string,
format: 'json' | 'yaml',
): Promise<{
readonly contentType: 'application/json' | 'application/yaml'
readonly body: string
readonly etag: string
}>
}
export interface RepositoryRouteDependencies {
readonly publicBaseUrl: string
readonly resolveContext: (request: Request) => Promise<ActorContext>
readonly service: RepositoryHttpService
}
interface SafeDetail {
readonly path: string
readonly rule: string
readonly message: string
readonly remediation?: string
readonly currentEtag?: string
readonly currentRevision?: number
readonly recovery?: string
}
class RepositoryHttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: readonly SafeDetail[],
) {
super(message)
}
}
function errorResponse(
status: number,
code: string,
message: string,
requestId: string,
details?: readonly SafeDetail[],
) {
return Response.json(
{
error: {
code,
message,
requestId,
...(details?.length ? { details } : {}),
},
},
{ status },
)
}
function validation(
code: string,
message: string,
path: string,
remediation?: string,
): never {
throw new RepositoryHttpError(422, code, message, [
{
path,
rule: code,
message,
...(remediation ? { remediation } : {}),
},
])
}
function mediaType(request: Request): string {
return (
request.headers
.get('content-type')
?.split(';', 1)[0]!
.trim()
.toLowerCase() ?? ''
)
}
async function readBoundedBody(
request: Request,
maximumBytes = maximumProfileBytes,
): Promise<Uint8Array> {
const declaredLength = request.headers.get('content-length')
if (
declaredLength !== null &&
(!/^\d+$/u.test(declaredLength) || Number(declaredLength) > maximumBytes)
) {
validation(
'repository_profile_too_large',
`Request body must not exceed ${maximumBytes} bytes`,
'/',
)
}
if (!request.body)
validation('request_body_required', 'Request body is required', '/')
const reader = request.body!.getReader()
const chunks: Uint8Array[] = []
let length = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
length += value.length
if (length > maximumBytes) {
await reader.cancel()
validation(
'repository_profile_too_large',
`Request body must not exceed ${maximumBytes} bytes`,
'/',
)
}
chunks.push(value)
}
const body = new Uint8Array(length)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.length
}
return body
}
function decodeUtf8(body: Uint8Array): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(body)
} catch {
validation('request_utf8_invalid', 'Request body must be valid UTF-8', '/')
}
}
function plainObject(value: unknown): value is Record<string, unknown> {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
)
}
function withProfileSource(
profileDraft: unknown,
source: 'manual' | 'imported',
): unknown {
if (!plainObject(profileDraft) || !plainObject(profileDraft.metadata)) {
return profileDraft
}
return {
...profileDraft,
metadata: { ...profileDraft.metadata, source },
}
}
async function parseCreateBody(request: Request): Promise<{
readonly displayName: string
readonly initialProfile: unknown
}> {
const type = mediaType(request)
const format =
type === 'application/json'
? 'json'
: type === 'application/yaml'
? 'yaml'
: null
if (!format) {
validation(
'repository_content_type_unsupported',
'Repository creation requires application/json or application/yaml',
'headers.content-type',
)
}
const text = decodeUtf8(await readBoundedBody(request))
const value = parseRepositoryProfile(text, format)
if (!plainObject(value)) {
validation(
'repository_request_invalid',
'Request body must be an object',
'/',
)
}
const keys = Object.keys(value).sort()
const isManualWrapper =
format === 'json' &&
keys.length === 2 &&
keys[0] === 'displayName' &&
keys[1] === 'initialProfile'
if (isManualWrapper) {
if (typeof value.displayName !== 'string') {
validation(
'repository_request_invalid',
'displayName must be a string',
'/displayName',
)
}
return {
displayName: value.displayName,
initialProfile: withProfileSource(value.initialProfile, 'manual'),
}
}
if (!plainObject(value.metadata) || typeof value.metadata.name !== 'string') {
validation(
'repository_request_invalid',
'Imported RepositoryProfile metadata.name must be a string',
'/metadata/name',
)
}
return {
displayName: value.metadata.name,
initialProfile: withProfileSource(value, 'imported'),
}
}
async function parseProfileBody(request: Request): Promise<unknown> {
const type = mediaType(request)
const format =
type === 'application/json'
? 'json'
: type === 'application/yaml'
? 'yaml'
: null
if (!format) {
validation(
'repository_content_type_unsupported',
'RepositoryProfile requires application/json or application/yaml',
'headers.content-type',
)
}
return parseRepositoryProfile(await readBoundedBody(request), format)
}
function oneValue(parameters: URLSearchParams, name: string): string | null {
const values = parameters.getAll(name)
if (values.length > 1) {
validation(
'repository_query_invalid',
`${name} must be supplied at most once`,
`query.${name}`,
)
}
return values[0] ?? null
}
function parseListQuery(url: string): RepositoryListQuery {
const parameters = new URL(url).searchParams
for (const key of parameters.keys()) {
if (!listQueryKeys.has(key)) {
validation(
'repository_query_invalid',
`Unsupported query parameter: ${key}`,
`query.${key}`,
)
}
}
const cursor = oneValue(parameters, 'cursor')
if (cursor !== null && (cursor.length < 1 || cursor.length > 500)) {
validation(
'repository_query_invalid',
'cursor must contain between 1 and 500 characters',
'query.cursor',
)
}
const q = oneValue(parameters, 'q')
if (q !== null && (q.trim().length < 1 || q.length > 200)) {
validation(
'repository_query_invalid',
'q must contain between 1 and 200 characters',
'query.q',
)
}
const limitValue = oneValue(parameters, 'limit')
const limit = limitValue === null ? 50 : Number(limitValue)
if (!/^\d+$/u.test(limitValue ?? '50') || limit < 1 || limit > 100) {
validation(
'repository_query_invalid',
'limit must be an integer between 1 and 100',
'query.limit',
)
}
const source = oneValue(parameters, 'source')
if (source !== null && source !== 'manual' && source !== 'gitea') {
validation(
'repository_query_invalid',
'source must be manual or gitea',
'query.source',
)
}
const archivedValue = oneValue(parameters, 'archived')
if (
archivedValue !== null &&
archivedValue !== 'true' &&
archivedValue !== 'false'
) {
validation(
'repository_query_invalid',
'archived must be true or false',
'query.archived',
)
}
return {
limit,
...(q !== null ? { q: q.trim() } : {}),
...(cursor !== null ? { cursor } : {}),
...(source !== null ? { source } : {}),
...(archivedValue !== null ? { archived: archivedValue === 'true' } : {}),
}
}
function parseExportFormat(url: string): 'json' | 'yaml' {
const parameters = new URL(url).searchParams
for (const key of parameters.keys()) {
if (key !== 'format') {
validation(
'repository_query_invalid',
`Unsupported query parameter: ${key}`,
`query.${key}`,
)
}
}
const format = oneValue(parameters, 'format') ?? 'yaml'
if (format !== 'json' && format !== 'yaml') {
validation(
'repository_query_invalid',
'format must be json or yaml',
'query.format',
)
}
return format
}
function assertRepositoryId(repositoryId: string): void {
if (!uuidPattern.test(repositoryId)) {
throw new RepositoryHttpError(
404,
'repository_not_found',
'Repository not found',
)
}
}
function profileEnvelope(result: RepositoryProfileRevisionResult) {
return {
repositoryId: result.revision.repositoryId,
revision: result.revision.revisionNumber,
contentDigest: result.revision.contentDigest,
createdAt: result.revision.createdAt,
profile: result.revision.profile,
}
}
function safeIssues(value: unknown): SafeDetail[] | undefined {
if (!Array.isArray(value)) return undefined
const issues = value.flatMap((issue): SafeDetail[] => {
if (!plainObject(issue)) return []
const path = typeof issue.path === 'string' ? issue.path : '/'
const code = typeof issue.code === 'string' ? issue.code : 'invalid'
const message =
typeof issue.message === 'string' ? issue.message : 'Value is invalid'
const remediation =
typeof issue.remediation === 'string' ? issue.remediation : undefined
return [
{
path,
rule: code,
message,
...(remediation ? { remediation } : {}),
},
]
})
return issues.length ? issues : undefined
}
function applicationError(caught: unknown): {
readonly code: string
readonly message: string
readonly details: Record<string, unknown>
} | null {
if (caught === null || typeof caught !== 'object') return null
const candidate = caught as Record<string, unknown>
if (
typeof candidate.code !== 'string' ||
typeof candidate.message !== 'string'
) {
return null
}
return {
code: candidate.code,
message: candidate.message,
details: plainObject(candidate.details) ? candidate.details : {},
}
}
function mappedError(caught: unknown, requestId: string): Response {
if (caught instanceof RepositoryHttpError) {
return errorResponse(
caught.status,
caught.code,
caught.message,
requestId,
caught.details,
)
}
if (caught instanceof RepositoryProfileImportError) {
return errorResponse(
422,
'repository_profile_invalid',
'Repository profile is invalid',
requestId,
safeIssues(caught.issues),
)
}
if (caught instanceof AuthenticatedWorkspaceContextError) {
return errorResponse(
caught.code === 'authentication_required' ? 401 : 403,
caught.code,
caught.code === 'authentication_required'
? 'Authentication required'
: 'Access denied',
requestId,
)
}
const application = applicationError(caught)
if (application) {
if (application.code === 'authentication_required') {
return errorResponse(
401,
application.code,
'Authentication required',
requestId,
)
}
if (application.code === 'workspace_access_denied') {
return errorResponse(403, application.code, 'Access denied', requestId)
}
if (application.code === 'repository_not_found') {
return errorResponse(
404,
application.code,
'Repository not found',
requestId,
)
}
if (application.code === 'repository_profile_conflict') {
const currentEtag =
typeof application.details.currentEtag === 'string'
? application.details.currentEtag
: undefined
const currentRevision =
typeof application.details.currentRevision === 'number'
? application.details.currentRevision
: undefined
const recovery =
application.details.recovery === 'reload-and-review'
? application.details.recovery
: undefined
return errorResponse(
409,
application.code,
'Repository profile changed; reload and review before saving',
requestId,
[
{
path: 'headers.if-match',
rule: 'etag-conflict',
message: 'The supplied profile ETag is stale',
...(currentEtag ? { currentEtag } : {}),
...(currentRevision ? { currentRevision } : {}),
...(recovery ? { recovery } : {}),
},
],
)
}
if (
application.code === 'repository_profile_invalid' ||
application.code === 'repository_input_invalid' ||
application.code === 'repository_profile_etag_invalid' ||
application.code === 'repository_cursor_invalid' ||
application.code === 'repository_list_limit_invalid'
) {
return errorResponse(
422,
application.code,
application.message,
requestId,
safeIssues(application.details.issues),
)
}
}
return errorResponse(
503,
'repository_service_unavailable',
'Repository service is temporarily unavailable',
requestId,
)
}
async function authenticated<T>(
request: Request,
dependencies: RepositoryRouteDependencies,
operation: (actor: ActorContext) => Promise<T>,
): Promise<T> {
const actor = await dependencies.resolveContext(request)
return operation(actor)
}
async function mutationBoundary(
request: Request,
dependencies: RepositoryRouteDependencies,
requestId: string,
operation: (request: Request) => Promise<Response>,
): Promise<Response> {
return handleAuthRequest(
request,
async (sameOriginRequest) => {
if (
sameOriginRequest.headers.get('origin') !==
new URL(dependencies.publicBaseUrl).origin
) {
return errorResponse(
403,
'invalid_origin',
'Invalid request origin',
requestId,
)
}
return operation(sameOriginRequest)
},
dependencies.publicBaseUrl,
() =>
errorResponse(403, 'invalid_origin', 'Invalid request origin', requestId),
)
}
export async function handleListRepositories(
request: Request,
dependencies: RepositoryRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
return Response.json(
await authenticated(request, dependencies, (actor) =>
dependencies.service.list(actor, parseListQuery(request.url)),
),
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (caught) {
return mappedError(caught, requestId)
}
}
export function handleCreateRepository(
request: Request,
dependencies: RepositoryRouteDependencies,
) {
const requestId = crypto.randomUUID()
return mutationBoundary(
request,
dependencies,
requestId,
async (safeRequest) => {
try {
const actor = await dependencies.resolveContext(safeRequest)
const body = await parseCreateBody(safeRequest)
const created = await dependencies.service.create(
actor,
body.displayName,
body.initialProfile,
)
return Response.json(
{
repository: created.repository,
currentProfile: profileEnvelope({
revision: created.revision,
etag: created.etag,
}),
},
{
status: 201,
headers: { ETag: created.etag, 'Cache-Control': 'no-store' },
},
)
} catch (caught) {
return mappedError(caught, requestId)
}
},
)
}
export async function handleGetRepository(
request: Request,
repositoryId: string,
dependencies: RepositoryRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
return Response.json(
await authenticated(request, dependencies, async (actor) => {
assertRepositoryId(repositoryId)
return dependencies.service.get(actor, repositoryId)
}),
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (caught) {
return mappedError(caught, requestId)
}
}
export async function handleGetRepositoryProfile(
request: Request,
repositoryId: string,
dependencies: RepositoryRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
const result = await authenticated(request, dependencies, async (actor) => {
assertRepositoryId(repositoryId)
return dependencies.service.getProfile(actor, repositoryId)
})
return Response.json(profileEnvelope(result), {
headers: { ETag: result.etag, 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
}
export function handlePutRepositoryProfile(
request: Request,
repositoryId: string,
dependencies: RepositoryRouteDependencies,
) {
const requestId = crypto.randomUUID()
return mutationBoundary(
request,
dependencies,
requestId,
async (safeRequest) => {
try {
const actor = await dependencies.resolveContext(safeRequest)
assertRepositoryId(repositoryId)
const expectedEtag = safeRequest.headers.get('if-match')
if (expectedEtag === null) {
throw new RepositoryHttpError(
428,
'repository_profile_precondition_required',
'If-Match is required',
)
}
const profileDraft = await parseProfileBody(safeRequest)
const result = await dependencies.service.putProfile(
actor,
repositoryId,
expectedEtag,
profileDraft,
)
return Response.json(profileEnvelope(result), {
status: result.created ? 201 : 200,
headers: { ETag: result.etag, 'Cache-Control': 'no-store' },
})
} catch (caught) {
return mappedError(caught, requestId)
}
},
)
}
export async function handleExportRepositoryProfile(
request: Request,
repositoryId: string,
dependencies: RepositoryRouteDependencies,
) {
const requestId = crypto.randomUUID()
try {
const result = await authenticated(request, dependencies, async (actor) => {
assertRepositoryId(repositoryId)
return dependencies.service.exportProfile(
actor,
repositoryId,
parseExportFormat(request.url),
)
})
const extension =
result.contentType === 'application/json' ? 'json' : 'yaml'
return new Response(result.body, {
headers: {
'Content-Type': result.contentType,
'Content-Disposition': `attachment; filename="repository-profile.${extension}"`,
ETag: result.etag,
'Cache-Control': 'no-store',
},
})
} catch (caught) {
return mappedError(caught, requestId)
}
}

Some files were not shown because too many files have changed in this diff Show More