Move browser authentication behind a generated-contract BFF

This commit is contained in:
ms2k 2026-07-19 18:21:17 +09:00
commit fa7220ee33
42 changed files with 7259 additions and 2825 deletions

View file

@ -10,27 +10,30 @@
},
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && node tools/compress-assets.mjs && node tools/check-bundle.mjs",
"preview": "vite preview",
"check": "tsc --noEmit -p ./tsconfig.app.json && tsc --noEmit -p ./tsconfig.node.json && tsc --noEmit -p ./tsconfig.test.json && pnpm --filter @remora/svelte-check-compat check",
"lint": "oxlint --threads 1 --deny-warnings src tests *.config.ts && prettier --check . && pnpm run lint:api",
"lint:api": "NODE_OPTIONS=--max-old-space-size=2048 redocly lint ../docs/api/openapi.yaml --extends=spec",
"test": "vitest run --config vitest.config.ts",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:unit": "vitest run --config vitest.unit.config.ts && node --test server.test.mjs server/*.test.mjs",
"test:component": "vitest run --config vitest.browser.config.ts",
"test:component:full": "vitest run --config vitest.browser-full.config.ts",
"test:e2e": "playwright test --project=chromium",
"test:e2e:full": "playwright test"
},
"dependencies": {
"oidc-client-ts": "3.5.0",
"svelte": "5.56.5"
"redis": "6.1.0",
"svelte": "5.56.5",
"ws": "8.21.1"
},
"devDependencies": {
"@axe-core/playwright": "4.12.1",
"@playwright/test": "1.61.1",
"@redocly/cli": "2.33.1",
"@sveltejs/vite-plugin-svelte": "7.2.0",
"@types/node": "26.1.1",
"@types/ws": "8.18.1",
"@vitest/browser-playwright": "4.1.10",
"oxlint": "1.74.0",
"oxlint-tsgolint": "0.24.0",

175
web/pnpm-lock.yaml generated
View file

@ -7,13 +7,19 @@ settings:
importers:
.:
dependencies:
oidc-client-ts:
specifier: 3.5.0
version: 3.5.0
redis:
specifier: 6.1.0
version: 6.1.0(@opentelemetry/[email protected])
svelte:
specifier: 5.56.5
version: 5.56.5(@typescript-eslint/[email protected])
ws:
specifier: 8.21.1
version: 8.21.1
devDependencies:
'@axe-core/playwright':
specifier: 4.12.1
version: 4.12.1([email protected])
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
@ -26,6 +32,9 @@ importers:
'@types/node':
specifier: 26.1.1
version: 26.1.1
'@types/ws':
specifier: 8.18.1
version: 8.18.1
'@vitest/browser-playwright':
specifier: 4.1.10
version: 4.1.10([email protected])([email protected](@types/[email protected]))([email protected])
@ -67,6 +76,14 @@ importers:
version: 6.0.3
packages:
'@axe-core/[email protected]':
resolution:
{
integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==,
}
peerDependencies:
playwright-core: '>= 1.0.0'
'@babel/[email protected]':
resolution:
{
@ -555,6 +572,57 @@ packages:
integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==,
}
'@redis/[email protected]':
resolution:
{
integrity: sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@redis/client': ^6.1.0
'@redis/[email protected]':
resolution:
{
integrity: sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@node-rs/xxhash': ^1.1.0
'@opentelemetry/api': '>=1 <2'
peerDependenciesMeta:
'@node-rs/xxhash':
optional: true
'@opentelemetry/api':
optional: true
'@redis/[email protected]':
resolution:
{
integrity: sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@redis/client': ^6.1.0
'@redis/[email protected]':
resolution:
{
integrity: sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@redis/client': ^6.1.0
'@redis/[email protected]':
resolution:
{
integrity: sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@redis/client': ^6.1.0
'@redocly/[email protected]':
resolution:
{
@ -842,6 +910,12 @@ packages:
integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==,
}
'@types/[email protected]':
resolution:
{
integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==,
}
'@typescript-eslint/[email protected]':
resolution:
{
@ -1162,6 +1236,13 @@ packages:
}
engines: { node: '>=12' }
[email protected]:
resolution:
{
integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==,
}
engines: { node: '>=4' }
[email protected]:
resolution:
{
@ -1250,6 +1331,13 @@ packages:
}
engines: { node: '>=6' }
[email protected]:
resolution:
{
integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==,
}
engines: { node: '>=0.10.0' }
[email protected]:
resolution:
{
@ -1604,13 +1692,6 @@ packages:
}
engines: { node: '>=0.10.0' }
[email protected]:
resolution:
{
integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==,
}
engines: { node: '>=18' }
[email protected]:
resolution:
{
@ -1943,13 +2024,6 @@ packages:
}
engines: { node: '>=12.20.0' }
[email protected]:
resolution:
{
integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==,
}
engines: { node: '>=18' }
[email protected]:
resolution:
{
@ -2160,6 +2234,13 @@ packages:
}
engines: { node: '>= 14.18.0' }
[email protected]:
resolution:
{
integrity: sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==,
}
engines: { node: '>= 20.0.0' }
[email protected]:
resolution:
{
@ -2698,10 +2779,10 @@ packages:
utf-8-validate:
optional: true
[email protected].0:
[email protected].1:
resolution:
{
integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==,
integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==,
}
engines: { node: '>=10.0.0' }
peerDependencies:
@ -2761,6 +2842,11 @@ packages:
}
snapshots:
'@axe-core/[email protected]([email protected])':
dependencies:
axe-core: 4.12.1
playwright-core: 1.61.1
'@babel/[email protected]':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@ -2990,6 +3076,28 @@ snapshots:
'@polka/[email protected]': {}
'@redis/[email protected](@redis/[email protected](@opentelemetry/[email protected]))':
dependencies:
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redis/[email protected](@opentelemetry/[email protected])':
dependencies:
cluster-key-slot: 1.1.2
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@redis/[email protected](@redis/[email protected](@opentelemetry/[email protected]))':
dependencies:
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redis/[email protected](@redis/[email protected](@opentelemetry/[email protected]))':
dependencies:
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redis/[email protected](@redis/[email protected](@opentelemetry/[email protected]))':
dependencies:
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redocly/[email protected]':
dependencies:
fast-deep-equal: 3.1.3
@ -3190,6 +3298,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/node': 26.1.1
'@typescript-eslint/[email protected]':
optional: true
@ -3276,7 +3388,7 @@ snapshots:
sirv: 3.0.2
tinyrainbow: 3.1.0
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected]))
ws: 8.21.0
ws: 8.21.1
transitivePeerDependencies:
- bufferutil
- msw
@ -3346,6 +3458,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@ -3392,6 +3506,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
color-name: 1.1.4
@ -3550,8 +3666,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@ -3708,10 +3822,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
jwt-decode: 4.0.0
[email protected]:
dependencies:
'@types/json-schema': 7.0.15
@ -3835,6 +3945,17 @@ snapshots:
[email protected]: {}
[email protected](@opentelemetry/[email protected]):
dependencies:
'@redis/bloom': 6.1.0(@redis/[email protected](@opentelemetry/[email protected]))
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redis/json': 6.1.0(@redis/[email protected](@opentelemetry/[email protected]))
'@redis/search': 6.1.0(@redis/[email protected](@opentelemetry/[email protected]))
'@redis/time-series': 6.1.0(@redis/[email protected](@opentelemetry/[email protected]))
transitivePeerDependencies:
- '@node-rs/xxhash'
- '@opentelemetry/api'
[email protected]([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected]([email protected]([email protected]))([email protected])):
dependencies:
'@redocly/openapi-core': 1.34.17
@ -4186,7 +4307,7 @@ snapshots:
[email protected]: {}
[email protected].0: {}
[email protected].1: {}
[email protected]: {}

View file

@ -1,68 +1,101 @@
import { createReadStream, statSync } from 'node:fs'
import { createServer } from 'node:http'
import { extname, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
const root = resolve(process.env.REMORA_WEB_ROOT ?? '/app/dist')
const port = numberEnvironment('REMORA_WEB_PORT', 8080)
const runtimeConfig = {
apiUrl: environment('REMORA_WEB_API_URL', '/v1/'),
eventsUrl: environment('REMORA_WEB_EVENTS_URL', ''),
oidcAuthority: environment('REMORA_WEB_OIDC_AUTHORITY', '/oidc'),
oidcClientId: environment('REMORA_WEB_OIDC_CLIENT_ID', 'remora-web'),
oidcRedirectUri: environment('REMORA_WEB_OIDC_REDIRECT_URI', '/'),
oidcPostLogoutRedirectUri: environment(
'REMORA_WEB_OIDC_POST_LOGOUT_REDIRECT_URI',
'/',
),
oidcScope: environment('REMORA_WEB_OIDC_SCOPE', 'openid profile'),
}
const startedAt = Date.now()
let requests = 0
import { loadConfig } from './server/config.mjs'
import { OAuthClient } from './server/oauth.mjs'
import { ApiProxy } from './server/proxy.mjs'
import { SessionStore } from './server/sessions.mjs'
createServer((request, response) => {
requests += 1
const traceId = traceIdentifier(request.headers.traceparent)
response.setHeader('X-Content-Type-Options', 'nosniff')
response.setHeader('Referrer-Policy', 'no-referrer')
response.setHeader('X-Frame-Options', 'DENY')
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
response.setHeader('Content-Security-Policy', contentSecurityPolicy())
response.setHeader('Traceparent', `00-${traceId}-0000000000000001-01`)
response.once('finish', () => {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
trace_id: traceId,
method: request.method,
path: requestPath(request.url),
status: response.statusCode,
})}\n`,
)
export function createWebServer(config, dependencies = {}) {
const store = dependencies.store ?? new SessionStore(config)
const oauth =
dependencies.oauth ??
new OAuthClient(config, store, dependencies.fetchImplementation)
const proxy =
dependencies.proxy ??
new ApiProxy(config, oauth, dependencies.fetchImplementation)
const startedAt = Date.now()
let requests = 0
const server = createServer(async (request, response) => {
requests += 1
const traceId = traceIdentifier(request.headers.traceparent)
secureHeaders(response, traceId)
response.once('finish', () => {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
trace_id: traceId,
method: request.method,
path: requestPath(request.url),
status: response.statusCode,
})}\n`,
)
})
try {
await route(
request,
response,
config,
store,
oauth,
proxy,
startedAt,
requests,
)
} catch {
if (!response.headersSent)
response.writeHead(502, { 'Cache-Control': 'no-store' })
response.end()
}
})
route(request, response)
}).listen(port, '0.0.0.0', () => {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
message: 'web application ready',
port,
})}\n`,
)
})
server.on('upgrade', async (request, socket, head) => {
try {
if (!(await proxy.upgrade(request, socket, head))) socket.destroy()
} catch {
socket.destroy()
}
})
return { server, store }
}
function route(request, response) {
async function route(
request,
response,
config,
store,
oauth,
proxy,
startedAt,
requests,
) {
const url = new URL(request.url ?? '/', config.publicOrigin)
if (request.method === 'POST' && url.pathname === '/client-errors') {
await recordClientError(request, response, config)
return
}
if (await oauth.route(request, response, url)) return
if (await proxy.route(request, response, url)) return
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405, { Allow: 'GET, HEAD' })
response.end()
return
}
const url = new URL(request.url ?? '/', 'http://localhost')
if (url.pathname === '/health/live' || url.pathname === '/health/ready') {
json(response, 200, { status: 'ready' })
if (url.pathname === '/health/live') {
json(response, 200, { status: 'live' }, request.method)
return
}
if (url.pathname === '/health/ready') {
json(
response,
store.ready ? 200 : 503,
{ status: store.ready ? 'ready' : 'unavailable' },
request.method,
)
return
}
if (url.pathname === '/metrics') {
@ -81,9 +114,10 @@ function route(request, response) {
return
}
if (url.pathname === '/runtime-config.js') {
const body = `window.remoraRuntimeConfig=${JSON.stringify(
normalizedRuntimeConfig(),
)};\n`
const body = `window.remoraRuntimeConfig=${JSON.stringify({
apiUrl: '/bff/api/',
eventsUrl: `${config.publicOrigin.replace(/^http/, 'ws')}/bff/events`,
})};\n`
response.writeHead(200, {
'Cache-Control': 'no-store',
'Content-Type': 'text/javascript; charset=utf-8',
@ -92,27 +126,116 @@ function route(request, response) {
response.end(request.method === 'HEAD' ? undefined : body)
return
}
const candidate = safeFile(url.pathname)
const file = candidate ?? resolve(root, 'index.html')
const body = regularFile(file) ? file : resolve(root, 'index.html')
if (!regularFile(body)) {
serveStatic(request, response, url, config.root)
}
async function recordClientError(request, response, config) {
if (
request.headers.origin !== config.publicOrigin ||
request.headers['sec-fetch-site'] === 'cross-site'
) {
response.writeHead(403, { 'Cache-Control': 'no-store' })
response.end()
return
}
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > 4096) {
response.writeHead(413, { 'Cache-Control': 'no-store' })
response.end()
return
}
chunks.push(chunk)
}
let body
try {
body = JSON.parse(Buffer.concat(chunks).toString())
} catch {
response.writeHead(400, { 'Cache-Control': 'no-store' })
response.end()
return
}
const validKind =
body?.kind === 'error' || body?.kind === 'unhandled_rejection'
if (
!validKind ||
typeof body.path !== 'string' ||
body.path.length > 1024 ||
!body.path.startsWith('/')
) {
response.writeHead(400, { 'Cache-Control': 'no-store' })
response.end()
return
}
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
component: 'remora-web',
event: 'browser_error',
kind: body.kind,
path: body.path,
})}\n`,
)
response.writeHead(204, { 'Cache-Control': 'no-store' })
response.end()
}
function serveStatic(request, response, url, root) {
const candidate = safeFile(url.pathname, root)
let file = candidate && regularFile(candidate) ? candidate : null
if (
file === null &&
request.headers.accept?.includes('text/html') &&
!url.pathname.includes('.')
)
file = resolve(root, 'index.html')
if (file === null || !regularFile(file)) {
response.writeHead(404)
response.end()
return
}
const size = statSync(body).size
response.writeHead(200, {
'Cache-Control': body.endsWith('index.html')
const compressed = compressedFile(file, request.headers['accept-encoding'])
const served = compressed?.path ?? file
const size = statSync(served).size
const headers = {
'Cache-Control': file.endsWith('index.html')
? 'no-cache'
: 'public, max-age=31536000, immutable',
'Content-Type': contentType(body),
'Content-Type': contentType(file),
'Content-Length': size,
})
if (request.method === 'HEAD') {
response.end()
} else {
createReadStream(body).pipe(response)
}
if (compressed !== null) {
headers['Content-Encoding'] = compressed.encoding
headers.Vary = 'Accept-Encoding'
}
response.writeHead(200, headers)
if (request.method === 'HEAD') response.end()
else createReadStream(served).pipe(response)
}
function compressedFile(path, accepted = '') {
for (const [encoding, suffix] of [
['br', '.br'],
['gzip', '.gz'],
])
if (accepted.includes(encoding) && regularFile(`${path}${suffix}`))
return { encoding, path: `${path}${suffix}` }
return null
}
function secureHeaders(response, traceId) {
response.setHeader('X-Content-Type-Options', 'nosniff')
response.setHeader('Referrer-Policy', 'no-referrer')
response.setHeader('X-Frame-Options', 'DENY')
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
response.setHeader(
'Content-Security-Policy',
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'",
)
response.setHeader('Traceparent', `00-${traceId}-0000000000000001-01`)
}
function requestPath(value) {
@ -123,32 +246,7 @@ function requestPath(value) {
}
}
function normalizedRuntimeConfig() {
const origin = requiredEnvironment('REMORA_WEB_PUBLIC_ORIGIN')
return {
...runtimeConfig,
eventsUrl:
runtimeConfig.eventsUrl ||
absolute(runtimeConfig.apiUrl.replace(/\/?$/, '/events'), origin, true),
oidcAuthority: absolute(runtimeConfig.oidcAuthority, origin, false),
oidcRedirectUri: absolute(runtimeConfig.oidcRedirectUri, origin, false),
oidcPostLogoutRedirectUri: absolute(
runtimeConfig.oidcPostLogoutRedirectUri,
origin,
false,
),
}
}
function absolute(value, origin, websocket) {
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return value
if (!origin) return value
const url = new URL(value, origin)
if (websocket) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return url.toString()
}
function safeFile(pathname) {
function safeFile(pathname, root) {
let decoded
try {
decoded = decodeURIComponent(pathname)
@ -169,13 +267,13 @@ function regularFile(path) {
}
}
function json(response, status, value) {
function json(response, status, value, method) {
const body = JSON.stringify(value)
response.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
})
response.end(body)
response.end(method === 'HEAD' ? undefined : body)
}
function contentType(path) {
@ -191,11 +289,6 @@ function contentType(path) {
)
}
function contentSecurityPolicy() {
const connect = environment('REMORA_WEB_CSP_CONNECT_SRC', "'self'")
return `default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; connect-src ${connect}`
}
function traceIdentifier(traceparent) {
const match =
typeof traceparent === 'string'
@ -204,23 +297,32 @@ function traceIdentifier(traceparent) {
return match?.[1] ?? crypto.randomUUID().replaceAll('-', '')
}
function environment(name, fallback) {
const value = process.env[name]
return value === undefined || value === '' ? fallback : value
async function main() {
const config = loadConfig()
const { server, store } = createWebServer(config)
await store.connect()
server.listen(config.port, '0.0.0.0', () => {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
message: 'web application ready',
port: config.port,
})}\n`,
)
})
}
function requiredEnvironment(name) {
const value = process.env[name]
if (value === undefined || value === '') {
throw new Error(`${name} is required`)
}
return value
}
function numberEnvironment(name, fallback) {
const value = Number(environment(name, String(fallback)))
if (!Number.isSafeInteger(value) || value < 1 || value > 65535) {
throw new Error(`${name} must be a valid port`)
}
return value
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href)
main().catch((error) => {
process.stderr.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
component: 'remora-web',
message: error.message,
})}\n`,
)
process.exitCode = 1
})

105
web/server.test.mjs Normal file
View file

@ -0,0 +1,105 @@
import assert from 'node:assert/strict'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { after, before, test } from 'node:test'
import { createWebServer } from './server.mjs'
let base
let root
let server
before(async () => {
root = await mkdtemp(join(tmpdir(), 'remora-web-server-'))
await writeFile(join(root, 'index.html'), '<main>Remora</main>')
await writeFile(join(root, 'application.js'), 'application()')
await writeFile(join(root, 'application.js.br'), 'compressed')
const config = {
root,
publicOrigin: 'http://127.0.0.1',
}
const created = createWebServer(config, {
store: { ready: true },
oauth: { route: async () => false },
proxy: {
route: async () => false,
upgrade: async () => false,
},
})
server = created.server
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (typeof address !== 'object' || address === null)
throw new Error('server address unavailable')
base = `http://127.0.0.1:${address.port}`
})
after(async () => {
server.closeIdleConnections()
server.closeAllConnections()
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
)
await rm(root, { recursive: true })
})
test('serves health, metrics, runtime configuration, and security headers', async () => {
const ready = await fetch(`${base}/health/ready`)
assert.equal(ready.status, 200)
assert.deepEqual(await ready.json(), { status: 'ready' })
assert.equal(ready.headers.get('x-content-type-options'), 'nosniff')
assert.equal(ready.headers.get('x-frame-options'), 'DENY')
assert.match(
ready.headers.get('content-security-policy'),
/connect-src 'self'/,
)
const metrics = await fetch(`${base}/metrics`)
assert.match(await metrics.text(), /remora_web_requests_total/)
const runtime = await fetch(`${base}/runtime-config.js`)
const script = await runtime.text()
assert.match(script, /"apiUrl":"\/bff\/api\/"/)
assert.doesNotMatch(script, /oidc|client|secret/i)
assert.equal(runtime.headers.get('cache-control'), 'no-store')
})
test('serves immutable precompressed assets and correct HEAD responses', async () => {
const asset = await fetch(`${base}/application.js`, {
headers: { 'Accept-Encoding': 'br' },
})
assert.equal(asset.status, 200)
assert.equal(asset.headers.get('content-encoding'), 'br')
assert.equal(asset.headers.get('vary'), 'Accept-Encoding')
assert.match(asset.headers.get('cache-control'), /immutable/)
const head = await fetch(`${base}/application.js`, { method: 'HEAD' })
assert.equal(head.status, 200)
assert.equal(await head.text(), '')
assert.equal(
head.headers.get('content-type'),
'text/javascript; charset=utf-8',
)
})
test('limits the SPA fallback to extensionless HTML navigation', async () => {
const navigation = await fetch(`${base}/jobs/current`, {
headers: { Accept: 'text/html' },
})
assert.equal(navigation.status, 200)
assert.equal(await navigation.text(), '<main>Remora</main>')
const data = await fetch(`${base}/jobs/current`, {
headers: { Accept: 'application/json' },
})
assert.equal(data.status, 404)
const missingAsset = await fetch(`${base}/missing.js`, {
headers: { Accept: 'text/html' },
})
assert.equal(missingAsset.status, 404)
const traversal = await fetch(`${base}/%2e%2e/package.json`, {
headers: { Accept: 'text/html' },
})
assert.equal(traversal.status, 404)
})

61
web/server/config.mjs Normal file
View file

@ -0,0 +1,61 @@
export function loadConfig(environment = process.env) {
const publicOrigin = required(environment, 'REMORA_WEB_PUBLIC_ORIGIN')
const apiUpstream = required(environment, 'REMORA_WEB_API_UPSTREAM')
const oidcAuthority = required(environment, 'REMORA_WEB_OIDC_AUTHORITY')
return {
root: environment.REMORA_WEB_ROOT || '/app/dist',
port: port(environment.REMORA_WEB_PORT || '8080'),
publicOrigin: new URL(publicOrigin).origin,
apiUpstream: trailingSlash(new URL(apiUpstream)),
eventsUpstream: webSocketUrl(
environment.REMORA_WEB_EVENTS_UPSTREAM ||
new URL('events', trailingSlash(new URL(apiUpstream))).toString(),
),
oidcAuthority: oidcAuthority.replace(/\/$/, ''),
oidcClientId: required(environment, 'REMORA_WEB_OIDC_CLIENT_ID'),
oidcClientSecret: required(environment, 'REMORA_WEB_OIDC_CLIENT_SECRET'),
oidcScope: environment.REMORA_WEB_OIDC_SCOPE || 'openid profile',
sessionUrl: required(environment, 'REMORA_WEB_SESSION_URL'),
sessionNamespace:
environment.REMORA_WEB_SESSION_NAMESPACE || 'remora:web-session:',
sessionTtlSeconds: positiveInteger(
environment.REMORA_WEB_SESSION_TTL_SECONDS || '28800',
'REMORA_WEB_SESSION_TTL_SECONDS',
),
secureCookie: true,
}
}
function required(environment, name) {
const value = environment[name]
if (value === undefined || value === '')
throw new Error(`${name} is required`)
return value
}
function port(value) {
const parsed = positiveInteger(value, 'REMORA_WEB_PORT')
if (parsed > 65535) throw new Error('REMORA_WEB_PORT must be a valid port')
return parsed
}
function positiveInteger(value, name) {
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < 1)
throw new Error(`${name} must be a positive integer`)
return parsed
}
function trailingSlash(url) {
if (!url.pathname.endsWith('/')) url.pathname += '/'
return url
}
function webSocketUrl(value) {
const url = new URL(value)
if (url.protocol === 'http:') url.protocol = 'ws:'
else if (url.protocol === 'https:') url.protocol = 'wss:'
if (url.protocol !== 'ws:' && url.protocol !== 'wss:')
throw new Error('REMORA_WEB_EVENTS_UPSTREAM must use HTTP or WebSocket')
return url
}

395
web/server/oauth.mjs Normal file
View file

@ -0,0 +1,395 @@
import { createHash, createPublicKey, createVerify } from 'node:crypto'
import {
equalSecret,
expiredSessionCookie,
randomValue,
sessionCookie,
sessionIdentifier,
} from './sessions.mjs'
const CLOCK_SKEW_SECONDS = 30
const OIDC_TIMEOUT_MILLISECONDS = 15_000
export class OAuthClient {
constructor(config, store, fetchImplementation = fetch) {
this.config = config
this.store = store
this.fetch = fetchImplementation
this.discovery = null
}
async route(request, response, url) {
if (request.method === 'GET' && url.pathname === '/auth/login')
return this.login(request, response)
if (request.method === 'GET' && url.pathname === '/auth/callback')
return this.callback(request, response, url)
if (request.method === 'GET' && url.pathname === '/auth/session')
return this.session(request, response)
if (request.method === 'POST' && url.pathname === '/auth/logout')
return this.logout(request, response)
return false
}
async login(request, response) {
const previous = sessionIdentifier(request)
if (previous !== null) await this.store.destroy(previous)
const discovery = await this.metadata()
const verifier = randomValue(48)
const state = randomValue()
const nonce = randomValue()
const identifier = await this.store.create({
kind: 'authorization',
state,
nonce,
verifier,
})
const authorization = new URL(discovery.authorization_endpoint)
authorization.search = new URLSearchParams({
client_id: this.config.oidcClientId,
redirect_uri: `${this.config.publicOrigin}/auth/callback`,
response_type: 'code',
scope: this.config.oidcScope,
state,
nonce,
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
code_challenge_method: 'S256',
}).toString()
response.writeHead(302, {
Location: authorization.toString(),
'Set-Cookie': sessionCookie(identifier, this.config),
'Cache-Control': 'no-store',
})
response.end()
return true
}
async callback(request, response, url) {
const identifier = sessionIdentifier(request)
const pending = identifier && (await this.store.read(identifier))
const state = url.searchParams.get('state')
const code = url.searchParams.get('code')
if (
pending?.kind !== 'authorization' ||
code === null ||
state === null ||
!equalSecret(state, pending.state)
)
return this.authenticationFailure(response, identifier)
try {
const discovery = await this.metadata()
const tokenResponse = await this.fetch(discovery.token_endpoint, {
method: 'POST',
headers: {
Authorization: clientAuthorization(this.config),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: `${this.config.publicOrigin}/auth/callback`,
code_verifier: pending.verifier,
}),
signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS),
})
if (!tokenResponse.ok) throw new Error('token exchange failed')
const tokens = await tokenResponse.json()
const identity = await this.validateIdToken(
tokens.id_token,
pending.nonce,
)
const csrfToken = randomValue()
const session = authenticatedSession(tokens, identity, csrfToken)
const replacement = await this.store.replace(identifier, session)
response.writeHead(302, {
Location: this.config.publicOrigin,
'Set-Cookie': sessionCookie(replacement, this.config),
'Cache-Control': 'no-store',
})
response.end()
} catch {
await this.authenticationFailure(response, identifier)
}
return true
}
async session(request, response) {
const loaded = await this.authenticated(request)
if (loaded === null) {
json(
response,
200,
{ kind: 'signed_out' },
{
'Set-Cookie': expiredSessionCookie(this.config),
},
)
return true
}
json(
response,
200,
{
kind: 'authenticated',
identity: loaded.session.identity,
csrfToken: loaded.session.csrfToken,
},
loaded.rotated
? { 'Set-Cookie': sessionCookie(loaded.identifier, this.config) }
: {},
)
return true
}
async logout(request, response) {
const loaded = await this.authenticated(request, false)
if (
loaded === null ||
!validUnsafeRequest(request, this.config, loaded.session.csrfToken)
) {
json(response, 403, { error: 'invalid_csrf' })
return true
}
await this.store.destroy(loaded.identifier)
const discovery = await this.metadata().catch(() => null)
let location = this.config.publicOrigin
if (discovery?.end_session_endpoint) {
const logout = new URL(discovery.end_session_endpoint)
logout.searchParams.set(
'post_logout_redirect_uri',
this.config.publicOrigin,
)
logout.searchParams.set('id_token_hint', loaded.session.idToken)
location = logout.toString()
}
json(
response,
200,
{ redirect: location },
{ 'Set-Cookie': expiredSessionCookie(this.config) },
)
return true
}
async authenticated(request, refresh = true) {
const identifier = sessionIdentifier(request)
if (identifier === null) return null
let session = await this.store.read(identifier)
if (!validAuthenticatedSession(session)) {
if (session?.kind === 'authenticated')
await this.store.destroy(identifier)
return null
}
let rotated = false
if (session.accessExpiresAt <= now() + CLOCK_SKEW_SECONDS) {
if (!refresh) {
await this.store.destroy(identifier)
return null
}
session = await this.refresh(session).catch(() => null)
if (session === null) {
await this.store.destroy(identifier)
return null
}
identifier = await this.store.replace(identifier, session)
rotated = true
}
return { identifier, session, rotated }
}
async refresh(session) {
if (typeof session.refreshToken !== 'string') return null
const discovery = await this.metadata()
const response = await this.fetch(discovery.token_endpoint, {
method: 'POST',
headers: {
Authorization: clientAuthorization(this.config),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
}),
signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS),
})
if (!response.ok) return null
const tokens = await response.json()
const idToken =
typeof tokens.id_token === 'string' ? tokens.id_token : session.idToken
const identity =
typeof tokens.id_token === 'string'
? await this.validateIdToken(tokens.id_token, undefined)
: session.identity
if (identity !== session.identity) return null
return authenticatedSession(
{
...tokens,
id_token: idToken,
refresh_token:
typeof tokens.refresh_token === 'string'
? tokens.refresh_token
: session.refreshToken,
},
identity,
session.csrfToken,
)
}
async metadata() {
if (this.discovery !== null) return this.discovery
const response = await this.fetch(
`${this.config.oidcAuthority}/.well-known/openid-configuration`,
{ signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS) },
)
if (!response.ok) throw new Error('OIDC discovery failed')
const metadata = await response.json()
for (const field of [
'issuer',
'authorization_endpoint',
'token_endpoint',
'jwks_uri',
])
if (typeof metadata[field] !== 'string')
throw new Error(`OIDC discovery omitted ${field}`)
if (metadata.issuer !== this.config.oidcAuthority)
throw new Error('OIDC discovery issuer mismatch')
this.discovery = metadata
return metadata
}
async validateIdToken(token, nonce) {
const [headerPart, payloadPart, signaturePart] = String(token).split('.')
if (!headerPart || !payloadPart || !signaturePart)
throw new Error('invalid ID token')
const header = JSON.parse(Buffer.from(headerPart, 'base64url').toString())
const claims = JSON.parse(Buffer.from(payloadPart, 'base64url').toString())
if (header.alg !== 'RS256' || typeof header.kid !== 'string')
throw new Error('unsupported ID token signature')
const discovery = await this.metadata()
const response = await this.fetch(discovery.jwks_uri, {
signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS),
})
if (!response.ok) throw new Error('JWKS fetch failed')
const jwks = await response.json()
const key = jwks.keys?.find((candidate) => candidate.kid === header.kid)
if (key === undefined) throw new Error('ID token key missing')
const verifier = createVerify('RSA-SHA256')
verifier.update(`${headerPart}.${payloadPart}`)
verifier.end()
if (
!verifier.verify(
createPublicKey({ key, format: 'jwk' }),
Buffer.from(signaturePart, 'base64url'),
)
)
throw new Error('invalid ID token signature')
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
if (
claims.iss !== this.config.oidcAuthority ||
!audience.includes(this.config.oidcClientId) ||
(audience.length > 1 && claims.azp !== this.config.oidcClientId) ||
(nonce !== undefined && claims.nonce !== nonce) ||
typeof claims.sub !== 'string' ||
typeof claims.iat !== 'number' ||
claims.iat > now() + CLOCK_SKEW_SECONDS ||
typeof claims.exp !== 'number' ||
claims.exp <= now() - CLOCK_SKEW_SECONDS
)
throw new Error('invalid ID token claims')
return `${claims.iss}\u0000${claims.sub}`
}
async authenticationFailure(response, identifier) {
if (identifier) await this.store.destroy(identifier)
response.writeHead(302, {
Location: `${this.config.publicOrigin}/?authentication=failed`,
'Set-Cookie': expiredSessionCookie(this.config),
'Cache-Control': 'no-store',
})
response.end()
return true
}
}
export function validUnsafeRequest(request, config, csrfToken) {
return (
validBrowserContext(request, config) &&
equalSecret(request.headers['x-remora-csrf'], csrfToken)
)
}
export function validBrowserContext(request, config) {
const fetchSite = request.headers['sec-fetch-site']
return (
request.headers.origin === config.publicOrigin &&
(fetchSite === undefined ||
fetchSite === 'same-origin' ||
fetchSite === 'none')
)
}
function authenticatedSession(tokens, identity, csrfToken) {
if (
typeof tokens.access_token !== 'string' ||
typeof tokens.id_token !== 'string' ||
typeof tokens.expires_in !== 'number' ||
!Number.isFinite(tokens.expires_in) ||
tokens.expires_in <= 0
)
throw new Error('token response incomplete')
return {
kind: 'authenticated',
identity,
csrfToken,
accessToken: tokens.access_token,
refreshToken:
typeof tokens.refresh_token === 'string' ? tokens.refresh_token : null,
idToken: tokens.id_token,
accessExpiresAt: now() + tokens.expires_in,
}
}
function validAuthenticatedSession(session) {
return (
session?.kind === 'authenticated' &&
typeof session.identity === 'string' &&
session.identity !== '' &&
typeof session.csrfToken === 'string' &&
session.csrfToken !== '' &&
typeof session.accessToken === 'string' &&
session.accessToken !== '' &&
(session.refreshToken === null ||
(typeof session.refreshToken === 'string' &&
session.refreshToken !== '')) &&
typeof session.idToken === 'string' &&
session.idToken !== '' &&
Number.isFinite(session.accessExpiresAt) &&
session.accessExpiresAt > 0
)
}
function clientAuthorization(config) {
const identifier = formComponent(config.oidcClientId)
const secret = formComponent(config.oidcClientSecret)
return `Basic ${Buffer.from(`${identifier}:${secret}`).toString('base64')}`
}
function formComponent(value) {
return new URLSearchParams({ value }).toString().slice('value='.length)
}
function now() {
return Math.floor(Date.now() / 1000)
}
function json(response, status, value, headers = {}) {
const body = JSON.stringify(value)
response.writeHead(status, {
...headers,
'Cache-Control': 'no-store',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
})
response.end(body)
}

244
web/server/oauth.test.mjs Normal file
View file

@ -0,0 +1,244 @@
import assert from 'node:assert/strict'
import { createSign, generateKeyPairSync } from 'node:crypto'
import { test } from 'node:test'
import { OAuthClient } from './oauth.mjs'
import { SESSION_COOKIE } from './sessions.mjs'
const identifier = 'a'.repeat(43)
const replacement = 'b'.repeat(43)
const now = Math.floor(Date.now() / 1000)
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
})
const publicJwk = publicKey.export({ format: 'jwk' })
publicJwk.kid = 'test-key'
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
const config = {
publicOrigin: 'https://remora.example.com',
oidcAuthority: 'https://identity.example.com',
oidcClientId: 'remora client',
oidcClientSecret: 'secret:value',
oidcScope: 'openid profile',
secureCookie: true,
}
test('completes authorization code flow without exposing tokens to the browser', async () => {
const store = new MemoryStore()
const requests = []
const oauth = new OAuthClient(config, store, async (url, init = {}) => {
requests.push({ url: String(url), init })
if (String(url).endsWith('/.well-known/openid-configuration'))
return Response.json(metadata())
if (String(url) === metadata().token_endpoint) {
assert.equal(
init.headers.Authorization,
`Basic ${Buffer.from('remora+client:secret%3Avalue').toString('base64')}`,
)
const parameters = new URLSearchParams(init.body)
assert.equal(parameters.get('code_verifier'), store.value.verifier)
return Response.json({
access_token: 'server-only-access-token',
refresh_token: 'server-only-refresh-token',
id_token: idToken(store.value.nonce),
expires_in: 300,
})
}
if (String(url) === metadata().jwks_uri)
return Response.json({ keys: [publicJwk] })
throw new Error(`unexpected request: ${url}`)
})
const login = new TestResponse()
await oauth.login({ headers: {} }, login)
const authorization = new URL(login.headers.Location)
assert.equal(authorization.searchParams.get('code_challenge_method'), 'S256')
assert.equal(authorization.searchParams.get('state'), store.value.state)
assert.equal(authorization.searchParams.get('nonce'), store.value.nonce)
assert.match(login.headers['Set-Cookie'], /HttpOnly; Secure; SameSite=Strict/)
const callback = new TestResponse()
await oauth.callback(
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
callback,
new URL(
`/auth/callback?code=authorization-code&state=${store.value.state}`,
config.publicOrigin,
),
)
assert.equal(callback.status, 302)
assert.equal(callback.headers.Location, config.publicOrigin)
assert.match(callback.headers['Set-Cookie'], new RegExp(replacement))
assert.equal(store.value.kind, 'authenticated')
assert.equal(store.value.identity, `${config.oidcAuthority}\u0000subject-123`)
assert.equal(store.value.accessToken, 'server-only-access-token')
assert.equal(requests.length, 3)
const session = new TestResponse()
await oauth.session(
{ headers: { cookie: `${SESSION_COOKIE}=${replacement}` } },
session,
)
assert.deepEqual(JSON.parse(session.body), {
kind: 'authenticated',
identity: `${config.oidcAuthority}\u0000subject-123`,
csrfToken: store.value.csrfToken,
})
assert.doesNotMatch(session.body, /access|refresh|id_token/i)
})
test('destroys pending authorization when nonce validation fails', async () => {
const store = new MemoryStore()
const oauth = new OAuthClient(config, store, async (url) => {
if (String(url).endsWith('/.well-known/openid-configuration'))
return Response.json(metadata())
if (String(url) === metadata().token_endpoint)
return Response.json({
access_token: 'access',
id_token: idToken('wrong-nonce'),
expires_in: 300,
})
if (String(url) === metadata().jwks_uri)
return Response.json({ keys: [publicJwk] })
throw new Error(`unexpected request: ${url}`)
})
await oauth.login({ headers: {} }, new TestResponse())
const response = new TestResponse()
await oauth.callback(
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
response,
new URL(
`/auth/callback?code=authorization-code&state=${store.value.state}`,
config.publicOrigin,
),
)
assert.equal(response.status, 302)
assert.equal(
response.headers.Location,
`${config.publicOrigin}/?authentication=failed`,
)
assert.equal(store.value, null)
})
test('reauthentication destroys the preceding browser session', async () => {
const store = new MemoryStore()
store.value = validSession()
const oauth = new OAuthClient(config, store, async () =>
Response.json(metadata()),
)
await oauth.login(
{ headers: { cookie: `${SESSION_COOKIE}=${replacement}` } },
new TestResponse(),
)
assert.deepEqual(store.destroyed, [replacement])
assert.equal(store.value.kind, 'authorization')
})
test('expired and malformed authenticated sessions are destroyed', async () => {
const store = new MemoryStore()
const oauth = new OAuthClient(config, store)
store.value = { kind: 'authenticated', accessToken: 'incomplete' }
assert.equal(
await oauth.authenticated(
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
false,
),
null,
)
store.value = { ...validSession(), accessExpiresAt: now - 1 }
assert.equal(
await oauth.authenticated(
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
false,
),
null,
)
assert.deepEqual(store.destroyed, [identifier, identifier])
})
class MemoryStore {
value = null
destroyed = []
async create(value) {
this.value = value
return identifier
}
async read() {
return this.value
}
async replace(_identifier, value) {
this.value = value
return replacement
}
async destroy(identifier) {
this.destroyed.push(identifier)
this.value = null
}
}
class TestResponse {
status = 0
headers = {}
body = ''
writeHead(status, headers) {
this.status = status
this.headers = headers
}
end(body = '') {
this.body = body
}
}
function metadata() {
return {
issuer: config.oidcAuthority,
authorization_endpoint: `${config.oidcAuthority}/oauth/authorize`,
token_endpoint: `${config.oidcAuthority}/oauth/token`,
jwks_uri: `${config.oidcAuthority}/oauth/keys`,
}
}
function validSession() {
return {
kind: 'authenticated',
identity: `${config.oidcAuthority}\u0000subject-123`,
csrfToken: 'csrf-value',
accessToken: 'access-token',
refreshToken: null,
idToken: 'id-token',
accessExpiresAt: now + 300,
}
}
function idToken(nonce) {
const header = encode({ alg: 'RS256', kid: publicJwk.kid })
const payload = encode({
iss: config.oidcAuthority,
aud: config.oidcClientId,
sub: 'subject-123',
nonce,
iat: now,
exp: now + 300,
})
const signer = createSign('RSA-SHA256')
signer.update(`${header}.${payload}`)
signer.end()
return `${header}.${payload}.${signer.sign(privateKey).toString('base64url')}`
}
function encode(value) {
return Buffer.from(JSON.stringify(value)).toString('base64url')
}

197
web/server/proxy.mjs Normal file
View file

@ -0,0 +1,197 @@
import { WebSocket, WebSocketServer } from 'ws'
import { validBrowserContext, validUnsafeRequest } from './oauth.mjs'
import { equalSecret, sessionCookie } from './sessions.mjs'
const MAX_REQUEST_BYTES = 1024 * 1024
const MAX_EVENT_BYTES = 1024 * 1024
const API_TIMEOUT_MILLISECONDS = 30_000
export class ApiProxy {
constructor(config, oauth, fetchImplementation = fetch) {
this.config = config
this.oauth = oauth
this.fetch = fetchImplementation
this.webSockets = new WebSocketServer({
noServer: true,
maxPayload: MAX_EVENT_BYTES,
})
}
async route(request, response, url) {
if (!url.pathname.startsWith('/bff/api/')) return false
if (!['GET', 'POST'].includes(request.method)) {
response.writeHead(405, { Allow: 'GET, POST' })
response.end()
return true
}
const loaded = await this.oauth.authenticated(request)
if (loaded === null) {
response.writeHead(401, { 'Cache-Control': 'no-store' })
response.end()
return true
}
if (
request.method !== 'GET' &&
!validUnsafeRequest(request, this.config, loaded.session.csrfToken)
) {
requestEvent('csrf_rejected', {
origin_matches: request.headers.origin === this.config.publicOrigin,
fetch_site: request.headers['sec-fetch-site'] ?? 'absent',
csrf_present: typeof request.headers['x-remora-csrf'] === 'string',
csrf_matches: equalSecret(
request.headers['x-remora-csrf'],
loaded.session.csrfToken,
),
})
response.writeHead(403, { 'Cache-Control': 'no-store' })
response.end()
return true
}
const suffix = url.pathname.slice('/bff/api/'.length)
if (suffix.startsWith('/')) {
response.writeHead(400, { 'Cache-Control': 'no-store' })
response.end()
return true
}
const upstream = new URL(suffix, this.config.apiUpstream)
upstream.search = url.search
if (
upstream.origin !== this.config.apiUpstream.origin ||
!upstream.pathname.startsWith(this.config.apiUpstream.pathname)
) {
response.writeHead(400, { 'Cache-Control': 'no-store' })
response.end()
return true
}
const headers = {
Accept: request.headers.accept || 'application/json',
Authorization: `Bearer ${loaded.session.accessToken}`,
}
if (request.method === 'POST' && suffix === 'event-tickets')
headers.Origin = this.config.publicOrigin
copyHeader(request, headers, 'content-type')
copyHeader(request, headers, 'idempotency-key')
copyHeader(request, headers, 'traceparent')
let body
try {
body = request.method === 'GET' ? undefined : await requestBody(request)
} catch (error) {
if (!(error instanceof RequestBodyTooLarge)) throw error
response.writeHead(413, { 'Cache-Control': 'no-store' })
response.end()
return true
}
const upstreamResponse = await this.fetch(upstream, {
method: request.method,
headers,
body,
redirect: 'manual',
signal: AbortSignal.timeout(API_TIMEOUT_MILLISECONDS),
})
const responseHeaders = { 'Cache-Control': 'no-store' }
if (loaded.rotated)
responseHeaders['Set-Cookie'] = sessionCookie(
loaded.identifier,
this.config,
)
for (const name of ['content-type', 'traceparent'])
if (upstreamResponse.headers.has(name))
responseHeaders[name] = upstreamResponse.headers.get(name)
response.writeHead(upstreamResponse.status, responseHeaders)
response.end(Buffer.from(await upstreamResponse.arrayBuffer()))
return true
}
async upgrade(request, socket, head) {
const url = new URL(request.url || '/', this.config.publicOrigin)
if (url.pathname !== '/bff/events') return false
const ticket = url.searchParams.get('ticket')
const cursor = url.searchParams.get('cursor')
const loaded = await this.oauth.authenticated(request, false)
if (
loaded === null ||
ticket === null ||
cursor === null ||
!validBrowserContext(request, this.config)
) {
socketEvent('websocket_rejected')
rejectUpgrade(socket, 401)
return true
}
const target = new URL(this.config.eventsUpstream)
target.search = new URLSearchParams({ ticket, cursor }).toString()
this.webSockets.handleUpgrade(request, socket, head, (browser) => {
const upstream = new WebSocket(target, {
handshakeTimeout: 15_000,
maxPayload: MAX_EVENT_BYTES,
origin: this.config.publicOrigin,
})
upstream.on('open', () => socketEvent('websocket_upstream_open'))
upstream.on('unexpected-response', (_request, response) =>
socketEvent('websocket_upstream_rejected', response.statusCode),
)
upstream.on('error', () => socketEvent('websocket_upstream_error'))
relay(browser, upstream)
})
return true
}
}
function copyHeader(request, headers, name) {
const value = request.headers[name]
if (typeof value === 'string') headers[name] = value
}
async function requestBody(request) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > MAX_REQUEST_BYTES) throw new RequestBodyTooLarge()
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
class RequestBodyTooLarge extends Error {}
function relay(browser, upstream) {
upstream.on('open', () => undefined)
browser.on('message', (data, binary) => {
if (upstream.readyState === WebSocket.OPEN) upstream.send(data, { binary })
})
upstream.on('message', (data, binary) => {
if (browser.readyState === WebSocket.OPEN) browser.send(data, { binary })
})
const closeBrowser = (code = 1011, reason = '') => {
if (browser.readyState === WebSocket.OPEN) browser.close(code, reason)
}
const closeUpstream = (code = 1011, reason = '') => {
if (upstream.readyState === WebSocket.OPEN) upstream.close(code, reason)
}
upstream.on('close', closeBrowser)
browser.on('close', closeUpstream)
upstream.on('error', () => closeBrowser())
browser.on('error', () => closeUpstream())
}
function rejectUpgrade(socket, status) {
socket.end(`HTTP/1.1 ${status} Unauthorized\r\nConnection: close\r\n\r\n`)
}
function socketEvent(event, status) {
requestEvent(event, status === undefined ? {} : { status })
}
function requestEvent(event, details) {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: event.endsWith('open') ? 'info' : 'warn',
component: 'remora-web',
event,
...details,
})}\n`,
)
}

View file

@ -0,0 +1,149 @@
import assert from 'node:assert/strict'
import { Readable } from 'node:stream'
import { test } from 'node:test'
import { validBrowserContext, validUnsafeRequest } from './oauth.mjs'
import { ApiProxy } from './proxy.mjs'
import {
SESSION_COOKIE,
expiredSessionCookie,
sessionCookie,
sessionIdentifier,
} from './sessions.mjs'
const config = {
publicOrigin: 'https://remora.example.com',
secureCookie: true,
}
test('session cookies are host-only, opaque, secure, and not script-readable', () => {
const cookie = sessionCookie('opaque-session', config)
assert.equal(
cookie,
`${SESSION_COOKIE}=opaque-session; Path=/; HttpOnly; Secure; SameSite=Strict`,
)
assert.doesNotMatch(cookie, /Domain=/)
assert.match(expiredSessionCookie(config), /Max-Age=0/)
assert.equal(
sessionIdentifier({
headers: { cookie: `unrelated=1; ${SESSION_COOKIE}=opaque-session` },
}),
'opaque-session',
)
})
test('unsafe requests require both same-origin context and the CSRF value', () => {
assert.equal(
validUnsafeRequest(
{
headers: {
origin: config.publicOrigin,
'sec-fetch-site': 'same-origin',
'x-remora-csrf': 'csrf-value',
},
},
config,
'csrf-value',
),
true,
)
assert.equal(
validUnsafeRequest(
{
headers: {
origin: 'https://attacker.invalid',
'sec-fetch-site': 'cross-site',
'x-remora-csrf': 'csrf-value',
},
},
config,
'csrf-value',
),
false,
)
assert.equal(
validUnsafeRequest(
{
headers: {
origin: config.publicOrigin,
'sec-fetch-site': 'same-origin',
'x-remora-csrf': 'wrong',
},
},
config,
'csrf-value',
),
false,
)
})
test('browser WebSocket context must be same-origin', () => {
assert.equal(
validBrowserContext(
{
headers: {
origin: config.publicOrigin,
'sec-fetch-site': 'same-origin',
},
},
config,
),
true,
)
assert.equal(
validBrowserContext(
{
headers: {
origin: 'https://attacker.invalid',
'sec-fetch-site': 'cross-site',
},
},
config,
),
false,
)
})
test('event ticket requests use the validated public origin upstream', async () => {
let upstreamOptions
const proxy = new ApiProxy(
{
...config,
apiUpstream: new URL('https://api.remora.invalid/v1/'),
eventsUpstream: new URL('wss://api.remora.invalid/v1/events'),
},
{
authenticated: async () => ({
rotated: false,
session: {
accessToken: 'server-token',
csrfToken: 'csrf-value',
},
}),
},
async (_url, options) => {
upstreamOptions = options
return new Response('{}', {
status: 201,
headers: { 'Content-Type': 'application/json' },
})
},
)
const request = Readable.from([])
request.method = 'POST'
request.headers = {
origin: config.publicOrigin,
'sec-fetch-site': 'same-origin',
'x-remora-csrf': 'csrf-value',
}
const response = {
writeHead: () => undefined,
end: () => undefined,
}
await proxy.route(
request,
response,
new URL(`${config.publicOrigin}/bff/api/event-tickets`),
)
assert.equal(upstreamOptions.headers.Origin, config.publicOrigin)
})

89
web/server/sessions.mjs Normal file
View file

@ -0,0 +1,89 @@
import { randomBytes, timingSafeEqual } from 'node:crypto'
import { createClient } from 'redis'
export const SESSION_COOKIE = '__Host-remora-session'
export class SessionStore {
constructor(config, client = createClient({ url: config.sessionUrl })) {
this.config = config
this.client = client
}
async connect() {
this.client.on('error', () => undefined)
await this.client.connect()
}
get ready() {
return this.client.isReady
}
async create(value) {
const identifier = randomValue(32)
await this.client.set(this.key(identifier), JSON.stringify(value), {
EX: this.config.sessionTtlSeconds,
})
return identifier
}
async read(identifier) {
if (!validIdentifier(identifier)) return null
const encoded = await this.client.get(this.key(identifier))
if (encoded === null) return null
try {
return JSON.parse(encoded)
} catch {
await this.destroy(identifier)
return null
}
}
async replace(identifier, value) {
const replacement = await this.create(value)
await this.destroy(identifier)
return replacement
}
async destroy(identifier) {
if (validIdentifier(identifier)) await this.client.del(this.key(identifier))
}
key(identifier) {
return `${this.config.sessionNamespace}${identifier}`
}
}
export function sessionIdentifier(request) {
const cookies = String(request.headers.cookie || '').split(';')
for (const cookie of cookies) {
const separator = cookie.indexOf('=')
if (separator === -1) continue
if (cookie.slice(0, separator).trim() === SESSION_COOKIE)
return cookie.slice(separator + 1).trim()
}
return null
}
export function sessionCookie(identifier, config) {
const secure = config.secureCookie ? '; Secure' : ''
return `${SESSION_COOKIE}=${identifier}; Path=/; HttpOnly${secure}; SameSite=Strict`
}
export function expiredSessionCookie(config) {
return `${sessionCookie('', config)}; Max-Age=0`
}
export function randomValue(bytes = 24) {
return randomBytes(bytes).toString('base64url')
}
export function equalSecret(left, right) {
const a = Buffer.from(String(left))
const b = Buffer.from(String(right))
return a.length === b.length && timingSafeEqual(a, b)
}
function validIdentifier(value) {
return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value)
}

View file

@ -85,7 +85,7 @@ test('clears workflow-local authority on an authenticated account change', async
fixture.session.emit({
kind: 'authenticated',
session: {
accessToken: 'token-two',
csrfToken: 'token-two',
identity: 'issuer\u0000other-subject',
},
})
@ -380,6 +380,7 @@ test('renders credential form errors through the accessible error boundary', asy
await expect
.element(screen.getByRole('alert'))
.toHaveTextContent('Enter one header per line as name: value')
await expect.element(screen.getByLabelText('New headers')).toBeInvalid()
})
function runtimeFixture(): {
@ -404,10 +405,10 @@ function runtimeFixture(): {
}
}
function authenticated(accessToken: string): SessionState {
function authenticated(csrfToken: string): SessionState {
return {
kind: 'authenticated',
session: { accessToken, identity: 'issuer\u0000subject' },
session: { csrfToken, identity: 'issuer\u0000subject' },
}
}
@ -451,9 +452,9 @@ class FakeApi extends SynchronizationApiFake {
canRevokeExecutionGrants = true
credential: SourceCredentialDto = sourceCredential('active')
async listJobs(accessToken: string): Promise<JobPageDto> {
async listJobs(csrfToken: string): Promise<JobPageDto> {
this.listRequests += 1
this.tokens.push(accessToken)
this.tokens.push(csrfToken)
return {
items: this.snapshots ?? [this.snapshot],
next_cursor: null,
@ -465,7 +466,7 @@ class FakeApi extends SynchronizationApiFake {
}
async getJob(
_accessToken: string,
_csrfToken: string,
_jobId: string,
): Promise<JobResourceDto | null> {
this.jobRequests += 1
@ -473,13 +474,13 @@ class FakeApi extends SynchronizationApiFake {
}
async getDeletion(
_accessToken: string,
_csrfToken: string,
_operationId: string,
): Promise<DeletionOperationDto | null> {
return this.deletion
}
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
async createEventTicket(_csrfToken: string): Promise<EventTicketDto> {
return {
ticket: 'ticket_00001',
expires_at: '2026-07-17T00:01:00Z',
@ -487,7 +488,7 @@ class FakeApi extends SynchronizationApiFake {
}
async submitJob(
_accessToken: string,
_csrfToken: string,
idempotencyKey: string,
submission: JobSubmissionDto,
): Promise<JobResourceDto> {
@ -506,7 +507,7 @@ class FakeApi extends SynchronizationApiFake {
}
async getResult(
_accessToken: string,
_csrfToken: string,
_jobId: string,
): Promise<ResultDescriptorDto | null> {
return result
@ -534,7 +535,7 @@ class FakeApi extends SynchronizationApiFake {
}
async mutateJob(
_accessToken: string,
_csrfToken: string,
_jobId: string,
operation: 'cancel' | 'soft-delete' | 'restore' | 'revoke',
idempotencyKey: string,
@ -556,7 +557,7 @@ class FakeApi extends SynchronizationApiFake {
}
async hardDeleteJob(
_accessToken: string,
_csrfToken: string,
_jobId: string,
idempotencyKey: string,
): Promise<DeletionOperationDto> {
@ -584,7 +585,7 @@ class FakeApi extends SynchronizationApiFake {
}
async createCredential(
_accessToken: string,
_csrfToken: string,
idempotencyKey: string,
credential: SourceCredentialCreateDto,
): Promise<SourceCredentialDto> {
@ -598,7 +599,7 @@ class FakeApi extends SynchronizationApiFake {
}
async rotateCredential(
_accessToken: string,
_csrfToken: string,
_credentialId: string,
idempotencyKey: string,
_rotation: SourceCredentialRotateDto,
@ -615,7 +616,7 @@ class FakeApi extends SynchronizationApiFake {
}
async revokeCredential(
_accessToken: string,
_csrfToken: string,
_credentialId: string,
idempotencyKey: string,
): Promise<SourceCredentialDto> {

File diff suppressed because it is too large Load diff

View file

@ -30,65 +30,65 @@ import type {
} from './contracts'
export interface PublicApi {
listJobs(accessToken: string, cursor?: string): Promise<JobPageDto>
getJob(accessToken: string, jobId: string): Promise<JobResourceDto | null>
listJobs(csrfToken: string, cursor?: string): Promise<JobPageDto>
getJob(csrfToken: string, jobId: string): Promise<JobResourceDto | null>
getDeletion(
accessToken: string,
csrfToken: string,
operationId: string,
): Promise<DeletionOperationDto | null>
createEventTicket(accessToken: string): Promise<EventTicketDto>
createEventTicket(csrfToken: string): Promise<EventTicketDto>
submitJob(
accessToken: string,
csrfToken: string,
idempotencyKey: string,
submission: JobSubmissionDto,
): Promise<JobResourceDto>
getResult(
accessToken: string,
csrfToken: string,
jobId: string,
): Promise<ResultDescriptorDto | null>
listResultMembers(
accessToken: string,
csrfToken: string,
jobId: string,
cursor?: string,
): Promise<ResultMemberPageDto>
accessResultMember(
accessToken: string,
csrfToken: string,
jobId: string,
memberId: string,
): Promise<ResultMemberAccessDto>
mutateJob(
accessToken: string,
csrfToken: string,
jobId: string,
operation: 'cancel' | 'soft-delete' | 'restore' | 'revoke',
idempotencyKey: string,
): Promise<JobResourceDto>
hardDeleteJob(
accessToken: string,
csrfToken: string,
jobId: string,
idempotencyKey: string,
): Promise<DeletionOperationDto>
listAttempts(
accessToken: string,
csrfToken: string,
jobId: string,
cursor?: string,
): Promise<AttemptPageDto>
listCredentials(
accessToken: string,
csrfToken: string,
cursor?: string,
): Promise<SourceCredentialPageDto>
createCredential(
accessToken: string,
csrfToken: string,
idempotencyKey: string,
credential: SourceCredentialCreateDto,
): Promise<SourceCredentialDto>
rotateCredential(
accessToken: string,
csrfToken: string,
credentialId: string,
idempotencyKey: string,
rotation: SourceCredentialRotateDto,
): Promise<SourceCredentialDto>
revokeCredential(
accessToken: string,
csrfToken: string,
credentialId: string,
idempotencyKey: string,
): Promise<SourceCredentialDto>
@ -114,50 +114,50 @@ export class FetchPublicApi implements PublicApi {
this.#fetch = fetchImplementation ?? globalThis.fetch.bind(globalThis)
}
async listJobs(accessToken: string, cursor?: string): Promise<JobPageDto> {
async listJobs(csrfToken: string, cursor?: string): Promise<JobPageDto> {
const url = this.#url('jobs')
url.searchParams.set('include_deleted', 'true')
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
return decodeJobPage(await this.#request(url, accessToken))
return decodeJobPage(await this.#request(url, csrfToken))
}
async getJob(
accessToken: string,
csrfToken: string,
jobId: string,
): Promise<JobResourceDto | null> {
const response = await this.#requestOrMissing(
this.#url(`jobs/${encodeURIComponent(jobId)}`),
accessToken,
csrfToken,
)
return response === null ? null : decodeJobResource(response)
}
async getDeletion(
accessToken: string,
csrfToken: string,
operationId: string,
): Promise<DeletionOperationDto | null> {
const response = await this.#requestOrMissing(
this.#url(`deletions/${encodeURIComponent(operationId)}`),
accessToken,
csrfToken,
)
return response === null ? null : decodeDeletionOperation(response)
}
async createEventTicket(accessToken: string): Promise<EventTicketDto> {
async createEventTicket(csrfToken: string): Promise<EventTicketDto> {
return decodeEventTicket(
await this.#request(this.#url('event-tickets'), accessToken, {
await this.#request(this.#url('event-tickets'), csrfToken, {
method: 'POST',
}),
)
}
async submitJob(
accessToken: string,
csrfToken: string,
idempotencyKey: string,
submission: JobSubmissionDto,
): Promise<JobResourceDto> {
return decodeJobResource(
await this.#request(this.#url('jobs'), accessToken, {
await this.#request(this.#url('jobs'), csrfToken, {
method: 'POST',
idempotencyKey,
body: submission,
@ -166,28 +166,28 @@ export class FetchPublicApi implements PublicApi {
}
async getResult(
accessToken: string,
csrfToken: string,
jobId: string,
): Promise<ResultDescriptorDto | null> {
const response = await this.#requestOrMissing(
this.#url(`jobs/${encodeURIComponent(jobId)}/result`),
accessToken,
csrfToken,
)
return response === null ? null : decodeResultDescriptor(response)
}
async listResultMembers(
accessToken: string,
csrfToken: string,
jobId: string,
cursor?: string,
): Promise<ResultMemberPageDto> {
const url = this.#url(`jobs/${encodeURIComponent(jobId)}/result/members`)
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
return decodeResultMemberPage(await this.#request(url, accessToken))
return decodeResultMemberPage(await this.#request(url, csrfToken))
}
async accessResultMember(
accessToken: string,
csrfToken: string,
jobId: string,
memberId: string,
): Promise<ResultMemberAccessDto> {
@ -196,13 +196,13 @@ export class FetchPublicApi implements PublicApi {
this.#url(
`jobs/${encodeURIComponent(jobId)}/result/members/${encodeURIComponent(memberId)}/content`,
),
accessToken,
csrfToken,
),
)
}
async mutateJob(
accessToken: string,
csrfToken: string,
jobId: string,
operation: 'cancel' | 'soft-delete' | 'restore' | 'revoke',
idempotencyKey: string,
@ -210,52 +210,52 @@ export class FetchPublicApi implements PublicApi {
return decodeJobResource(
await this.#request(
this.#url(`jobs/${encodeURIComponent(jobId)}/${operation}`),
accessToken,
csrfToken,
{ method: 'POST', idempotencyKey },
),
)
}
async hardDeleteJob(
accessToken: string,
csrfToken: string,
jobId: string,
idempotencyKey: string,
): Promise<DeletionOperationDto> {
return decodeDeletionOperation(
await this.#request(
this.#url(`jobs/${encodeURIComponent(jobId)}/hard-delete`),
accessToken,
csrfToken,
{ method: 'POST', idempotencyKey },
),
)
}
async listAttempts(
accessToken: string,
csrfToken: string,
jobId: string,
cursor?: string,
): Promise<AttemptPageDto> {
const url = this.#url(`jobs/${encodeURIComponent(jobId)}/attempts`)
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
return decodeAttemptPage(await this.#request(url, accessToken))
return decodeAttemptPage(await this.#request(url, csrfToken))
}
async listCredentials(
accessToken: string,
csrfToken: string,
cursor?: string,
): Promise<SourceCredentialPageDto> {
const url = this.#url('source-credentials')
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
return decodeSourceCredentialPage(await this.#request(url, accessToken))
return decodeSourceCredentialPage(await this.#request(url, csrfToken))
}
async createCredential(
accessToken: string,
csrfToken: string,
idempotencyKey: string,
credential: SourceCredentialCreateDto,
): Promise<SourceCredentialDto> {
return decodeSourceCredential(
await this.#request(this.#url('source-credentials'), accessToken, {
await this.#request(this.#url('source-credentials'), csrfToken, {
method: 'POST',
idempotencyKey,
body: credential,
@ -264,7 +264,7 @@ export class FetchPublicApi implements PublicApi {
}
async rotateCredential(
accessToken: string,
csrfToken: string,
credentialId: string,
idempotencyKey: string,
rotation: SourceCredentialRotateDto,
@ -274,14 +274,14 @@ export class FetchPublicApi implements PublicApi {
this.#url(
`source-credentials/${encodeURIComponent(credentialId)}/versions`,
),
accessToken,
csrfToken,
{ method: 'POST', idempotencyKey, body: rotation },
),
)
}
async revokeCredential(
accessToken: string,
csrfToken: string,
credentialId: string,
idempotencyKey: string,
): Promise<SourceCredentialDto> {
@ -290,7 +290,7 @@ export class FetchPublicApi implements PublicApi {
this.#url(
`source-credentials/${encodeURIComponent(credentialId)}/revoke`,
),
accessToken,
csrfToken,
{ method: 'POST', idempotencyKey },
),
)
@ -302,7 +302,7 @@ export class FetchPublicApi implements PublicApi {
async #request(
url: URL,
accessToken: string,
csrfToken: string,
options: {
method?: 'GET' | 'POST'
idempotencyKey?: string
@ -311,7 +311,7 @@ export class FetchPublicApi implements PublicApi {
): Promise<unknown> {
const headers: Record<string, string> = {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
'X-Remora-CSRF': csrfToken,
}
if (options.idempotencyKey !== undefined)
headers['Idempotency-Key'] = options.idempotencyKey
@ -323,15 +323,15 @@ export class FetchPublicApi implements PublicApi {
? {}
: { body: JSON.stringify(options.body) }),
cache: 'no-store',
credentials: 'omit',
credentials: 'same-origin',
})
if (!response.ok) throw await apiResponseError(response)
return response.json()
}
async #requestOrMissing(url: URL, accessToken: string): Promise<unknown> {
async #requestOrMissing(url: URL, csrfToken: string): Promise<unknown> {
try {
return await this.#request(url, accessToken)
return await this.#request(url, csrfToken)
} catch (error) {
if (error instanceof ApiResponseError && error.status === 404) return null
throw error

View file

@ -27,10 +27,10 @@ describe('adapted public API client', () => {
)
expect(init).toMatchObject({
cache: 'no-store',
credentials: 'omit',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
Authorization: 'Bearer access-token',
'X-Remora-CSRF': 'access-token',
},
})
})
@ -109,7 +109,7 @@ describe('adapted public API client', () => {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: 'Bearer token',
'X-Remora-CSRF': 'token',
'Content-Type': 'application/json',
'Idempotency-Key': 'intent_0001',
},
@ -168,7 +168,7 @@ describe('adapted public API client', () => {
await expect(
api.accessResultMember('token', 'job_00000001', 'member_00001'),
).rejects.toThrow('invalid member access URL')
).rejects.toThrow('$.url has an invalid format')
})
})
@ -181,13 +181,13 @@ describe('snapshot wire decoding', () => {
event_cursor: 'cursor-snapshot-0001',
management_capabilities: { revoke_execution_grants: false },
}),
).toThrow('unknown DTO field')
).toThrow('does not match its union')
expect(() =>
decodeEventTicket({
ticket: 'short',
expires_at: '2026-07-17T00:01:00Z',
}),
).toThrow('invalid opaque identifier')
).toThrow('$.ticket has an invalid format')
})
})

View file

@ -1,16 +1,98 @@
import type { JobPhase, RuntimeFailureClass } from '../domain/vocabulary'
// Generated from contracts/typespec/main.tsp. Do not edit.
export type CursorDto = { cursor?: string }
export type LimitDto = { limit?: number }
export type IdempotencyKeyDto = { 'Idempotency-Key': string }
export type JobIdDto = { job_id: OpaqueIdDto }
export type OpaqueIdDto = string
export interface TargetDto {
export type CredentialIdParameterDto = { credential_id: CredentialIdDto }
export type CredentialIdDto = string
export type AttemptIdDto = { attempt_id: OpaqueIdDto }
export type TargetDto = { store: string; bucket: BucketNameDto; prefix: string }
export type BucketNameDto = string
export type TargetOverrideDto = { bucket?: BucketNameDto; prefix?: string }
export type StagingTargetDto = {
store: string
bucket: string
bucket: BucketNameDto
prefix: string
}
export interface TargetOverrideDto {
bucket?: string
prefix?: string
export type HttpSourceSubmissionDto = {
kind: 'http'
url: string
result_path: ResultPathDto
credential_id?: CredentialIdDto
}
export type ResultPathDto = string
export type ObjectStoreSourceSubmissionDto = {
kind: 'object_store'
store: string
bucket: string
key: string
version_id?: string
result_path: ResultPathDto
credential_id?: CredentialIdDto
}
export type HuggingFaceSelectionDto = {
paths?: Array<ResultPathDto>
prefixes?: Array<ResultPathDto>
}
export type HuggingFaceSourceSubmissionDto = {
kind: 'hugging_face'
repository_type: 'model' | 'dataset' | 'space'
repository_id: HuggingFaceRepositoryIdDto
revision: string
selection?: HuggingFaceSelectionDto
credential_id?: CredentialIdDto
}
export type HuggingFaceRepositoryIdDto = string
export type HttpSourceProjectionDto = {
kind: 'http'
display_url: string
result_path: ResultPathDto
credential_id?: CredentialIdDto
}
export type ObjectStoreSourceProjectionDto = {
kind: 'object_store'
store: string
bucket: string
key: string
version_id?: string
result_path: ResultPathDto
credential_id?: CredentialIdDto
}
export type HuggingFaceSourceProjectionDto = {
kind: 'hugging_face'
repository_type: 'model' | 'dataset' | 'space'
repository_id: HuggingFaceRepositoryIdDto
revision: string
selection?: HuggingFaceSelectionDto
credential_id?: CredentialIdDto
}
export type JobSubmissionDto = {
source: SourceSubmissionDto
target?: TargetOverrideDto
label?: string
}
export type SourceSubmissionDto =
@ -18,50 +100,115 @@ export type SourceSubmissionDto =
| ObjectStoreSourceSubmissionDto
| HuggingFaceSourceSubmissionDto
export interface HttpSourceSubmissionDto {
kind: 'http'
url: string
result_path: string
credential_id?: OpaqueIdDto
export type BasicAuthMaterialDto = { username: string; password: string }
export type TokenMaterialDto = { token: string }
export type HttpHeadersMaterialDto = { headers: RecordStringDto }
export type RecordStringDto = {}
export type ObjectStoreCredentialMaterialDto = {
access_key_id: string
secret_access_key: string
session_token?: string
}
export interface ObjectStoreSourceSubmissionDto {
export type BasicAuthCredentialCreateDto = {
label: string
kind: 'basic_auth'
material: BasicAuthMaterialDto
}
export type BearerTokenCredentialCreateDto = {
label: string
kind: 'bearer_token'
material: TokenMaterialDto
}
export type HttpHeadersCredentialCreateDto = {
label: string
kind: 'http_headers'
material: HttpHeadersMaterialDto
}
export type ObjectStoreCredentialCreateDto = {
label: string
kind: 'object_store'
store: string
bucket: string
key: string
version_id?: string
result_path: string
credential_id?: OpaqueIdDto
material: ObjectStoreCredentialMaterialDto
}
export interface HuggingFaceSelectionDto {
paths?: string[]
prefixes?: string[]
}
export interface HuggingFaceSourceSubmissionDto {
export type HuggingFaceCredentialCreateDto = {
label: string
kind: 'hugging_face'
repository_type: 'model' | 'dataset' | 'space'
repository_id: string
revision: string
selection?: HuggingFaceSelectionDto
credential_id?: OpaqueIdDto
material: TokenMaterialDto
}
export interface JobSubmissionDto {
source: SourceSubmissionDto
target?: TargetOverrideDto
label?: string
export type SourceCredentialRotateDto = { material: CredentialMaterialDto }
export type CredentialMaterialDto =
| BasicAuthMaterialDto
| TokenMaterialDto
| HttpHeadersMaterialDto
| ObjectStoreCredentialMaterialDto
export type SourceCredentialVersionDto = {
id: CredentialVersionIdDto
created_at: string
}
export interface AttemptAuthorityDto {
export type CredentialVersionIdDto = string
export type CredentialGrantVersionDto = {
credential_id: CredentialIdDto
version_id: CredentialVersionIdDto
}
export type SourceCredentialDto = {
id: CredentialIdDto
owner: PrincipalIdDto
label: string
kind: SourceCredentialKindDto
status: 'active' | 'revoked'
active_version: SourceCredentialVersionDto | null
created_at: string
updated_at: string
}
export type PrincipalIdDto = string
export type SourceCredentialKindDto =
| 'basic_auth'
| 'bearer_token'
| 'http_headers'
| 'object_store'
| 'hugging_face'
export type SourceCredentialPageDto = {
items: Array<SourceCredentialDto>
next_cursor: string | null
}
export type JobPhaseProjectionDto = { phase: JobPhaseDto }
export type JobPhaseDto =
| 'Queued'
| 'Running'
| 'RetryWait'
| 'Cancelling'
| 'Succeeded'
| 'Failed'
| 'Cancelled'
export type AttemptAuthorityDto = {
attempt_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
}
export interface ProgressDto {
export type LeaseProjectionDto = { expires_at: string; renew_after: string }
export type ProgressDto = {
attempt_id: OpaqueIdDto
fence: number
completed_bytes: number
@ -71,47 +218,63 @@ export interface ProgressDto {
observed_at: string
}
export type JobFailureDto =
| {
class:
| 'validation'
| 'authorization'
| 'source_not_found'
| 'source_changed'
| 'cancelled'
message: string
retryable: false
safe_details?: Record<string, string | number | boolean | null>
}
| {
class:
| 'transient_source'
| 'transient_target'
| 'integrity'
| 'capacity'
| 'internal'
| 'lost_attempt'
message: string
retryable: true
safe_details?: Record<string, string | number | boolean | null>
}
export interface AttemptOutcomeRequestDto {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
outcome:
| { kind: 'success'; result: ResultDescriptorDto }
| { kind: 'failure'; failure: AttemptRuntimeFailureDto }
export type ProgressValueDto = {
completed_bytes: number
total_bytes?: number | null
completed_members: number
total_members?: number | null
}
export interface AttemptRuntimeFailureDto {
class: RuntimeFailureClass
export type CheckpointProjectionDto = {
reusable: boolean
validated_bytes: number
validated_members: number
}
export type PermanentJobFailureDto = {
class:
| 'validation'
| 'authorization'
| 'source_not_found'
| 'source_changed'
| 'cancelled'
retryable: false
message: string
safe_details?: Record<string, string | number | boolean | null>
safe_details?: RecordSafeDetailValueDto
}
export interface ResultDescriptorDto {
export type RecordSafeDetailValueDto = {}
export type SafeDetailValueDto = string | number | boolean | null
export type RetryableJobFailureDto = {
class:
| 'transient_source'
| 'transient_target'
| 'integrity'
| 'capacity'
| 'internal'
| 'lost_attempt'
retryable: true
message: string
safe_details?: RecordSafeDetailValueDto
}
export type AttemptRuntimeFailureDto = {
class:
| 'authorization'
| 'source_not_found'
| 'source_changed'
| 'transient_source'
| 'transient_target'
| 'integrity'
| 'capacity'
| 'internal'
message: string
safe_details?: RecordSafeDetailValueDto
}
export type ResultDescriptorDto = {
kind: 'manifest'
manifest_id: OpaqueIdDto
schema_version: number
@ -120,224 +283,48 @@ export interface ResultDescriptorDto {
total_bytes: number
}
export interface ObjectIdentityDto {
export type ObjectIdentityDto = {
store: string
bucket: string
key: string
version_id?: string
checksum: string
checksum: Sha256DigestDto
size: number
}
export interface JobPhaseProjectionDto {
phase: JobPhase
}
export type Sha256DigestDto = string
interface EventBaseDto {
schema_version: 1
event_id: OpaqueIdDto
sequence: number
cursor: string
occurred_at: string
}
export type EventFrameDto =
JobChangedDto | DeletionChangedDto | ResyncRequiredDto | HeartbeatDto
export interface JobChangedDto extends EventBaseDto {
kind: 'job_changed'
job_id: OpaqueIdDto
job_revision: number
phase: JobPhase
lifecycle: 'visible' | 'soft_deleted' | 'hard_deleting' | 'hard_deleted'
authoritative_attempt: { attempt_id: OpaqueIdDto; fence: number } | null
progress: EventProgressDto | null
}
export type EventProgressDto = Omit<ProgressDto, 'observed_at'>
export interface DeletionChangedDto extends EventBaseDto {
kind: 'deletion_changed'
job_id: OpaqueIdDto
operation_id: OpaqueIdDto
state: 'queued' | 'running' | 'retry_wait' | 'blocked' | 'completed'
}
export interface ResyncRequiredDto extends EventBaseDto {
kind: 'resync_required'
reason:
| 'cursor_absent'
| 'cursor_invalid'
| 'cursor_expired'
| 'authorization_changed'
| 'history_unavailable'
}
export interface HeartbeatDto extends EventBaseDto {
kind: 'heartbeat'
}
export interface EventTicketDto {
ticket: OpaqueIdDto
expires_at: string
}
export type SourceCredentialKindDto =
| 'basic_auth'
| 'bearer_token'
| 'http_headers'
| 'object_store'
| 'hugging_face'
export interface BasicAuthMaterialDto {
username: string
password: string
}
export interface TokenMaterialDto {
token: string
}
export interface HttpHeadersMaterialDto {
headers: Record<string, string>
}
export interface ObjectStoreCredentialMaterialDto {
access_key_id: string
secret_access_key: string
session_token?: string
}
export type CredentialMaterialDto =
| BasicAuthMaterialDto
| TokenMaterialDto
| HttpHeadersMaterialDto
| ObjectStoreCredentialMaterialDto
export type SourceCredentialCreateDto =
| { kind: 'basic_auth'; label: string; material: BasicAuthMaterialDto }
| { kind: 'bearer_token'; label: string; material: TokenMaterialDto }
| { kind: 'http_headers'; label: string; material: HttpHeadersMaterialDto }
| {
kind: 'object_store'
label: string
material: ObjectStoreCredentialMaterialDto
}
| { kind: 'hugging_face'; label: string; material: TokenMaterialDto }
export interface SourceCredentialRotateDto {
material: CredentialMaterialDto
}
export interface SourceCredentialVersionDto {
id: OpaqueIdDto
created_at: string
}
export interface CredentialGrantVersionDto {
credential_id: OpaqueIdDto
version_id: OpaqueIdDto
}
export interface SourceCredentialDto {
id: OpaqueIdDto
owner: OpaqueIdDto
label: string
kind: SourceCredentialKindDto
status: 'active' | 'revoked'
active_version: SourceCredentialVersionDto | null
created_at: string
updated_at: string
}
export interface SourceCredentialPageDto {
items: SourceCredentialDto[]
next_cursor: string | null
}
export type SourceProjectionDto =
| HttpSourceProjectionDto
| ObjectStoreSourceProjectionDto
| HuggingFaceSourceProjectionDto
export interface HttpSourceProjectionDto {
kind: 'http'
display_url: string
result_path: string
credential_id?: OpaqueIdDto
}
export interface ObjectStoreSourceProjectionDto {
kind: 'object_store'
store: string
bucket: string
key: string
version_id?: string
result_path: string
credential_id?: OpaqueIdDto
}
export interface HuggingFaceSourceProjectionDto {
kind: 'hugging_face'
repository_type: 'model' | 'dataset' | 'space'
repository_id: string
revision: string
selection?: HuggingFaceSelectionDto
credential_id?: OpaqueIdDto
}
export interface LeaseProjectionDto {
expires_at: string
renew_after: string
}
export interface ProgressValueDto {
completed_bytes: number
total_bytes?: number | null
completed_members: number
total_members?: number | null
}
export interface CheckpointProjectionDto {
reusable: boolean
validated_bytes: number
validated_members: number
}
export interface DurableDescriptorRefDto {
export type DurableDescriptorRefDto = {
id: OpaqueIdDto
kind: 'resolved_source' | 'checkpoint'
schema_version: number
byte_length: number
item_count?: number
checksum: string
checksum: Sha256DigestDto
}
export interface ResultMemberDto {
export type ResultMemberDto = {
id: OpaqueIdDto
path: string
size: number
object: ObjectIdentityDto
}
export interface ResultMemberPageDto {
items: ResultMemberDto[]
export type ResultMemberPageDto = {
items: Array<ResultMemberDto>
next_cursor: string | null
}
export interface ResultMemberAccessDto {
url: string
expires_at: string
}
export type ResultMemberAccessDto = { url: string; expires_at: string }
export interface JobDto {
export type JobDto = {
resource_kind: 'job'
id: OpaqueIdDto
owner: OpaqueIdDto
owner: PrincipalIdDto
label: string | null
revision: number
phase: JobPhase
lifecycle: 'visible' | 'soft_deleted' | 'hard_deleting' | 'hard_deleted'
phase: JobPhaseDto
lifecycle: JobLifecycleDto
source: SourceProjectionDto
target: TargetDto
authoritative_attempt: AttemptAuthorityDto | null
@ -350,12 +337,22 @@ export interface JobDto {
event_cursor: string
}
export interface JobTombstoneDto {
export type JobLifecycleDto =
'visible' | 'soft_deleted' | 'hard_deleting' | 'hard_deleted'
export type SourceProjectionDto =
| HttpSourceProjectionDto
| ObjectStoreSourceProjectionDto
| HuggingFaceSourceProjectionDto
export type JobFailureDto = PermanentJobFailureDto | RetryableJobFailureDto
export type JobTombstoneDto = {
resource_kind: 'tombstone'
id: OpaqueIdDto
owner: OpaqueIdDto
owner: PrincipalIdDto
target: TargetDto
terminal_phase: 'Succeeded' | 'Failed' | 'Cancelled'
terminal_phase: TerminalJobPhaseDto
deletion_operation_id: OpaqueIdDto
created_at: string
hard_delete_started_at: string
@ -363,20 +360,20 @@ export interface JobTombstoneDto {
event_cursor: string
}
export type JobResourceDto = JobDto | JobTombstoneDto
export type TerminalJobPhaseDto = 'Succeeded' | 'Failed' | 'Cancelled'
export interface ManagementCapabilitiesDto {
revoke_execution_grants: boolean
}
export interface JobPageDto {
items: JobResourceDto[]
export type JobPageDto = {
items: Array<JobResourceDto>
next_cursor: string | null
event_cursor: string
management_capabilities: ManagementCapabilitiesDto
}
export interface AttemptRecordDto {
export type JobResourceDto = JobDto | JobTombstoneDto
export type ManagementCapabilitiesDto = { revoke_execution_grants: boolean }
export type AttemptRecordDto = {
id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
@ -392,17 +389,17 @@ export interface AttemptRecordDto {
updated_at: string
}
export interface AttemptPageDto {
items: AttemptRecordDto[]
export type AttemptPageDto = {
items: Array<AttemptRecordDto>
next_cursor: string | null
}
export interface DeletionProblemDto {
export type DeletionProblemDto = {
class: 'authorization' | 'target_unavailable' | 'configuration' | 'internal'
message: string
}
export interface DeletionOperationDto {
export type DeletionOperationDto = {
id: OpaqueIdDto
job_id: OpaqueIdDto
state: 'queued' | 'running' | 'retry_wait' | 'blocked' | 'completed'
@ -411,7 +408,9 @@ export interface DeletionOperationDto {
updated_at: string
}
export interface ApiErrorDto {
export type EventTicketDto = { ticket: OpaqueIdDto; expires_at: string }
export type ApiErrorDto = {
code:
| 'invalid_request'
| 'unauthenticated'
@ -426,17 +425,15 @@ export interface ApiErrorDto {
| 'internal'
message: string
request_id: OpaqueIdDto
fields?: { pointer: string; code: string }[]
fields?: Array<{ pointer: string; code: string }>
}
export type StagingTargetDto = TargetDto
export interface ClaimRequestDto {
export type ClaimRequestDto = {
operation_id: OpaqueIdDto
worker_incarnation: OpaqueIdDto
}
export interface ExecutionEnvelopeDto {
export type ExecutionEnvelopeDto = {
id: OpaqueIdDto
job_id: OpaqueIdDto
attempt_id: OpaqueIdDto
@ -452,32 +449,191 @@ export interface ExecutionEnvelopeDto {
checkpoint_ref: DurableDescriptorRefDto | null
}
export interface ClaimResponseDto {
export type ClaimResponseDto = {
authority: AttemptAuthorityDto
execution: ExecutionEnvelopeDto
}
export interface ActivationResponseDto extends ClaimResponseDto {
export type ActivationResponseDto = {
authority: AttemptAuthorityDto
execution: ExecutionEnvelopeDto
lease: LeaseProjectionDto
}
export interface AttemptMutationDto {
export type AttemptMutationDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
}
export type AttemptRenewRequestDto = AttemptMutationDto
export type AttemptActivateRequestDto = AttemptMutationDto
export interface AttemptResolvedSourceRequestDto extends AttemptMutationDto {
resolved_source_ref: DurableDescriptorRefDto & { kind: 'resolved_source' }
export type AttemptRenewRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
}
export interface AttemptCheckpointRequestDto extends AttemptMutationDto {
checkpoint_ref: DurableDescriptorRefDto & { kind: 'checkpoint' }
export type AttemptActivateRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
}
export interface AttemptProgressRequestDto extends AttemptMutationDto {
export type AttemptStopRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
}
export type StagingObjectIdentityDto = {
store: string
bucket: string
key: string
version_id?: string
}
export type StagingWriteIntentDto = {
store: string
bucket: string
key: string
}
export type StagingMultipartIdentityDto = {
store: string
bucket: string
key: string
upload_id: string
}
export type AttemptStagingObjectRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
object: StagingObjectIdentityDto
completed_upload_id?: string
}
export type AttemptStagingWriteIntentRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
intent: StagingWriteIntentDto
}
export type AttemptStagingMultipartRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
multipart: StagingMultipartIdentityDto
}
export type ResolvedSourceDescriptorRefDto = {
id: OpaqueIdDto
kind: 'resolved_source'
schema_version: number
byte_length: number
item_count?: number
checksum: Sha256DigestDto
}
export type CheckpointDescriptorRefDto = {
id: OpaqueIdDto
kind: 'checkpoint'
schema_version: number
byte_length: number
item_count?: number
checksum: Sha256DigestDto
}
export type AttemptResolvedSourceRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
resolved_source_ref: ResolvedSourceDescriptorRefDto
}
export type AttemptCheckpointRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
checkpoint_ref: CheckpointDescriptorRefDto
resolved_source_id: OpaqueIdDto
validated_bytes: number
validated_members: number
staging_multipart?: StagingMultipartIdentityDto
}
export type AttemptProgressRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
progress: ProgressValueDto
}
export type AttemptOutcomeRequestDto = {
operation_id: OpaqueIdDto
fence: number
worker_incarnation: OpaqueIdDto
outcome:
| { kind: 'success'; result: ResultDescriptorDto }
| { kind: 'failure'; failure: AttemptRuntimeFailureDto }
}
export type EventAttemptDto = { attempt_id: OpaqueIdDto; fence: number }
export type EventProgressDto = {
attempt_id: OpaqueIdDto
fence: number
completed_bytes: number
total_bytes?: number | null
completed_members: number
total_members?: number | null
}
export type EventBaseDto = {
schema_version: 1
event_id: OpaqueIdDto
sequence: number
cursor: EventCursorDto
occurred_at: string
}
export type EventCursorDto = string
export type JobChangedDto = EventBaseDto & {
kind: 'job_changed'
job_id: OpaqueIdDto
job_revision: number
phase: JobPhaseDto
lifecycle: JobLifecycleDto
authoritative_attempt: EventAttemptDto | null
progress: EventProgressDto | null
}
export type DeletionChangedDto = EventBaseDto & {
kind: 'deletion_changed'
job_id: OpaqueIdDto
operation_id: OpaqueIdDto
state: 'queued' | 'running' | 'retry_wait' | 'blocked' | 'completed'
}
export type ResyncRequiredDto = EventBaseDto & {
kind: 'resync_required'
reason:
| 'cursor_absent'
| 'cursor_invalid'
| 'cursor_expired'
| 'authorization_changed'
| 'history_unavailable'
}
export type HeartbeatDto = EventBaseDto & { kind: 'heartbeat' }
export type SourceCredentialCreateDto =
| BasicAuthCredentialCreateDto
| BearerTokenCredentialCreateDto
| HttpHeadersCredentialCreateDto
| ObjectStoreCredentialCreateDto
| HuggingFaceCredentialCreateDto
export type EventFrameDto =
JobChangedDto | DeletionChangedDto | ResyncRequiredDto | HeartbeatDto

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -16,19 +16,19 @@ import type {
} from './contracts'
export abstract class SynchronizationApiFake implements PublicApi {
abstract listJobs(accessToken: string, cursor?: string): Promise<JobPageDto>
abstract listJobs(csrfToken: string, cursor?: string): Promise<JobPageDto>
abstract getJob(
accessToken: string,
csrfToken: string,
jobId: string,
): Promise<JobResourceDto | null>
abstract getDeletion(
accessToken: string,
csrfToken: string,
operationId: string,
): Promise<DeletionOperationDto | null>
abstract createEventTicket(accessToken: string): Promise<EventTicketDto>
abstract createEventTicket(csrfToken: string): Promise<EventTicketDto>
submitJob(
_accessToken: string,
_csrfToken: string,
_idempotencyKey: string,
_submission: JobSubmissionDto,
): Promise<JobResourceDto> {
@ -36,14 +36,14 @@ export abstract class SynchronizationApiFake implements PublicApi {
}
getResult(
_accessToken: string,
_csrfToken: string,
_jobId: string,
): Promise<ResultDescriptorDto | null> {
return unsupported()
}
listResultMembers(
_accessToken: string,
_csrfToken: string,
_jobId: string,
_cursor?: string,
): Promise<ResultMemberPageDto> {
@ -51,7 +51,7 @@ export abstract class SynchronizationApiFake implements PublicApi {
}
accessResultMember(
_accessToken: string,
_csrfToken: string,
_jobId: string,
_memberId: string,
): Promise<ResultMemberAccessDto> {
@ -59,7 +59,7 @@ export abstract class SynchronizationApiFake implements PublicApi {
}
mutateJob(
_accessToken: string,
_csrfToken: string,
_jobId: string,
_operation: 'cancel' | 'soft-delete' | 'restore' | 'revoke',
_idempotencyKey: string,
@ -68,7 +68,7 @@ export abstract class SynchronizationApiFake implements PublicApi {
}
hardDeleteJob(
_accessToken: string,
_csrfToken: string,
_jobId: string,
_idempotencyKey: string,
): Promise<DeletionOperationDto> {
@ -76,7 +76,7 @@ export abstract class SynchronizationApiFake implements PublicApi {
}
listAttempts(
_accessToken: string,
_csrfToken: string,
_jobId: string,
_cursor?: string,
): Promise<AttemptPageDto> {
@ -84,14 +84,14 @@ export abstract class SynchronizationApiFake implements PublicApi {
}
listCredentials(
_accessToken: string,
_csrfToken: string,
_cursor?: string,
): Promise<SourceCredentialPageDto> {
return unsupported()
}
createCredential(
_accessToken: string,
_csrfToken: string,
_idempotencyKey: string,
_credential: SourceCredentialCreateDto,
): Promise<SourceCredentialDto> {
@ -99,7 +99,7 @@ export abstract class SynchronizationApiFake implements PublicApi {
}
rotateCredential(
_accessToken: string,
_csrfToken: string,
_credentialId: string,
_idempotencyKey: string,
_rotation: SourceCredentialRotateDto,
@ -108,7 +108,7 @@ export abstract class SynchronizationApiFake implements PublicApi {
}
revokeCredential(
_accessToken: string,
_csrfToken: string,
_credentialId: string,
_idempotencyKey: string,
): Promise<SourceCredentialDto> {

247
web/src/api/validate.ts Normal file
View file

@ -0,0 +1,247 @@
import { schemas } from './generated-schema'
interface Schema {
readonly $ref?: string
readonly type?: string
readonly properties?: Readonly<Record<string, Schema>>
readonly required?: readonly string[]
readonly items?: Schema
readonly oneOf?: readonly Schema[]
readonly anyOf?: readonly Schema[]
readonly allOf?: readonly Schema[]
readonly const?: unknown
readonly enum?: readonly unknown[]
readonly additionalProperties?: Schema
readonly unevaluatedProperties?: Schema
readonly not?: Schema
readonly minItems?: number
readonly maxItems?: number
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
readonly format?: string
readonly minimum?: number
readonly maximum?: number
}
const schemaTable: Readonly<Record<string, Schema>> = schemas
export function validateSchema(
name: keyof typeof schemas,
value: unknown,
): void {
validate(value, schemas[name], '$', true)
}
function validate(
value: unknown,
schema: Schema,
path: string,
seal: boolean,
): void {
if (schema.$ref !== undefined) {
const name = referenceName(schema.$ref)
const referenced = schemaTable[name]
if (referenced === undefined) fail(path, `unknown schema ${name}`)
validate(value, referenced, path, seal)
}
const alternatives = schema.oneOf ?? schema.anyOf
if (alternatives !== undefined) {
const matches = alternatives.filter((candidate) =>
accepts(value, candidate, path),
).length
if (
(schema.oneOf !== undefined && matches !== 1) ||
(schema.anyOf !== undefined && matches === 0)
)
fail(path, 'does not match its union')
}
if (schema.allOf !== undefined)
for (const member of schema.allOf) validate(value, member, path, false)
if (schema.const !== undefined && value !== schema.const)
fail(path, `must equal ${JSON.stringify(schema.const)}`)
if (
schema.enum !== undefined &&
!schema.enum.some((member) => member === value)
)
fail(path, 'is not an allowed value')
switch (schema.type) {
case 'object':
validateObject(value, schema, path, seal)
break
case 'array':
validateArray(value, schema, path)
break
case 'string':
validateString(value, schema, path)
break
case 'integer':
validateNumber(value, schema, path, true)
break
case 'number':
validateNumber(value, schema, path, false)
break
case 'boolean':
if (typeof value !== 'boolean') fail(path, 'must be a boolean')
break
case 'null':
if (value !== null) fail(path, 'must be null')
break
case undefined:
break
default:
fail(path, `uses unsupported schema type ${schema.type}`)
}
}
function validateObject(
value: unknown,
schema: Schema,
path: string,
seal: boolean,
): void {
if (!isRecord(value)) fail(path, 'must be an object')
const properties = schema.properties ?? {}
for (const name of schema.required ?? [])
if (!Object.hasOwn(value, name)) fail(`${path}.${name}`, 'is required')
for (const [name, property] of Object.entries(properties))
if (Object.hasOwn(value, name))
validate(value[name], property, `${path}.${name}`, true)
if (schema.additionalProperties !== undefined)
for (const [name, member] of Object.entries(value))
if (!(name in properties))
validate(member, schema.additionalProperties, `${path}.${name}`, true)
if (seal && schema.unevaluatedProperties !== undefined) {
const allowed = collectProperties(schema)
for (const name of Object.keys(value))
if (!allowed.has(name)) {
if (schema.unevaluatedProperties.not !== undefined)
fail(`${path}.${name}`, 'is not allowed')
validate(
value[name],
schema.unevaluatedProperties,
`${path}.${name}`,
true,
)
}
}
}
function validateArray(value: unknown, schema: Schema, path: string): void {
if (!Array.isArray(value)) fail(path, 'must be an array')
if (schema.minItems !== undefined && value.length < schema.minItems)
fail(path, `must contain at least ${schema.minItems} items`)
if (schema.maxItems !== undefined && value.length > schema.maxItems)
fail(path, `must contain at most ${schema.maxItems} items`)
const items = schema.items
if (items !== undefined)
value.forEach((member, index) =>
validate(member, items, `${path}[${index}]`, true),
)
}
function validateString(value: unknown, schema: Schema, path: string): void {
if (typeof value !== 'string') fail(path, 'must be a string')
if (schema.minLength !== undefined && value.length < schema.minLength)
fail(path, `must contain at least ${schema.minLength} characters`)
if (schema.maxLength !== undefined && value.length > schema.maxLength)
fail(path, `must contain at most ${schema.maxLength} characters`)
if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value))
fail(path, 'has an invalid format')
if (schema.format === 'uri') {
try {
const parsed = new URL(value)
if (parsed.protocol === '') fail(path, 'must be an absolute URL')
} catch {
fail(path, 'must be an absolute URL')
}
}
if (schema.format === 'date-time' && !validDateTime(value))
fail(path, 'must be an RFC 3339 date-time')
}
function validateNumber(
value: unknown,
schema: Schema,
path: string,
integer: boolean,
): void {
if (
typeof value !== 'number' ||
!Number.isFinite(value) ||
Math.abs(value) > Number.MAX_SAFE_INTEGER ||
(integer && !Number.isSafeInteger(value))
)
fail(path, integer ? 'must be a safe integer' : 'must be a safe number')
if (schema.minimum !== undefined && value < schema.minimum)
fail(path, `must be at least ${schema.minimum}`)
if (schema.maximum !== undefined && value > schema.maximum)
fail(path, `must be at most ${schema.maximum}`)
}
function collectProperties(schema: Schema): Set<string> {
const names = new Set(Object.keys(schema.properties ?? {}))
if (schema.$ref !== undefined) {
const referenced = schemaTable[referenceName(schema.$ref)]
if (referenced !== undefined)
for (const property of collectProperties(referenced)) names.add(property)
}
if (schema.allOf !== undefined)
for (const member of schema.allOf)
for (const property of collectProperties(member)) names.add(property)
return names
}
function accepts(value: unknown, schema: Schema, path: string): boolean {
try {
validate(value, schema, path, true)
return true
} catch {
return false
}
}
function validDateTime(value: string): boolean {
const match =
/^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.\d+)?(?:Z|[+-](\d\d):(\d\d))$/.exec(
value,
)
if (match === null) return false
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const hour = Number(match[4])
const minute = Number(match[5])
const second = Number(match[6])
const zoneHour = match[7] === undefined ? undefined : Number(match[7])
const zoneMinute = match[8] === undefined ? undefined : Number(match[8])
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
const days = [0, 31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
return (
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= (days[month] ?? 0) &&
hour <= 23 &&
minute <= 59 &&
second <= 59 &&
(zoneHour === undefined ||
(zoneHour <= 23 && zoneMinute !== undefined && zoneMinute <= 59))
)
}
function referenceName(reference: string): string {
return reference.replace(/^#\/\$defs\//, '').replace(/\.json$/, '')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function fail(path: string, message: string): never {
throw new Error(`${path} ${message}`)
}

254
web/src/app.css Normal file
View file

@ -0,0 +1,254 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 20rem;
min-height: 100vh;
color: #14213d;
background: #f4f6f8;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
main {
max-width: 90rem;
min-height: 100vh;
margin: auto;
padding: clamp(1rem, 4vw, 3rem);
}
header,
.section-heading,
.status-line,
nav,
.actions,
.records li,
dl div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
h1 {
margin: 0.25rem 0 0;
font-size: clamp(2.5rem, 7vw, 4.5rem);
letter-spacing: -0.06em;
}
h2,
h3 {
margin: 0 0 1rem;
}
.eyebrow {
margin: 0;
color: #52606d;
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.status-line {
margin: 1rem 0;
justify-content: flex-start;
}
output,
.pill {
padding: 0.3rem 0.65rem;
border-radius: 999px;
background: #deebf7;
}
nav {
justify-content: flex-start;
margin: 1.5rem 0;
border-bottom: 1px solid #cbd5e1;
}
nav button {
border-radius: 0;
background: transparent;
color: #334155;
}
nav button[aria-current='page'] {
border-bottom: 3px solid #1d4ed8;
color: #0f172a;
}
.workspace {
display: grid;
grid-template-columns: minmax(17rem, 0.8fr) minmax(20rem, 1.2fr);
gap: 1rem;
align-items: start;
}
section {
padding: 1.25rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
background: white;
}
button {
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0.65rem 0.9rem;
border: 0;
border-radius: 0.4rem;
color: white;
background: #1e3a5f;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
button.danger {
background: #9f1239;
}
:where(button, input, select, textarea):focus-visible {
outline: 3px solid #005fcc;
outline-offset: 3px;
}
ul,
ol {
margin: 0;
padding: 0;
list-style: none;
}
.cards {
display: grid;
gap: 0.6rem;
margin-top: 1rem;
}
.job {
display: grid;
width: 100%;
grid-template-columns: 1fr auto;
gap: 0.25rem 1rem;
text-align: left;
background: #eef2f6;
color: #14213d;
}
.job.selected {
outline: 3px solid #2563eb;
}
.job small {
grid-column: 1 / -1;
}
dl {
display: grid;
gap: 0.5rem;
margin: 1rem 0;
}
dl div {
align-items: start;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 0.5rem;
}
dt {
font-weight: 700;
}
dd {
margin: 0;
text-align: right;
overflow-wrap: anywhere;
}
.actions {
justify-content: flex-start;
flex-wrap: wrap;
margin-top: 1rem;
}
.records {
display: grid;
gap: 0.5rem;
margin: 0 0 1.5rem;
}
.records li {
padding: 0.65rem;
background: #f8fafc;
}
form {
display: grid;
gap: 0.85rem;
}
label {
display: grid;
gap: 0.3rem;
font-weight: 650;
}
input,
select,
textarea {
width: 100%;
min-height: 2.75rem;
padding: 0.65rem;
border: 1px solid #64748b;
border-radius: 0.35rem;
font: inherit;
}
textarea {
min-height: 5rem;
resize: vertical;
}
fieldset {
display: grid;
gap: 0.75rem;
border: 1px solid #94a3b8;
}
.message {
margin: 1rem 0;
padding: 0.75rem;
border-left: 4px solid #047857;
background: white;
}
.message.error,
[role='alert'] {
border-left-color: #be123c;
}
@media (max-width: 48rem) {
.workspace {
grid-template-columns: 1fr;
}
header {
align-items: flex-start;
}
.records li,
dl div {
align-items: flex-start;
flex-direction: column;
}
dd {
text-align: left;
}
}

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