This commit is contained in:
Vendored
+6
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'],
|
||||
})
|
||||
}
|
||||
}
|
||||
+15
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
+14
@@ -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
Reference in New Issue
Block a user