Move browser authentication behind a generated-contract BFF
This commit is contained in:
parent
6f0f20f5cc
commit
fa7220ee33
42 changed files with 7259 additions and 2825 deletions
|
|
@ -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
175
web/pnpm-lock.yaml
generated
|
|
@ -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]: {}
|
||||
|
||||
|
|
|
|||
342
web/server.mjs
342
web/server.mjs
|
|
@ -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
105
web/server.test.mjs
Normal 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
61
web/server/config.mjs
Normal 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
395
web/server/oauth.mjs
Normal 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
244
web/server/oauth.test.mjs
Normal 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
197
web/server/proxy.mjs
Normal 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`,
|
||||
)
|
||||
}
|
||||
149
web/server/security.test.mjs
Normal file
149
web/server/security.test.mjs
Normal 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
89
web/server/sessions.mjs
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
2756
web/src/api/generated-schema.ts
Normal file
2756
web/src/api/generated-schema.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
247
web/src/api/validate.ts
Normal 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
254
web/src/app.css
Normal 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
Loading…
Reference in a new issue