Implement authorization-fenced web workflows

This commit is contained in:
ms2k 2026-07-18 01:49:00 +09:00
commit 267665bb35
18 changed files with 3853 additions and 156 deletions

View file

@ -5,14 +5,24 @@ import type {
EventConnection,
EventSocket,
EventSocketListener,
PublicApi,
} from './api/client'
import { ApiResponseError } from './api/client'
import { SynchronizationApiFake } from './api/testing'
import type {
AttemptPageDto,
DeletionOperationDto,
EventFrameDto,
EventTicketDto,
JobPageDto,
JobResourceDto,
JobSubmissionDto,
ResultDescriptorDto,
ResultMemberAccessDto,
ResultMemberPageDto,
SourceCredentialCreateDto,
SourceCredentialDto,
SourceCredentialPageDto,
SourceCredentialRotateDto,
} from './api/contracts'
import App from './App.svelte'
import type { Session, SessionState } from './auth/session'
@ -63,6 +73,295 @@ test('clears rendered authority on logout and reloads it on token change', async
expect(fixture.api.tokens).toEqual(['token-one', 'token-two'])
})
test('clears workflow-local authority on an authenticated account change', async () => {
const fixture = runtimeFixture()
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await expect.element(screen.getByText('Initial snapshot')).toBeVisible()
await screen.getByRole('button', { name: 'Credentials' }).click()
await expect.element(screen.getByText('Fixture credential')).toBeVisible()
fixture.api.snapshot = job(4, { label: 'Other account' })
fixture.session.emit({
kind: 'authenticated',
session: {
accessToken: 'token-two',
identity: 'issuer\u0000other-subject',
},
})
await expect.element(screen.getByText('Other account')).toBeVisible()
await expect
.element(screen.getByRole('heading', { name: 'Authorized jobs' }))
.toBeVisible()
expect(document.body.textContent).not.toContain('Fixture credential')
})
test('submits an authorized HTTP job and retains its idempotency key on retry', async () => {
const fixture = runtimeFixture()
fixture.api.submitFailure = new ApiResponseError(
503,
'unavailable',
'Service unavailable',
'request_0001',
)
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await expect.element(screen.getByText('Initial snapshot')).toBeVisible()
await screen.getByRole('button', { name: 'Submit job' }).click()
await screen.getByLabelText('Label').fill('Imported archive')
await screen
.getByLabelText('URL')
.fill('https://example.invalid/archive?token=secret')
await screen.getByLabelText('Result path').fill('archive.bin')
await screen.getByLabelText('Prefix').fill('imports')
await screen.getByRole('button', { name: 'Submit job now' }).click()
await expect
.element(screen.getByRole('alert'))
.toHaveTextContent('Service unavailable')
await screen.getByRole('button', { name: 'Submit job now' }).click()
await expect.element(screen.getByText('Job accepted')).toBeVisible()
await expect
.element(screen.getByRole('heading', { name: 'Imported archive' }))
.toBeVisible()
expect(fixture.api.idempotencyKeys[0]).toBe(fixture.api.idempotencyKeys[1])
expect(fixture.api.submitted).toEqual({
source: {
kind: 'http',
url: 'https://example.invalid/archive?token=secret',
result_path: 'archive.bin',
},
target: { prefix: 'imports' },
label: 'Imported archive',
})
})
test('covers history, result access, lifecycle confirmations, and admin revocation', async () => {
const fixture = runtimeFixture()
fixture.api.snapshot = job(3, {
label: 'Completed archive',
phase: 'Succeeded',
result,
progress: null,
})
const navigate = vi.fn<(url: string) => void>()
const screen = await render(App, { runtime: fixture.runtime, navigate })
fixture.session.emit(authenticated('token-one'))
await screen.getByRole('button', { name: /Completed archive/ }).click()
await screen.getByRole('button', { name: 'Load attempt history' }).click()
await expect.element(screen.getByText('AttemptCommitted')).toBeVisible()
await screen.getByRole('button', { name: 'Browse result' }).click()
await expect.element(screen.getByText('archive.bin')).toBeVisible()
await screen.getByRole('button', { name: 'Open' }).click()
expect(navigate).toHaveBeenCalledWith('https://objects.invalid/member')
await screen.getByRole('button', { name: 'Soft delete' }).click()
await expect.element(screen.getByRole('alertdialog')).toBeVisible()
await screen.getByRole('button', { name: 'Confirm' }).click()
await screen.getByRole('button', { name: 'Restore' }).click()
await screen.getByRole('button', { name: 'Confirm' }).click()
await screen.getByRole('button', { name: 'Revoke execution grant' }).click()
await screen.getByRole('button', { name: 'Confirm' }).click()
await screen.getByRole('button', { name: 'Hard delete' }).click()
await expect
.element(
screen.getByText(/irreversible and removes committed target objects/),
)
.toBeVisible()
await screen.getByRole('button', { name: 'Confirm' }).click()
await expect
.element(
screen
.getByRole('list', { name: 'Deletion operations' })
.getByText('queued'),
)
.toBeVisible()
expect(document.body.textContent).not.toContain('Completed archive')
expect(fixture.api.jobMutations).toEqual([
'soft-delete',
'restore',
'revoke',
'hard-delete',
])
})
test('shows deleted resources after a fresh snapshot and gates administration', async () => {
const fixture = runtimeFixture()
fixture.api.snapshot = job(3, {
label: 'Restorable archive',
phase: 'Succeeded',
lifecycle: 'soft_deleted',
result,
})
fixture.api.canRevokeExecutionGrants = false
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await screen.getByRole('button', { name: /Restorable archive/ }).click()
await expect
.element(screen.getByRole('button', { name: 'Restore' }))
.toBeVisible()
expect(document.body.textContent).not.toContain('Revoke execution grant')
expect(document.body.textContent).not.toContain('Browse result')
})
test('recovers a blocked deletion operation from a tombstone snapshot', async () => {
const fixture = runtimeFixture()
fixture.api.snapshot = {
resource_kind: 'tombstone',
id: 'job_00000001',
owner: 'owner_000001',
target: {
store: 'target-main',
bucket: 'fixture-bucket',
prefix: 'results',
},
terminal_phase: 'Succeeded',
deletion_operation_id: 'deletion_001',
created_at: '2026-07-17T00:00:00Z',
hard_delete_started_at: '2026-07-17T00:01:00Z',
updated_at: '2026-07-17T00:02:00Z',
event_cursor: 'cursor-snapshot-0001',
}
fixture.api.deletion = {
id: 'deletion_001',
job_id: 'job_00000001',
state: 'blocked',
problem: {
class: 'target_unavailable',
message: 'Target access is unavailable',
},
created_at: '2026-07-17T00:01:00Z',
updated_at: '2026-07-17T00:02:00Z',
}
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await expect
.element(
screen
.getByRole('list', { name: 'Deletion operations' })
.getByText('blocked'),
)
.toBeVisible()
await expect
.element(screen.getByText('Target access is unavailable'))
.toBeVisible()
})
test('does not render delayed history under a different selected job', async () => {
const fixture = runtimeFixture()
const history = deferred<AttemptPageDto>()
fixture.api.snapshots = [
job(3, { label: 'First archive' }),
{
...job(2, { label: 'Second archive' }),
id: 'job_00000002',
},
]
fixture.api.nextAttempts = history.promise
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await screen.getByRole('button', { name: /First archive/ }).click()
await screen.getByRole('button', { name: 'Load attempt history' }).click()
await screen.getByRole('button', { name: /Second archive/ }).click()
history.resolve(attemptPage())
await vi.waitFor(() => {
expect(document.body.textContent).not.toContain('AttemptCommitted')
})
await expect
.element(screen.getByRole('region', { name: 'Second archive' }))
.toBeVisible()
})
test('confirms cancellation for a nonterminal job', async () => {
const fixture = runtimeFixture()
fixture.api.snapshot = job(3, {
label: 'Active archive',
phase: 'Running',
})
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await screen.getByRole('button', { name: /Active archive/ }).click()
await screen.getByRole('button', { name: 'Cancel job' }).click()
await expect
.element(screen.getByRole('alertdialog', { name: 'Confirm cancel' }))
.toBeVisible()
await screen.getByRole('button', { name: 'Confirm' }).click()
await expect.element(screen.getByText('Cancellation requested')).toBeVisible()
await expect
.element(
screen
.getByRole('region', { name: 'Active archive' })
.getByText('Cancelling'),
)
.toBeVisible()
expect(fixture.api.jobMutations).toEqual(['cancel'])
})
test('creates, rotates, and confirms revocation without retaining secret input', async () => {
const fixture = runtimeFixture()
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await expect.element(screen.getByText('Initial snapshot')).toBeVisible()
await screen.getByRole('button', { name: 'Credentials' }).click()
await screen.getByLabelText('Label').fill('New token')
const newToken = screen.getByRole('textbox', {
name: 'New token',
exact: true,
})
await newToken.fill('secret-one')
await screen.getByRole('button', { name: 'Create credential' }).click()
await expect
.element(screen.getByText(/secret material was not retained/))
.toBeVisible()
await expect.element(newToken).toHaveValue('')
await screen.getByLabelText('Replacement token').fill('secret-two')
await screen.getByRole('button', { name: 'Rotate credential' }).click()
await expect
.element(screen.getByLabelText('Replacement token'))
.toHaveValue('')
await screen.getByRole('button', { name: 'Revoke credential' }).click()
await expect
.element(
screen.getByRole('alertdialog', {
name: 'Confirm credential revocation',
}),
)
.toBeVisible()
await screen.getByRole('button', { name: 'Confirm' }).click()
await expect
.element(screen.getByText('Credential revoked for future grants'))
.toBeVisible()
})
test('renders credential form errors through the accessible error boundary', async () => {
const fixture = runtimeFixture()
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await expect.element(screen.getByText('Initial snapshot')).toBeVisible()
await screen.getByRole('button', { name: 'Credentials' }).click()
await screen.getByLabelText('Label').fill('Headers')
await screen.getByLabelText('Type').selectOptions('http_headers')
await screen.getByLabelText('New headers').fill('missing separator')
await screen.getByRole('button', { name: 'Create credential' }).click()
await expect
.element(screen.getByRole('alert'))
.toHaveTextContent('Enter one header per line as name: value')
})
function runtimeFixture(): {
runtime: WebClientRuntime
session: FakeSession
@ -116,20 +415,32 @@ class FakeSession implements Session {
}
}
class FakeApi implements PublicApi {
snapshot = job(1, { label: 'Initial snapshot' })
class FakeApi extends SynchronizationApiFake {
snapshot: JobResourceDto = job(1, { label: 'Initial snapshot' })
snapshots: JobResourceDto[] | null = null
refetched = job(2)
listRequests = 0
jobRequests = 0
readonly tokens: string[] = []
readonly jobMutations: string[] = []
readonly idempotencyKeys: string[] = []
submitted: JobSubmissionDto | null = null
submitFailure: Error | null = null
nextAttempts: Promise<AttemptPageDto> | null = null
deletion: DeletionOperationDto | null = null
canRevokeExecutionGrants = true
credential: SourceCredentialDto = sourceCredential('active')
async listJobs(accessToken: string): Promise<JobPageDto> {
this.listRequests += 1
this.tokens.push(accessToken)
return {
items: [this.snapshot],
items: this.snapshots ?? [this.snapshot],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
management_capabilities: {
revoke_execution_grants: this.canRevokeExecutionGrants,
},
}
}
@ -145,7 +456,7 @@ class FakeApi implements PublicApi {
_accessToken: string,
_operationId: string,
): Promise<DeletionOperationDto | null> {
return null
return this.deletion
}
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
@ -154,6 +465,178 @@ class FakeApi implements PublicApi {
expires_at: '2026-07-17T00:01:00Z',
}
}
async submitJob(
_accessToken: string,
idempotencyKey: string,
submission: JobSubmissionDto,
): Promise<JobResourceDto> {
this.idempotencyKeys.push(idempotencyKey)
this.submitted = submission
if (this.submitFailure !== null) {
const failure = this.submitFailure
this.submitFailure = null
throw failure
}
this.snapshot = job(1, {
label: submission.label ?? null,
phase: 'Queued',
})
return this.snapshot
}
async getResult(
_accessToken: string,
_jobId: string,
): Promise<ResultDescriptorDto | null> {
return result
}
async listResultMembers(): Promise<ResultMemberPageDto> {
return {
items: [
{
id: 'member_00001',
path: 'archive.bin',
size: 12,
object: result.manifest,
},
],
next_cursor: null,
}
}
async accessResultMember(): Promise<ResultMemberAccessDto> {
return {
url: 'https://objects.invalid/member',
expires_at: '2026-07-17T00:01:00Z',
}
}
async mutateJob(
_accessToken: string,
_jobId: string,
operation: 'cancel' | 'soft-delete' | 'restore' | 'revoke',
idempotencyKey: string,
): Promise<JobResourceDto> {
this.jobMutations.push(operation)
this.idempotencyKeys.push(idempotencyKey)
const current =
this.snapshot.resource_kind === 'job' ? this.snapshot : job(1)
this.snapshot = {
...current,
revision: current.revision + 1,
...(operation === 'cancel' ? { phase: 'Cancelling' as const } : {}),
...(operation === 'soft-delete'
? { lifecycle: 'soft_deleted' as const }
: {}),
...(operation === 'restore' ? { lifecycle: 'visible' as const } : {}),
}
return this.snapshot
}
async hardDeleteJob(
_accessToken: string,
_jobId: string,
idempotencyKey: string,
): Promise<DeletionOperationDto> {
this.jobMutations.push('hard-delete')
this.idempotencyKeys.push(idempotencyKey)
return {
id: 'deletion_001',
job_id: 'job_00000001',
state: 'queued',
problem: null,
created_at: '2026-07-17T00:00:00Z',
updated_at: '2026-07-17T00:00:00Z',
}
}
async listAttempts(): Promise<AttemptPageDto> {
if (this.nextAttempts === null) return attemptPage()
const response = this.nextAttempts
this.nextAttempts = null
return response
}
async listCredentials(): Promise<SourceCredentialPageDto> {
return { items: [this.credential], next_cursor: null }
}
async createCredential(
_accessToken: string,
idempotencyKey: string,
credential: SourceCredentialCreateDto,
): Promise<SourceCredentialDto> {
this.idempotencyKeys.push(idempotencyKey)
this.credential = {
...sourceCredential('active'),
label: credential.label,
kind: credential.kind,
}
return this.credential
}
async rotateCredential(
_accessToken: string,
_credentialId: string,
idempotencyKey: string,
_rotation: SourceCredentialRotateDto,
): Promise<SourceCredentialDto> {
this.idempotencyKeys.push(idempotencyKey)
this.credential = {
...this.credential,
active_version: {
id: 'version_0002',
created_at: '2026-07-17T00:01:00Z',
},
}
return this.credential
}
async revokeCredential(
_accessToken: string,
_credentialId: string,
idempotencyKey: string,
): Promise<SourceCredentialDto> {
this.idempotencyKeys.push(idempotencyKey)
this.credential = {
...this.credential,
status: 'revoked',
active_version: null,
}
return this.credential
}
}
function deferred<T>(): {
promise: Promise<T>
resolve(value: T): void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((complete) => {
resolve = complete
})
return { promise, resolve }
}
function attemptPage(): AttemptPageDto {
return {
items: [
{
id: 'attempt_0001',
fence: 1,
worker_incarnation: 'worker_00001',
state: 'AttemptCommitted',
progress: null,
result,
failure: null,
created_at: '2026-07-17T00:00:00Z',
updated_at: '2026-07-17T00:00:00Z',
},
],
next_cursor: null,
}
}
class FakeSocket implements EventSocket {
@ -180,3 +663,39 @@ class ImmediateScheduler implements Scheduler {
return () => {}
}
}
const result: ResultDescriptorDto = {
kind: 'manifest',
manifest_id: 'manifest_001',
schema_version: 1,
manifest: {
store: 'target-main',
bucket: 'fixture-bucket',
key: 'manifest.json',
checksum: `sha256:${'a'.repeat(64)}`,
size: 12,
},
member_count: 1,
total_bytes: 12,
}
function sourceCredential(
status: SourceCredentialDto['status'],
): SourceCredentialDto {
return {
id: 'credential_001',
owner: 'owner_000001',
label: 'Fixture credential',
kind: 'bearer_token',
status,
active_version:
status === 'active'
? {
id: 'version_0001',
created_at: '2026-07-17T00:00:00Z',
}
: null,
created_at: '2026-07-17T00:00:00Z',
updated_at: '2026-07-17T00:00:00Z',
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,92 @@
<script lang="ts">
let {
title,
message,
confirmLabel = 'Confirm',
cancelLabel,
onconfirm,
oncancel,
}: {
title: string
message: string
confirmLabel?: string
cancelLabel: string
onconfirm: () => void
oncancel: () => void
} = $props()
let dialog = $state<HTMLDialogElement>()
$effect(() => {
if (dialog === undefined) return
const current = dialog
current.showModal()
return () => {
if (current.open) current.close()
}
})
function cancel(): void {
dialog?.close()
oncancel()
}
function confirm(): void {
dialog?.close()
onconfirm()
}
</script>
<dialog
bind:this={dialog}
role="alertdialog"
aria-label={title}
oncancel={(event) => {
event.preventDefault()
cancel()
}}
>
<h3>{title}</h3>
<p>{message}</p>
<div class="actions">
<button type="button" onclick={cancel}>{cancelLabel}</button>
<button class="danger" type="button" onclick={confirm}>
{confirmLabel}
</button>
</div>
</dialog>
<style>
dialog {
max-width: min(32rem, calc(100vw - 2rem));
padding: 1rem;
border: 2px solid #be123c;
border-radius: 0.5rem;
}
dialog::backdrop {
background: rgb(15 23 42 / 55%);
}
.actions {
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
}
button {
border: 0;
border-radius: 0.35rem;
padding: 0.65rem 0.9rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.danger {
background: #be123c;
color: white;
}
</style>

View file

@ -1,16 +1,32 @@
import {
decodeApiError,
decodeAttemptPage,
decodeDeletionOperation,
decodeEventFrame,
decodeEventTicket,
decodeJobPage,
decodeJobResource,
decodeResultDescriptor,
decodeResultMemberAccess,
decodeResultMemberPage,
decodeSourceCredential,
decodeSourceCredentialPage,
} from './decode'
import type {
AttemptPageDto,
DeletionOperationDto,
EventFrameDto,
EventTicketDto,
JobPageDto,
JobResourceDto,
JobSubmissionDto,
ResultDescriptorDto,
ResultMemberAccessDto,
ResultMemberPageDto,
SourceCredentialCreateDto,
SourceCredentialDto,
SourceCredentialPageDto,
SourceCredentialRotateDto,
} from './contracts'
export interface PublicApi {
@ -21,11 +37,71 @@ export interface PublicApi {
operationId: string,
): Promise<DeletionOperationDto | null>
createEventTicket(accessToken: string): Promise<EventTicketDto>
submitJob(
accessToken: string,
idempotencyKey: string,
submission: JobSubmissionDto,
): Promise<JobResourceDto>
getResult(
accessToken: string,
jobId: string,
): Promise<ResultDescriptorDto | null>
listResultMembers(
accessToken: string,
jobId: string,
cursor?: string,
): Promise<ResultMemberPageDto>
accessResultMember(
accessToken: string,
jobId: string,
memberId: string,
): Promise<ResultMemberAccessDto>
mutateJob(
accessToken: string,
jobId: string,
operation: 'cancel' | 'soft-delete' | 'restore' | 'revoke',
idempotencyKey: string,
): Promise<JobResourceDto>
hardDeleteJob(
accessToken: string,
jobId: string,
idempotencyKey: string,
): Promise<DeletionOperationDto>
listAttempts(
accessToken: string,
jobId: string,
cursor?: string,
): Promise<AttemptPageDto>
listCredentials(
accessToken: string,
cursor?: string,
): Promise<SourceCredentialPageDto>
createCredential(
accessToken: string,
idempotencyKey: string,
credential: SourceCredentialCreateDto,
): Promise<SourceCredentialDto>
rotateCredential(
accessToken: string,
credentialId: string,
idempotencyKey: string,
rotation: SourceCredentialRotateDto,
): Promise<SourceCredentialDto>
revokeCredential(
accessToken: string,
credentialId: string,
idempotencyKey: string,
): Promise<SourceCredentialDto>
}
export class ApiResponseError extends Error {
constructor(readonly status: number) {
super(`Remora API returned HTTP ${status}`)
constructor(
readonly status: number,
readonly code?: string,
message = `Remora API returned HTTP ${status}`,
readonly requestId?: string,
) {
super(message)
}
}
@ -33,16 +109,14 @@ export class FetchPublicApi implements PublicApi {
readonly #baseUrl: URL
readonly #fetch: typeof fetch
constructor(
baseUrl: string | URL,
fetchImplementation: typeof fetch = fetch,
) {
this.#baseUrl = new URL(baseUrl, 'http://localhost/')
this.#fetch = fetchImplementation
constructor(baseUrl: string | URL, fetchImplementation?: typeof fetch) {
this.#baseUrl = new URL(baseUrl, browserBaseUrl())
this.#fetch = fetchImplementation ?? globalThis.fetch.bind(globalThis)
}
async listJobs(accessToken: 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))
}
@ -71,7 +145,154 @@ export class FetchPublicApi implements PublicApi {
async createEventTicket(accessToken: string): Promise<EventTicketDto> {
return decodeEventTicket(
await this.#request(this.#url('event-tickets'), accessToken, 'POST'),
await this.#request(this.#url('event-tickets'), accessToken, {
method: 'POST',
}),
)
}
async submitJob(
accessToken: string,
idempotencyKey: string,
submission: JobSubmissionDto,
): Promise<JobResourceDto> {
return decodeJobResource(
await this.#request(this.#url('jobs'), accessToken, {
method: 'POST',
idempotencyKey,
body: submission,
}),
)
}
async getResult(
accessToken: string,
jobId: string,
): Promise<ResultDescriptorDto | null> {
const response = await this.#requestOrMissing(
this.#url(`jobs/${encodeURIComponent(jobId)}/result`),
accessToken,
)
return response === null ? null : decodeResultDescriptor(response)
}
async listResultMembers(
accessToken: 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))
}
async accessResultMember(
accessToken: string,
jobId: string,
memberId: string,
): Promise<ResultMemberAccessDto> {
return decodeResultMemberAccess(
await this.#request(
this.#url(
`jobs/${encodeURIComponent(jobId)}/result/members/${encodeURIComponent(memberId)}/content`,
),
accessToken,
),
)
}
async mutateJob(
accessToken: string,
jobId: string,
operation: 'cancel' | 'soft-delete' | 'restore' | 'revoke',
idempotencyKey: string,
): Promise<JobResourceDto> {
return decodeJobResource(
await this.#request(
this.#url(`jobs/${encodeURIComponent(jobId)}/${operation}`),
accessToken,
{ method: 'POST', idempotencyKey },
),
)
}
async hardDeleteJob(
accessToken: string,
jobId: string,
idempotencyKey: string,
): Promise<DeletionOperationDto> {
return decodeDeletionOperation(
await this.#request(
this.#url(`jobs/${encodeURIComponent(jobId)}/hard-delete`),
accessToken,
{ method: 'POST', idempotencyKey },
),
)
}
async listAttempts(
accessToken: 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))
}
async listCredentials(
accessToken: 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))
}
async createCredential(
accessToken: string,
idempotencyKey: string,
credential: SourceCredentialCreateDto,
): Promise<SourceCredentialDto> {
return decodeSourceCredential(
await this.#request(this.#url('source-credentials'), accessToken, {
method: 'POST',
idempotencyKey,
body: credential,
}),
)
}
async rotateCredential(
accessToken: string,
credentialId: string,
idempotencyKey: string,
rotation: SourceCredentialRotateDto,
): Promise<SourceCredentialDto> {
return decodeSourceCredential(
await this.#request(
this.#url(
`source-credentials/${encodeURIComponent(credentialId)}/versions`,
),
accessToken,
{ method: 'POST', idempotencyKey, body: rotation },
),
)
}
async revokeCredential(
accessToken: string,
credentialId: string,
idempotencyKey: string,
): Promise<SourceCredentialDto> {
return decodeSourceCredential(
await this.#request(
this.#url(
`source-credentials/${encodeURIComponent(credentialId)}/revoke`,
),
accessToken,
{ method: 'POST', idempotencyKey },
),
)
}
@ -82,18 +303,29 @@ export class FetchPublicApi implements PublicApi {
async #request(
url: URL,
accessToken: string,
method = 'GET',
options: {
method?: 'GET' | 'POST'
idempotencyKey?: string
body?: unknown
} = {},
): Promise<unknown> {
const headers: Record<string, string> = {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
}
if (options.idempotencyKey !== undefined)
headers['Idempotency-Key'] = options.idempotencyKey
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
const response = await this.#fetch(url, {
method,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
method: options.method ?? 'GET',
headers,
...(options.body === undefined
? {}
: { body: JSON.stringify(options.body) }),
cache: 'no-store',
credentials: 'omit',
})
if (!response.ok) throw new ApiResponseError(response.status)
if (!response.ok) throw await apiResponseError(response)
return response.json()
}
@ -130,7 +362,7 @@ export class BrowserEventSocket implements EventSocket {
readonly #eventsUrl: URL
constructor(eventsUrl: string | URL) {
this.#eventsUrl = new URL(eventsUrl, 'http://localhost/')
this.#eventsUrl = new URL(eventsUrl, browserBaseUrl())
}
connect(
@ -175,6 +407,29 @@ function withTrailingSlash(url: URL): URL {
return copy
}
function browserBaseUrl(): string {
return typeof document === 'undefined'
? 'http://localhost/'
: document.baseURI
}
async function apiResponseError(response: Response): Promise<ApiResponseError> {
try {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/problem+json'))
return new ApiResponseError(response.status)
const problem = decodeApiError(await response.json())
return new ApiResponseError(
response.status,
problem.code,
problem.message,
problem.request_id,
)
} catch {
return new ApiResponseError(response.status)
}
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error('Invalid live event frame')
}

View file

@ -12,6 +12,7 @@ describe('adapted public API client', () => {
items: [job(1)],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
management_capabilities: { revoke_execution_grants: false },
}),
)
const api = new FetchPublicApi('https://remora.invalid/v1/', request)
@ -22,7 +23,7 @@ describe('adapted public API client', () => {
const [url, init] = request.mock.calls[0]
if (!(url instanceof URL)) throw new Error('expected URL request')
expect(url.href).toBe(
'https://remora.invalid/v1/jobs?cursor=cursor+page%2Fone',
'https://remora.invalid/v1/jobs?include_deleted=true&cursor=cursor+page%2Fone',
)
expect(init).toMatchObject({
cache: 'no-store',
@ -34,6 +35,24 @@ describe('adapted public API client', () => {
})
})
test('resolves browser-relative API URLs against the document', async () => {
const request = vi.fn<typeof fetch>(async () =>
Response.json({
items: [],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
management_capabilities: { revoke_execution_grants: false },
}),
)
const api = new FetchPublicApi('/api/v1/', request)
await api.listJobs('token')
const [url] = request.mock.calls[0]
if (!(url instanceof URL)) throw new Error('expected URL request')
expect(url.href).toBe('http://localhost/api/v1/jobs?include_deleted=true')
})
test('maps an indistinguishable missing resource without swallowing faults', async () => {
const missing = new FetchPublicApi(
'https://remora.invalid/v1/',
@ -68,6 +87,89 @@ describe('adapted public API client', () => {
})
expect(request.mock.calls[0]?.[1]?.method).toBe('POST')
})
test('sends mutation fingerprints through an explicit idempotency key', async () => {
const request = vi.fn<typeof fetch>(async () =>
Response.json(job(1, { label: 'Submitted job' }), { status: 202 }),
)
const api = new FetchPublicApi('https://remora.invalid/v1/', request)
const submission = {
source: {
kind: 'http' as const,
url: 'https://example.invalid/archive',
result_path: 'archive.bin',
},
label: 'Submitted job',
}
await expect(
api.submitJob('token', 'intent_0001', submission),
).resolves.toMatchObject({ label: 'Submitted job' })
expect(request.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: 'Bearer token',
'Content-Type': 'application/json',
'Idempotency-Key': 'intent_0001',
},
body: JSON.stringify(submission),
})
})
test('decodes safe structured API errors', async () => {
const api = new FetchPublicApi('https://remora.invalid/v1/', async () =>
Response.json(
{
code: 'precondition_failed',
message: 'Job is not terminal',
request_id: 'request_0001',
},
{
status: 409,
headers: { 'Content-Type': 'application/problem+json' },
},
),
)
await expect(api.getResult('token', 'job_00000001')).rejects.toEqual(
new ApiResponseError(
409,
'precondition_failed',
'Job is not terminal',
'request_0001',
),
)
})
test('decodes an authenticated exact-member access grant', async () => {
const api = new FetchPublicApi('https://remora.invalid/v1/', async () =>
Response.json({
url: 'https://objects.invalid/member?signature=temporary',
expires_at: '2026-07-17T00:01:00Z',
}),
)
await expect(
api.accessResultMember('token', 'job_00000001', 'member_00001'),
).resolves.toEqual({
url: 'https://objects.invalid/member?signature=temporary',
expires_at: '2026-07-17T00:01:00Z',
})
})
test('rejects a non-HTTP member access target', async () => {
const api = new FetchPublicApi('https://remora.invalid/v1/', async () =>
Response.json({
url: 'javascript:alert(1)',
expires_at: '2026-07-17T00:01:00Z',
}),
)
await expect(
api.accessResultMember('token', 'job_00000001', 'member_00001'),
).rejects.toThrow('invalid member access URL')
})
})
describe('snapshot wire decoding', () => {
@ -77,6 +179,7 @@ describe('snapshot wire decoding', () => {
items: [{ ...job(1), secret: 'must not pass through' }],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
management_capabilities: { revoke_execution_grants: false },
}),
).toThrow('unknown DTO field')
expect(() =>

View file

@ -325,6 +325,11 @@ export interface ResultMemberPageDto {
next_cursor: string | null
}
export interface ResultMemberAccessDto {
url: string
expires_at: string
}
export interface JobDto {
resource_kind: 'job'
id: OpaqueIdDto
@ -360,10 +365,15 @@ export interface JobTombstoneDto {
export type JobResourceDto = JobDto | JobTombstoneDto
export interface ManagementCapabilitiesDto {
revoke_execution_grants: boolean
}
export interface JobPageDto {
items: JobResourceDto[]
next_cursor: string | null
event_cursor: string
management_capabilities: ManagementCapabilitiesDto
}
export interface AttemptRecordDto {

View file

@ -2,6 +2,9 @@ import { runtimeFailureClass, type JobPhase } from '../domain/vocabulary'
import { opaqueId } from '../domain/validation'
import type {
AttemptOutcomeRequestDto,
AttemptPageDto,
AttemptRecordDto,
ApiErrorDto,
CheckpointProjectionDto,
DeletionOperationDto,
EventFrameDto,
@ -15,6 +18,12 @@ import type {
ObjectIdentityDto,
ProgressDto,
ResultDescriptorDto,
ResultMemberDto,
ResultMemberAccessDto,
ResultMemberPageDto,
SourceCredentialDto,
SourceCredentialPageDto,
SourceCredentialVersionDto,
SourceProjectionDto,
SourceSubmissionDto,
TargetDto,
@ -137,13 +146,25 @@ export function decodeEventTicket(value: unknown): EventTicketDto {
export function decodeJobPage(value: unknown): JobPageDto {
const object = record(value)
exactKeys(object, ['items', 'next_cursor', 'event_cursor'])
exactKeys(object, [
'items',
'next_cursor',
'event_cursor',
'management_capabilities',
])
if (!Array.isArray(object.items)) throw new Error('expected job array')
const capabilities = record(object.management_capabilities)
exactKeys(capabilities, ['revoke_execution_grants'])
return {
items: object.items.map(decodeJobResource),
next_cursor:
object.next_cursor === null ? null : cursor(object.next_cursor),
event_cursor: cursor(object.event_cursor),
management_capabilities: {
revoke_execution_grants: booleanValue(
capabilities.revoke_execution_grants,
),
},
}
}
@ -252,6 +273,98 @@ export function decodeDeletionOperation(value: unknown): DeletionOperationDto {
}
}
export function decodeAttemptPage(value: unknown): AttemptPageDto {
const object = record(value)
exactKeys(object, ['items', 'next_cursor'])
if (!Array.isArray(object.items)) throw new Error('expected attempt array')
return {
items: object.items.map(decodeAttemptRecord),
next_cursor:
object.next_cursor === null ? null : cursor(object.next_cursor),
}
}
export function decodeResultDescriptor(value: unknown): ResultDescriptorDto {
return decodeResult(value)
}
export function decodeResultMemberPage(value: unknown): ResultMemberPageDto {
const object = record(value)
exactKeys(object, ['items', 'next_cursor'])
if (!Array.isArray(object.items))
throw new Error('expected result member array')
return {
items: object.items.map(decodeResultMember),
next_cursor:
object.next_cursor === null ? null : cursor(object.next_cursor),
}
}
export function decodeResultMemberAccess(
value: unknown,
): ResultMemberAccessDto {
const object = record(value)
exactKeys(object, ['url', 'expires_at'])
return {
url: httpUrl(object.url),
expires_at: dateTime(object.expires_at),
}
}
export function decodeSourceCredential(value: unknown): SourceCredentialDto {
const object = record(value)
exactKeys(object, [
'id',
'owner',
'label',
'kind',
'status',
'active_version',
'created_at',
'updated_at',
])
return {
id: opaqueId(string(object.id)),
owner: opaqueId(string(object.owner)),
label: string(object.label),
kind: sourceCredentialKind(object.kind),
status: sourceCredentialStatus(object.status),
active_version:
object.active_version === null
? null
: decodeSourceCredentialVersion(object.active_version),
created_at: dateTime(object.created_at),
updated_at: dateTime(object.updated_at),
}
}
export function decodeSourceCredentialPage(
value: unknown,
): SourceCredentialPageDto {
const object = record(value)
exactKeys(object, ['items', 'next_cursor'])
if (!Array.isArray(object.items))
throw new Error('expected source credential array')
return {
items: object.items.map(decodeSourceCredential),
next_cursor:
object.next_cursor === null ? null : cursor(object.next_cursor),
}
}
export function decodeApiError(value: unknown): ApiErrorDto {
const object = record(value)
exactKeys(object, ['code', 'message', 'request_id', 'fields'])
return {
code: apiErrorCode(object.code),
message: string(object.message),
request_id: opaqueId(string(object.request_id)),
...(object.fields === undefined
? {}
: { fields: decodeApiErrorFields(object.fields) }),
}
}
export function decodeAttemptOutcome(value: unknown): AttemptOutcomeRequestDto {
const object = record(value)
exactKeys(object, ['operation_id', 'fence', 'worker_incarnation', 'outcome'])
@ -427,6 +540,69 @@ function decodeDeletionProblem(value: unknown): {
return { class: object.class, message: string(object.message) }
}
function decodeAttemptRecord(value: unknown): AttemptRecordDto {
const object = record(value)
exactKeys(object, [
'id',
'fence',
'worker_incarnation',
'state',
'progress',
'result',
'failure',
'created_at',
'updated_at',
])
const state = attemptState(object.state)
const result = object.result === null ? null : decodeResult(object.result)
if ((state === 'AttemptCommitted') !== (result !== null))
throw new Error('attempt result does not match state')
return {
id: opaqueId(string(object.id)),
fence: positiveInteger(object.fence),
worker_incarnation: opaqueId(string(object.worker_incarnation)),
state,
progress: object.progress === null ? null : decodeProgress(object.progress),
result,
failure: object.failure === null ? null : decodeJobFailure(object.failure),
created_at: dateTime(object.created_at),
updated_at: dateTime(object.updated_at),
}
}
function decodeResultMember(value: unknown): ResultMemberDto {
const object = record(value)
exactKeys(object, ['id', 'path', 'size', 'object'])
return {
id: opaqueId(string(object.id)),
path: string(object.path),
size: nonnegativeInteger(object.size),
object: decodeObjectIdentity(object.object),
}
}
function decodeSourceCredentialVersion(
value: unknown,
): SourceCredentialVersionDto {
const object = record(value)
exactKeys(object, ['id', 'created_at'])
return {
id: opaqueId(string(object.id)),
created_at: dateTime(object.created_at),
}
}
function decodeApiErrorFields(
value: unknown,
): { pointer: string; code: string }[] {
if (!Array.isArray(value)) throw new Error('expected error field array')
return value.map((field) => {
const object = record(field)
exactKeys(object, ['pointer', 'code'])
return { pointer: string(object.pointer), code: string(object.code) }
})
}
function decodeSourceSubmission(value: unknown): SourceSubmissionDto {
const object = record(value)
const credential =
@ -626,6 +802,11 @@ function string(value: unknown): string {
return value
}
function booleanValue(value: unknown): boolean {
if (typeof value !== 'boolean') throw new Error('expected boolean')
return value
}
function cursor(value: unknown): string {
const decoded = string(value)
const length = Array.from(decoded).length
@ -754,6 +935,72 @@ function deletionState(
return value
}
function attemptState(
value: unknown,
):
| 'AttemptAuthorized'
| 'AttemptExecuting'
| 'AttemptStopped'
| 'AttemptCommitted' {
if (
value !== 'AttemptAuthorized' &&
value !== 'AttemptExecuting' &&
value !== 'AttemptStopped' &&
value !== 'AttemptCommitted'
)
throw new Error('unknown attempt state')
return value
}
function sourceCredentialKind(value: unknown): SourceCredentialDto['kind'] {
if (
value !== 'basic_auth' &&
value !== 'bearer_token' &&
value !== 'http_headers' &&
value !== 'object_store' &&
value !== 'hugging_face'
)
throw new Error('unknown source credential kind')
return value
}
function sourceCredentialStatus(value: unknown): SourceCredentialDto['status'] {
if (value !== 'active' && value !== 'revoked')
throw new Error('unknown source credential status')
return value
}
function apiErrorCode(value: unknown): ApiErrorDto['code'] {
if (
value !== 'invalid_request' &&
value !== 'unauthenticated' &&
value !== 'forbidden' &&
value !== 'not_found' &&
value !== 'idempotency_conflict' &&
value !== 'precondition_failed' &&
value !== 'cursor_invalid' &&
value !== 'cursor_expired' &&
value !== 'rate_limited' &&
value !== 'unavailable' &&
value !== 'internal'
)
throw new Error('unknown API error code')
return value
}
function httpUrl(value: unknown): string {
const raw = string(value)
let parsed: URL
try {
parsed = new URL(raw)
} catch {
throw new Error('invalid member access URL')
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
throw new Error('invalid member access URL')
return raw
}
function resyncReason(
value: unknown,
):

121
web/src/api/testing.ts Normal file
View file

@ -0,0 +1,121 @@
import type { PublicApi } from './client'
import type {
AttemptPageDto,
DeletionOperationDto,
EventTicketDto,
JobPageDto,
JobResourceDto,
JobSubmissionDto,
ResultDescriptorDto,
ResultMemberAccessDto,
ResultMemberPageDto,
SourceCredentialCreateDto,
SourceCredentialDto,
SourceCredentialPageDto,
SourceCredentialRotateDto,
} from './contracts'
export abstract class SynchronizationApiFake implements PublicApi {
abstract listJobs(accessToken: string, cursor?: string): Promise<JobPageDto>
abstract getJob(
accessToken: string,
jobId: string,
): Promise<JobResourceDto | null>
abstract getDeletion(
accessToken: string,
operationId: string,
): Promise<DeletionOperationDto | null>
abstract createEventTicket(accessToken: string): Promise<EventTicketDto>
submitJob(
_accessToken: string,
_idempotencyKey: string,
_submission: JobSubmissionDto,
): Promise<JobResourceDto> {
return unsupported()
}
getResult(
_accessToken: string,
_jobId: string,
): Promise<ResultDescriptorDto | null> {
return unsupported()
}
listResultMembers(
_accessToken: string,
_jobId: string,
_cursor?: string,
): Promise<ResultMemberPageDto> {
return unsupported()
}
accessResultMember(
_accessToken: string,
_jobId: string,
_memberId: string,
): Promise<ResultMemberAccessDto> {
return unsupported()
}
mutateJob(
_accessToken: string,
_jobId: string,
_operation: 'cancel' | 'soft-delete' | 'restore' | 'revoke',
_idempotencyKey: string,
): Promise<JobResourceDto> {
return unsupported()
}
hardDeleteJob(
_accessToken: string,
_jobId: string,
_idempotencyKey: string,
): Promise<DeletionOperationDto> {
return unsupported()
}
listAttempts(
_accessToken: string,
_jobId: string,
_cursor?: string,
): Promise<AttemptPageDto> {
return unsupported()
}
listCredentials(
_accessToken: string,
_cursor?: string,
): Promise<SourceCredentialPageDto> {
return unsupported()
}
createCredential(
_accessToken: string,
_idempotencyKey: string,
_credential: SourceCredentialCreateDto,
): Promise<SourceCredentialDto> {
return unsupported()
}
rotateCredential(
_accessToken: string,
_credentialId: string,
_idempotencyKey: string,
_rotation: SourceCredentialRotateDto,
): Promise<SourceCredentialDto> {
return unsupported()
}
revokeCredential(
_accessToken: string,
_credentialId: string,
_idempotencyKey: string,
): Promise<SourceCredentialDto> {
return unsupported()
}
}
function unsupported<T>(): Promise<T> {
return Promise.reject(new Error('Workflow API call is not configured'))
}

View file

@ -4,7 +4,21 @@ import type {
EventSocketListener,
PublicApi,
} from '../api/client'
import type { EventFrameDto, JobResourceDto } from '../api/contracts'
import type {
AttemptPageDto,
DeletionOperationDto,
EventFrameDto,
JobResourceDto,
JobSubmissionDto,
ManagementCapabilitiesDto,
ResultDescriptorDto,
ResultMemberAccessDto,
ResultMemberPageDto,
SourceCredentialCreateDto,
SourceCredentialDto,
SourceCredentialPageDto,
SourceCredentialRotateDto,
} from '../api/contracts'
import type {
AuthenticatedSession,
Session,
@ -39,9 +53,11 @@ export class WebClientRuntime {
#sessionKey: string | null = null
#accessToken: string | null = null
#generation = 0
#snapshotEpoch = 0
#connectionEpoch = 0
#connection: EventConnection | null = null
#cancelRetry: (() => void) | null = null
#cancelHydrationRetry: (() => void) | null = null
#unsubscribeSession: (() => void) | null = null
#frames = Promise.resolve()
@ -74,6 +90,7 @@ export class WebClientRuntime {
this.#frames = Promise.resolve()
this.#closeConnection()
this.#cancelScheduledRetry()
this.#cancelScheduledHydration()
this.#unsubscribeSession?.()
this.#unsubscribeSession = null
}
@ -86,6 +103,117 @@ export class WebClientRuntime {
return this.#session.signOut()
}
refresh(): Promise<void> {
return this.#loadSnapshot(this.#generation, 'resyncing')
}
async submitJob(
idempotencyKey: string,
submission: JobSubmissionDto,
): Promise<JobResourceDto> {
const job = await this.#snapshotRequest((accessToken) =>
this.#api.submitJob(accessToken, idempotencyKey, submission),
)
this.#state.replaceJob(job.id, 0, job)
this.#publish()
return job
}
getResult(jobId: string): Promise<ResultDescriptorDto | null> {
return this.#snapshotRequest((accessToken) =>
this.#api.getResult(accessToken, jobId),
)
}
listResultMembers(
jobId: string,
cursor?: string,
): Promise<ResultMemberPageDto> {
return this.#snapshotRequest((accessToken) =>
this.#api.listResultMembers(accessToken, jobId, cursor),
)
}
accessResultMember(
jobId: string,
memberId: string,
): Promise<ResultMemberAccessDto> {
return this.#snapshotRequest((accessToken) =>
this.#api.accessResultMember(accessToken, jobId, memberId),
)
}
async mutateJob(
jobId: string,
operation: 'cancel' | 'soft-delete' | 'restore' | 'revoke',
idempotencyKey: string,
): Promise<JobResourceDto> {
const job = await this.#snapshotRequest((accessToken) =>
this.#api.mutateJob(accessToken, jobId, operation, idempotencyKey),
)
this.#state.replaceJob(job.id, 0, job)
this.#publish()
return job
}
async hardDeleteJob(
jobId: string,
idempotencyKey: string,
): Promise<DeletionOperationDto> {
const operation = await this.#snapshotRequest((accessToken) =>
this.#api.hardDeleteJob(accessToken, jobId, idempotencyKey),
)
this.#state.removeFullJob(jobId)
this.#state.replaceDeletion(operation.id, operation)
this.#publish()
return operation
}
listAttempts(jobId: string, cursor?: string): Promise<AttemptPageDto> {
return this.#snapshotRequest((accessToken) =>
this.#api.listAttempts(accessToken, jobId, cursor),
)
}
listCredentials(cursor?: string): Promise<SourceCredentialPageDto> {
return this.#snapshotRequest((accessToken) =>
this.#api.listCredentials(accessToken, cursor),
)
}
createCredential(
idempotencyKey: string,
credential: SourceCredentialCreateDto,
): Promise<SourceCredentialDto> {
return this.#snapshotRequest((accessToken) =>
this.#api.createCredential(accessToken, idempotencyKey, credential),
)
}
rotateCredential(
credentialId: string,
idempotencyKey: string,
rotation: SourceCredentialRotateDto,
): Promise<SourceCredentialDto> {
return this.#snapshotRequest((accessToken) =>
this.#api.rotateCredential(
accessToken,
credentialId,
idempotencyKey,
rotation,
),
)
}
revokeCredential(
credentialId: string,
idempotencyKey: string,
): Promise<SourceCredentialDto> {
return this.#snapshotRequest((accessToken) =>
this.#api.revokeCredential(accessToken, credentialId, idempotencyKey),
)
}
subscribe(listener: (state: SynchronizationSnapshot) => void): () => void {
this.#listeners.add(listener)
listener(this.snapshot)
@ -105,6 +233,7 @@ export class WebClientRuntime {
this.#frames = Promise.resolve()
this.#closeConnection()
this.#cancelScheduledRetry()
this.#cancelScheduledHydration()
this.#state.clear(state.kind === 'loading' ? 'loading' : 'signed_out')
if (state.kind === 'error') this.#state.setStatus('error', state.message)
this.#publish()
@ -119,6 +248,7 @@ export class WebClientRuntime {
this.#frames = Promise.resolve()
this.#closeConnection()
this.#cancelScheduledRetry()
this.#cancelScheduledHydration()
this.#state.clear('loading')
this.#publish()
void this.#loadSnapshot(this.#generation, 'loading')
@ -130,10 +260,12 @@ export class WebClientRuntime {
): Promise<void> {
const token = this.#accessToken
if (token === null || generation !== this.#generation) return
const snapshotEpoch = ++this.#snapshotEpoch
this.#connectionEpoch += 1
this.#frames = Promise.resolve()
this.#closeConnection()
this.#cancelScheduledRetry()
this.#cancelScheduledHydration()
this.#state.setStatus(status)
this.#publish()
try {
@ -141,29 +273,54 @@ export class WebClientRuntime {
const pageCursors = new Set<string>()
let pageCursor: string | undefined
let eventCursor: string | null = null
let managementCapabilities: ManagementCapabilitiesDto | null = null
do {
const page = await this.#api.listJobs(token, pageCursor)
if (generation !== this.#generation) return
if (
generation !== this.#generation ||
snapshotEpoch !== this.#snapshotEpoch
)
return
eventCursor ??= page.event_cursor
managementCapabilities ??= page.management_capabilities
if (
page.management_capabilities.revoke_execution_grants !==
managementCapabilities.revoke_execution_grants
)
throw new Error('Job pagination capabilities changed')
jobs.push(...page.items)
pageCursor = page.next_cursor ?? undefined
if (pageCursor !== undefined && !pageCursors.add(pageCursor))
throw new Error('Job pagination cursor repeated')
if (pageCursor !== undefined) {
if (pageCursors.has(pageCursor))
throw new Error('Job pagination cursor repeated')
pageCursors.add(pageCursor)
}
} while (pageCursor !== undefined)
if (eventCursor === null)
throw new Error('Job snapshot omitted its cursor')
this.#state.installSnapshot(jobs, eventCursor)
if (managementCapabilities === null)
throw new Error('Job snapshot omitted management capabilities')
this.#state.installSnapshot(jobs, eventCursor, managementCapabilities)
this.#publish()
await this.#connect(generation)
await this.#connect(generation, snapshotEpoch)
if (snapshotEpoch !== this.#snapshotEpoch) return
void this.#hydrateDeletions(generation, snapshotEpoch, jobs)
} catch (error) {
if (generation !== this.#generation) return
if (
generation !== this.#generation ||
snapshotEpoch !== this.#snapshotEpoch
)
return
this.#state.setStatus('error', message(error))
this.#publish()
this.#scheduleSnapshotRetry(generation)
}
}
async #connect(generation: number): Promise<void> {
async #connect(
generation: number,
snapshotEpoch = this.#snapshotEpoch,
): Promise<void> {
const token = this.#accessToken
const cursor = this.#state.snapshot().cursor
if (token === null || cursor === null || generation !== this.#generation)
@ -172,7 +329,11 @@ export class WebClientRuntime {
this.#publish()
try {
const ticket = await this.#api.createEventTicket(token)
if (generation !== this.#generation) return
if (
generation !== this.#generation ||
snapshotEpoch !== this.#snapshotEpoch
)
return
const epoch = ++this.#connectionEpoch
this.#state.beginConnection()
const listener = this.#listener(generation, epoch)
@ -182,6 +343,60 @@ export class WebClientRuntime {
}
}
async #hydrateDeletions(
generation: number,
snapshotEpoch: number,
jobs: JobResourceDto[],
): Promise<void> {
const token = this.#accessToken
if (
token === null ||
generation !== this.#generation ||
snapshotEpoch !== this.#snapshotEpoch
)
return
const operationIds = jobs.flatMap((job) =>
job.resource_kind === 'tombstone' ? [job.deletion_operation_id] : [],
)
let next = 0
let failed = false
const hydrate = async (): Promise<void> => {
while (
generation === this.#generation &&
snapshotEpoch === this.#snapshotEpoch
) {
const operationId = operationIds[next]
next += 1
if (operationId === undefined) return
try {
const operation = await this.#api.getDeletion(token, operationId)
if (
generation !== this.#generation ||
snapshotEpoch !== this.#snapshotEpoch
)
return
if (operation === null) {
failed = true
continue
}
this.#state.replaceDeletion(operationId, operation)
this.#publish()
} catch {
if (
generation !== this.#generation ||
snapshotEpoch !== this.#snapshotEpoch
)
return
failed = true
}
}
}
await Promise.all(
Array.from({ length: Math.min(4, operationIds.length) }, hydrate),
)
if (failed) this.#scheduleHydrationRetry(generation, snapshotEpoch, jobs)
}
#listener(generation: number, epoch: number): EventSocketListener {
const current = (): boolean =>
generation === this.#generation && epoch === this.#connectionEpoch
@ -294,6 +509,23 @@ export class WebClientRuntime {
}, 1000)
}
#scheduleHydrationRetry(
generation: number,
snapshotEpoch: number,
jobs: JobResourceDto[],
): void {
if (
generation !== this.#generation ||
snapshotEpoch !== this.#snapshotEpoch ||
this.#cancelHydrationRetry !== null
)
return
this.#cancelHydrationRetry = this.#scheduler.schedule(() => {
this.#cancelHydrationRetry = null
void this.#hydrateDeletions(generation, snapshotEpoch, jobs)
}, 1000)
}
#closeConnection(): void {
const connection = this.#connection
this.#connection = null
@ -305,6 +537,39 @@ export class WebClientRuntime {
this.#cancelRetry = null
}
#cancelScheduledHydration(): void {
this.#cancelHydrationRetry?.()
this.#cancelHydrationRetry = null
}
#token(): string {
if (this.#accessToken === null) throw new Error('Sign in is required')
return this.#accessToken
}
async #sessionRequest<T>(
request: (accessToken: string) => Promise<T>,
): Promise<T> {
const accessToken = this.#token()
const generation = this.#generation
const result = await request(accessToken)
if (generation !== this.#generation || accessToken !== this.#accessToken)
throw new Error('Session changed while the operation was pending')
return result
}
async #snapshotRequest<T>(
request: (accessToken: string) => Promise<T>,
): Promise<T> {
const snapshotEpoch = this.#snapshotEpoch
const result = await this.#sessionRequest(request)
if (snapshotEpoch !== this.#snapshotEpoch)
throw new Error(
'Authorization snapshot changed while the request was pending',
)
return result
}
#publish(): void {
const snapshot = this.snapshot
for (const listener of this.#listeners) listener(snapshot)

View file

@ -4,13 +4,14 @@ import type {
EventConnection,
EventSocket,
EventSocketListener,
PublicApi,
} from '../api/client'
import { SynchronizationApiFake } from '../api/testing'
import type {
DeletionOperationDto,
EventTicketDto,
JobPageDto,
JobResourceDto,
JobSubmissionDto,
} from '../api/contracts'
import type { Session, SessionState } from '../auth/session'
import { WebClientRuntime, type Scheduler } from './runtime'
@ -188,6 +189,163 @@ describe('web client runtime', () => {
expect(fixture.runtime.snapshot.status).toBe('live')
})
})
test('fails a snapshot with a repeated page cursor', async () => {
const session = new FakeSession()
const api = new FakeApi()
const socket = new FakeSocket()
api.repeatPageCursor = true
const runtime = new WebClientRuntime(
session,
api,
socket,
new DormantScheduler(),
)
await runtime.start()
session.emit(authenticated('token-one'))
await vi.waitFor(() => {
expect(runtime.snapshot.status).toBe('error')
expect(runtime.snapshot.message).toBe('Job pagination cursor repeated')
})
expect(api.pageCursors).toEqual([undefined, 'cursor-repeat'])
expect(socket.connections).toHaveLength(0)
})
test('discards a workflow response that resolves after logout', async () => {
const fixture = runtimeFixture()
const submission = deferred<JobResourceDto>()
fixture.api.nextSubmission = submission.promise
await fixture.runtime.start()
fixture.session.emit(authenticated('token-one'))
await live(fixture)
const response = fixture.runtime.submitJob('intent_0001', {
source: {
kind: 'http',
url: 'https://example.invalid/archive',
result_path: 'archive.bin',
},
})
fixture.session.emit({ kind: 'signed_out' })
submission.resolve(job(2, { label: 'Must remain hidden' }))
await expect(response).rejects.toThrow(
'Session changed while the operation was pending',
)
expect(fixture.runtime.snapshot.status).toBe('signed_out')
expect(fixture.runtime.snapshot.jobs).toEqual([])
})
test('removes a hard-deleted job before publishing its deletion operation', async () => {
const fixture = runtimeFixture()
await fixture.runtime.start()
fixture.session.emit(authenticated('token-one'))
await live(fixture)
await expect(
fixture.runtime.hardDeleteJob('job_00000001', 'intent_hard_delete_0001'),
).resolves.toEqual(fixture.api.deletion)
expect(fixture.runtime.snapshot.jobs).toEqual([])
expect(fixture.runtime.snapshot.deletions).toEqual([fixture.api.deletion])
})
test('hydrates deletion state from a fresh tombstone snapshot', async () => {
const fixture = runtimeFixture()
fixture.api.snapshot = {
resource_kind: 'tombstone',
id: 'job_00000001',
owner: 'owner_000001',
target: {
store: 'target-main',
bucket: 'fixture-bucket',
prefix: 'results',
},
terminal_phase: 'Succeeded',
deletion_operation_id: fixture.api.deletion.id,
created_at: observedAt,
hard_delete_started_at: observedAt,
updated_at: observedAt,
event_cursor: 'cursor-snapshot-0001',
}
await fixture.runtime.start()
fixture.session.emit(authenticated('token-one'))
await live(fixture)
await vi.waitFor(() => {
expect(fixture.runtime.snapshot.deletions).toEqual([fixture.api.deletion])
})
})
test('discards tombstone hydration from an obsolete authorization snapshot', async () => {
const fixture = runtimeFixture()
const hydration = deferred<DeletionOperationDto | null>()
fixture.api.snapshot = {
resource_kind: 'tombstone',
id: 'job_00000001',
owner: 'owner_000001',
target: {
store: 'target-main',
bucket: 'fixture-bucket',
prefix: 'results',
},
terminal_phase: 'Succeeded',
deletion_operation_id: fixture.api.deletion.id,
created_at: observedAt,
hard_delete_started_at: observedAt,
updated_at: observedAt,
event_cursor: 'cursor-snapshot-0001',
}
fixture.api.nextDeletion = hydration.promise
await fixture.runtime.start()
fixture.session.emit(authenticated('token-one'))
await live(fixture)
await vi.waitFor(() => {
expect(fixture.api.deletionRequests).toBe(1)
})
fixture.api.listItems = []
await fixture.runtime.refresh()
expect(fixture.runtime.snapshot.jobs).toEqual([])
hydration.resolve(fixture.api.deletion)
await flush()
expect(fixture.runtime.snapshot.deletions).toEqual([])
})
test('discards a mutation response from an obsolete authorization snapshot', async () => {
const fixture = runtimeFixture()
const mutation = deferred<JobResourceDto>()
fixture.api.nextMutation = mutation.promise
await fixture.runtime.start()
fixture.session.emit(authenticated('token-one'))
await live(fixture)
const response = fixture.runtime.mutateJob(
'job_00000001',
'soft-delete',
'intent_soft_delete_0001',
)
await vi.waitFor(() => {
expect(fixture.api.mutationRequests).toBe(1)
})
fixture.api.listItems = []
await fixture.runtime.refresh()
mutation.resolve(
job(2, {
lifecycle: 'soft_deleted',
}),
)
await expect(response).rejects.toThrow(
'Authorization snapshot changed while the request was pending',
)
expect(fixture.runtime.snapshot.jobs).toEqual([])
})
})
function runtimeFixture(): {
@ -263,8 +421,9 @@ class FakeSession implements Session {
}
}
class FakeApi implements PublicApi {
snapshot = job(1)
class FakeApi extends SynchronizationApiFake {
snapshot: JobResourceDto = job(1)
listItems: JobResourceDto[] | null = null
readonly deletion: DeletionOperationDto = {
id: 'deletion_001',
job_id: 'job_00000001',
@ -274,18 +433,27 @@ class FakeApi implements PublicApi {
updated_at: observedAt,
}
nextJob: Promise<JobResourceDto | null> | null = null
nextSubmission: Promise<JobResourceDto> | null = null
nextDeletion: Promise<DeletionOperationDto | null> | null = null
nextMutation: Promise<JobResourceDto> | null = null
repeatPageCursor = false
listRequests = 0
ticketRequests = 0
deletionRequests = 0
mutationRequests = 0
readonly tokens: string[] = []
readonly jobRequests: string[] = []
readonly pageCursors: (string | undefined)[] = []
async listJobs(accessToken: string): Promise<JobPageDto> {
async listJobs(accessToken: string, cursor?: string): Promise<JobPageDto> {
this.listRequests += 1
this.tokens.push(accessToken)
this.pageCursors.push(cursor)
return {
items: [this.snapshot],
next_cursor: null,
items: this.listItems ?? [this.snapshot],
next_cursor: this.repeatPageCursor ? 'cursor-repeat' : null,
event_cursor: 'cursor-snapshot-0001',
management_capabilities: { revoke_execution_grants: false },
}
}
@ -307,6 +475,12 @@ class FakeApi implements PublicApi {
_accessToken: string,
_operationId: string,
): Promise<DeletionOperationDto | null> {
this.deletionRequests += 1
if (this.nextDeletion !== null) {
const response = this.nextDeletion
this.nextDeletion = null
return response
}
return this.deletion
}
@ -317,6 +491,30 @@ class FakeApi implements PublicApi {
expires_at: '2026-07-17T00:01:00Z',
}
}
async submitJob(
_accessToken: string,
_idempotencyKey: string,
_submission: JobSubmissionDto,
): Promise<JobResourceDto> {
if (this.nextSubmission === null)
throw new Error('Unexpected job submission')
const response = this.nextSubmission
this.nextSubmission = null
return response
}
async mutateJob(): Promise<JobResourceDto> {
this.mutationRequests += 1
if (this.nextMutation === null) throw new Error('Unexpected job mutation')
const response = this.nextMutation
this.nextMutation = null
return response
}
async hardDeleteJob(): Promise<DeletionOperationDto> {
return this.deletion
}
}
interface FakeConnection extends EventConnection {
@ -361,3 +559,9 @@ class ImmediateScheduler implements Scheduler {
return () => {}
}
}
class DormantScheduler implements Scheduler {
schedule(): () => void {
return () => {}
}
}

View file

@ -3,6 +3,7 @@ import type {
EventFrameDto,
EventProgressDto,
JobResourceDto,
ManagementCapabilitiesDto,
} from '../api/contracts'
export type SynchronizationStatus =
@ -19,6 +20,9 @@ export interface SynchronizationSnapshot {
deletions: DeletionOperationDto[]
cursor: string | null
message: string | null
managementCapabilities: {
revokeExecutionGrants: boolean
}
}
export type SynchronizationEffect =
@ -35,6 +39,9 @@ export class SynchronizationState {
readonly #progress = new Map<string, EventProgressDto>()
#cursor: string | null = null
#nextSequence = 1
#managementCapabilities: ManagementCapabilitiesDto = {
revoke_execution_grants: false,
}
readonly #eventIds = new BoundedSet(2048)
snapshot(): SynchronizationSnapshot {
@ -49,6 +56,10 @@ export class SynchronizationState {
),
cursor: this.#cursor,
message: this.#message,
managementCapabilities: {
revokeExecutionGrants:
this.#managementCapabilities.revoke_execution_grants,
},
}
}
@ -67,15 +78,21 @@ export class SynchronizationState {
this.#eventIds.clear()
this.#cursor = null
this.#nextSequence = 1
this.#managementCapabilities = { revoke_execution_grants: false }
this.setStatus(status)
}
installSnapshot(resources: JobResourceDto[], cursor: string): void {
installSnapshot(
resources: JobResourceDto[],
cursor: string,
managementCapabilities: ManagementCapabilitiesDto,
): void {
this.#jobs.clear()
this.#deletions.clear()
this.#progress.clear()
for (const resource of resources) this.#jobs.set(resource.id, resource)
this.#cursor = cursor
this.#managementCapabilities = managementCapabilities
this.#eventIds.clear()
}
@ -134,18 +151,35 @@ export class SynchronizationState {
resource.revision < minimumRevision
)
return false
const current = this.#jobs.get(jobId)
if (
resource?.resource_kind === 'job' &&
(current?.resource_kind === 'tombstone' ||
(current?.resource_kind === 'job' &&
current.revision > resource.revision))
)
return false
this.#progress.delete(jobId)
if (resource === null) this.#jobs.delete(jobId)
else this.#jobs.set(jobId, resource)
return true
}
removeFullJob(jobId: string): void {
this.#progress.delete(jobId)
if (this.#jobs.get(jobId)?.resource_kind === 'job') this.#jobs.delete(jobId)
}
replaceDeletion(
operationId: string,
operation: DeletionOperationDto | null,
): void {
if (operation === null) this.#deletions.delete(operationId)
else this.#deletions.set(operationId, operation)
else {
const current = this.#deletions.get(operationId)
if (current === undefined || current.updated_at < operation.updated_at)
this.#deletions.set(operationId, operation)
}
}
}

View file

@ -3,10 +3,12 @@ import { describe, expect, test } from 'vitest'
import { SynchronizationState } from './state'
import { heartbeat, job, jobChanged } from './test-fixtures'
const capabilities = { revoke_execution_grants: false }
describe('synchronization state', () => {
test('deduplicates replay while advancing the connection cursor', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
state.installSnapshot([job(1)], 'cursor-snapshot-0001', capabilities)
state.beginConnection()
expect(state.apply(jobChanged(1, 'event_000001', 2, 20))).toEqual({
@ -31,7 +33,7 @@ describe('synchronization state', () => {
test('requires increasing per-connection sequence numbers', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
state.installSnapshot([job(1)], 'cursor-snapshot-0001', capabilities)
state.beginConnection()
expect(state.apply(heartbeat(2, 'event_000002'))).toEqual({
@ -42,7 +44,7 @@ describe('synchronization state', () => {
test('retains replay deduplication when a connection restarts', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
state.installSnapshot([job(1)], 'cursor-snapshot-0001', capabilities)
state.beginConnection()
const first = heartbeat(1, 'event_000001')
state.apply(first)
@ -59,7 +61,7 @@ describe('synchronization state', () => {
test('rejects an HTTP refetch older than its invalidation', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
state.installSnapshot([job(1)], 'cursor-snapshot-0001', capabilities)
expect(state.replaceJob('job_00000001', 3, job(2))).toBe(false)
expect(state.snapshot().jobs[0]?.resource).toEqual(job(1))
@ -69,7 +71,7 @@ describe('synchronization state', () => {
test('does not expose a progress hint with mismatched authority', () => {
const state = new SynchronizationState()
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
state.installSnapshot([job(1)], 'cursor-snapshot-0001', capabilities)
state.beginConnection()
const frame = jobChanged(1, 'event_000001', 2, 20)
if (frame.kind !== 'job_changed') throw new Error('invalid fixture')
@ -79,4 +81,50 @@ describe('synchronization state', () => {
expect(state.snapshot().jobs[0]?.provisionalProgress).toBeNull()
})
test('does not replace deletion state with an older hydration response', () => {
const state = new SynchronizationState()
const current = {
id: 'deletion_001',
job_id: 'job_00000001',
state: 'blocked' as const,
problem: null,
created_at: '2026-07-17T00:00:00Z',
updated_at: '2026-07-17T00:02:00Z',
}
state.replaceDeletion(current.id, current)
state.replaceDeletion(current.id, {
...current,
state: 'running',
updated_at: '2026-07-17T00:01:00Z',
})
expect(state.snapshot().deletions).toEqual([current])
})
test('does not regress or remove a permanent tombstone', () => {
const state = new SynchronizationState()
const tombstone = {
resource_kind: 'tombstone' as const,
id: 'job_00000001',
owner: 'owner_000001',
target: {
store: 'target-main',
bucket: 'fixture-bucket',
prefix: 'results',
},
terminal_phase: 'Succeeded' as const,
deletion_operation_id: 'deletion_001',
created_at: '2026-07-17T00:00:00Z',
hard_delete_started_at: '2026-07-17T00:01:00Z',
updated_at: '2026-07-17T00:01:00Z',
event_cursor: 'cursor-snapshot-0001',
}
state.installSnapshot([tombstone], 'cursor-snapshot-0001', capabilities)
expect(state.replaceJob(tombstone.id, 0, job(9))).toBe(false)
state.removeFullJob(tombstone.id)
expect(state.snapshot().jobs[0]?.resource).toEqual(tombstone)
})
})

226
web/src/workflows/forms.ts Normal file
View file

@ -0,0 +1,226 @@
import type {
BasicAuthMaterialDto,
CredentialMaterialDto,
HttpHeadersMaterialDto,
JobSubmissionDto,
ObjectStoreCredentialMaterialDto,
SourceCredentialCreateDto,
SourceCredentialKindDto,
TokenMaterialDto,
} from '../api/contracts'
export type FormValues = Record<string, FormDataEntryValue>
export function jobSubmission(values: FormValues): JobSubmissionDto {
const kind = required(values, 'source_kind')
const credentialId = optional(values, 'credential_id')
const credential =
credentialId === undefined ? {} : { credential_id: credentialId }
let source: JobSubmissionDto['source']
if (kind === 'http') {
source = {
kind,
url: requiredExact(values, 'url'),
result_path: requiredExact(values, 'result_path'),
...credential,
}
} else if (kind === 'object_store') {
const versionId = optionalExact(values, 'version_id')
source = {
kind,
store: required(values, 'source_store'),
bucket: required(values, 'source_bucket'),
key: requiredExact(values, 'source_key'),
...(versionId === undefined ? {} : { version_id: versionId }),
result_path: requiredExact(values, 'result_path'),
...credential,
}
} else if (kind === 'hugging_face') {
const repositoryType = required(values, 'repository_type')
if (
repositoryType !== 'model' &&
repositoryType !== 'dataset' &&
repositoryType !== 'space'
)
throw new Error('Choose a valid repository type')
const paths = lines(values, 'selection_paths')
const prefixes = lines(values, 'selection_prefixes')
source = {
kind,
repository_type: repositoryType,
repository_id: requiredExact(values, 'repository_id'),
revision: requiredExact(values, 'revision'),
...(paths.length === 0 && prefixes.length === 0
? {}
: {
selection: {
...(paths.length === 0 ? {} : { paths }),
...(prefixes.length === 0 ? {} : { prefixes }),
},
}),
...credential,
}
} else {
throw new Error('Choose a valid source type')
}
const label = optional(values, 'label')
const bucket = optional(values, 'target_bucket')
const prefix = optionalExact(values, 'target_prefix')
return {
source,
...(bucket === undefined && prefix === undefined
? {}
: {
target: {
...(bucket === undefined ? {} : { bucket }),
...(prefix === undefined ? {} : { prefix }),
},
}),
...(label === undefined ? {} : { label }),
}
}
export function credentialCreation(
values: FormValues,
): SourceCredentialCreateDto {
const kind = credentialKind(required(values, 'credential_kind'))
const label = required(values, 'credential_label')
switch (kind) {
case 'basic_auth':
return { kind, label, material: credentialMaterial(kind, values) }
case 'bearer_token':
return { kind, label, material: credentialMaterial(kind, values) }
case 'http_headers':
return { kind, label, material: credentialMaterial(kind, values) }
case 'object_store':
return { kind, label, material: credentialMaterial(kind, values) }
case 'hugging_face':
return { kind, label, material: credentialMaterial(kind, values) }
}
return unreachable(kind)
}
export function credentialMaterial(
kind: 'basic_auth',
values: FormValues,
): BasicAuthMaterialDto
export function credentialMaterial(
kind: 'bearer_token' | 'hugging_face',
values: FormValues,
): TokenMaterialDto
export function credentialMaterial(
kind: 'http_headers',
values: FormValues,
): HttpHeadersMaterialDto
export function credentialMaterial(
kind: 'object_store',
values: FormValues,
): ObjectStoreCredentialMaterialDto
export function credentialMaterial(
kind: SourceCredentialKindDto,
values: FormValues,
): CredentialMaterialDto
export function credentialMaterial(
kind: SourceCredentialKindDto,
values: FormValues,
): CredentialMaterialDto {
switch (kind) {
case 'basic_auth':
return {
username: requiredMaterial(values, 'username'),
password: requiredMaterial(values, 'password'),
}
case 'bearer_token':
case 'hugging_face':
return { token: requiredMaterial(values, 'token') }
case 'object_store': {
const sessionToken = optionalMaterial(values, 'session_token')
return {
access_key_id: requiredMaterial(values, 'access_key_id'),
secret_access_key: requiredMaterial(values, 'secret_access_key'),
...(sessionToken === undefined ? {} : { session_token: sessionToken }),
}
}
case 'http_headers':
return { headers: headerLines(required(values, 'headers')) }
}
return unreachable(kind)
}
function credentialKind(value: string): SourceCredentialKindDto {
if (
value !== 'basic_auth' &&
value !== 'bearer_token' &&
value !== 'http_headers' &&
value !== 'object_store' &&
value !== 'hugging_face'
)
throw new Error('Choose a valid credential type')
return value
}
function required(values: FormValues, name: string): string {
const value = optional(values, name)
if (value === undefined)
throw new Error(`Complete ${name.replaceAll('_', ' ')}`)
return value
}
function optional(values: FormValues, name: string): string | undefined {
const value = values[name]
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
function requiredExact(values: FormValues, name: string): string {
const value = optionalExact(values, name)
if (value === undefined)
throw new Error(`Complete ${name.replaceAll('_', ' ')}`)
return value
}
function optionalExact(values: FormValues, name: string): string | undefined {
const value = values[name]
return typeof value !== 'string' || value === '' ? undefined : value
}
function requiredMaterial(values: FormValues, name: string): string {
const value = optionalMaterial(values, name)
if (value === undefined)
throw new Error(`Complete ${name.replaceAll('_', ' ')}`)
return value
}
function optionalMaterial(
values: FormValues,
name: string,
): string | undefined {
const value = values[name]
return typeof value !== 'string' || value === '' ? undefined : value
}
function lines(values: FormValues, name: string): string[] {
const value = values[name]
if (typeof value !== 'string' || value === '') return []
return value.split(/\r\n|\n|\r/).filter((line) => line !== '')
}
function headerLines(value: string): Record<string, string> {
const headers: Record<string, string> = {}
for (const line of value.split('\n')) {
const separator = line.indexOf(':')
if (separator <= 0)
throw new Error('Enter one header per line as name: value')
const name = line.slice(0, separator).trim().toLowerCase()
const member = line.slice(separator + 1).trim()
if (name === '' || member === '')
throw new Error('Enter one header per line as name: value')
headers[name] = member
}
return headers
}
function unreachable(value: never): never {
throw new Error(`Unsupported credential kind: ${String(value)}`)
}

View file

@ -0,0 +1,30 @@
import { jsonValue, requestFingerprint } from '../domain/fingerprint'
export class MutationIntents {
readonly #entries = new Map<
string,
{ fingerprint: string; idempotencyKey: string }
>()
readonly #createKey: () => string
constructor(createKey: () => string = () => crypto.randomUUID()) {
this.#createKey = createKey
}
async key(operation: string, request: unknown = null): Promise<string> {
const fingerprint = await requestFingerprint(jsonValue(request))
const existing = this.#entries.get(operation)
if (existing?.fingerprint === fingerprint) return existing.idempotencyKey
const idempotencyKey = this.#createKey()
this.#entries.set(operation, { fingerprint, idempotencyKey })
return idempotencyKey
}
complete(operation: string): void {
this.#entries.delete(operation)
}
clear(): void {
this.#entries.clear()
}
}

View file

@ -0,0 +1,22 @@
export interface CursorPage<T> {
items: T[]
next_cursor: string | null
}
export async function collectPages<T>(
request: (cursor?: string) => Promise<CursorPage<T>>,
): Promise<T[]> {
const items: T[] = []
const cursors = new Set<string>()
let cursor: string | undefined
do {
const page = await request(cursor)
items.push(...page.items)
cursor = page.next_cursor ?? undefined
if (cursor !== undefined) {
if (cursors.has(cursor)) throw new Error('Pagination cursor repeated')
cursors.add(cursor)
}
} while (cursor !== undefined)
return items
}

View file

@ -0,0 +1,36 @@
import { describe, expect, test, vi } from 'vitest'
import { collectPages } from './pages'
describe('collectPages', () => {
test('collects every page in cursor order', async () => {
const request = vi
.fn<
(cursor?: string) => Promise<{
items: string[]
next_cursor: string | null
}>
>()
.mockResolvedValueOnce({ items: ['first'], next_cursor: 'cursor-2' })
.mockResolvedValueOnce({ items: ['second'], next_cursor: null })
await expect(collectPages(request)).resolves.toEqual(['first', 'second'])
expect(request.mock.calls).toEqual([[undefined], ['cursor-2']])
})
test('rejects a repeated cursor', async () => {
const request = vi
.fn<
(cursor?: string) => Promise<{
items: string[]
next_cursor: string | null
}>
>()
.mockResolvedValueOnce({ items: ['first'], next_cursor: 'cursor-2' })
.mockResolvedValueOnce({ items: ['second'], next_cursor: 'cursor-2' })
await expect(collectPages(request)).rejects.toThrow(
'Pagination cursor repeated',
)
})
})

View file

@ -0,0 +1,114 @@
import { describe, expect, test } from 'vitest'
import { credentialCreation, jobSubmission } from './forms'
import { MutationIntents } from './intents'
describe('workflow forms', () => {
test('builds each source submission without empty optional values', () => {
expect(
jobSubmission({
source_kind: 'http',
url: 'https://example.invalid/archive',
result_path: 'archive.bin',
credential_id: '',
label: 'Archive',
target_bucket: '',
target_prefix: 'imports',
}),
).toEqual({
source: {
kind: 'http',
url: 'https://example.invalid/archive',
result_path: 'archive.bin',
},
target: { prefix: 'imports' },
label: 'Archive',
})
expect(
jobSubmission({
source_kind: 'object_store',
source_store: 'source-main',
source_bucket: 'source-bucket',
source_key: ' archive ',
version_id: ' version-1 ',
result_path: ' result.bin ',
}),
).toMatchObject({
source: {
kind: 'object_store',
key: ' archive ',
version_id: ' version-1 ',
result_path: ' result.bin ',
},
})
expect(
jobSubmission({
source_kind: 'hugging_face',
repository_type: 'dataset',
repository_id: 'owner/repository',
revision: 'main',
selection_paths: 'one.bin\n two.bin ',
selection_prefixes: '',
}),
).toMatchObject({
source: {
kind: 'hugging_face',
selection: { paths: ['one.bin', ' two.bin '] },
},
})
})
test('builds closed credential material shapes', () => {
expect(
credentialCreation({
credential_kind: 'object_store',
credential_label: 'Source',
access_key_id: 'access',
secret_access_key: 'secret',
session_token: '',
}),
).toEqual({
kind: 'object_store',
label: 'Source',
material: {
access_key_id: 'access',
secret_access_key: 'secret',
},
})
expect(
credentialCreation({
credential_kind: 'bearer_token',
credential_label: 'Token',
token: ' exact secret ',
}),
).toEqual({
kind: 'bearer_token',
label: 'Token',
material: { token: ' exact secret ' },
})
})
})
test('mutation intents retain a key only for the same ambiguous request', async () => {
let sequence = 0
const intents = new MutationIntents(() => `intent_${++sequence}`)
await expect(intents.key('submit', { label: 'one' })).resolves.toBe(
'intent_1',
)
await expect(intents.key('submit', { label: 'one' })).resolves.toBe(
'intent_1',
)
await expect(intents.key('submit', { label: 'two' })).resolves.toBe(
'intent_2',
)
intents.complete('submit')
await expect(intents.key('submit', { label: 'two' })).resolves.toBe(
'intent_3',
)
await expect(
intents.key('credential', { username: 'user', password: 'secret' }),
).resolves.toBe('intent_4')
await expect(
intents.key('credential', { password: 'secret', username: 'user' }),
).resolves.toBe('intent_4')
})

View file

@ -0,0 +1,422 @@
import { expect, test, type Page, type Route } from '@playwright/test'
const observedAt = '2026-07-18T00:00:00Z'
const result: MockResult = {
kind: 'manifest',
manifest_id: 'manifest_001',
schema_version: 1,
manifest: {
store: 'target-main',
bucket: 'fixture-bucket',
key: 'manifest.json',
checksum: `sha256:${'a'.repeat(64)}`,
size: 12,
},
member_count: 1,
total_bytes: 12,
}
test.beforeEach(async ({ page }) => {
await page.routeWebSocket(/\/api\/v1\/events/, () => {})
})
test('completes credential, submission, result, history, and lifecycle workflows', async ({
page,
}) => {
const state = await mockApi(page)
await authenticatedSession(page)
await expect
.poll(() =>
page.evaluate(() =>
sessionStorage.getItem(
`oidc.user:${window.location.origin}/oidc:remora-web`,
),
),
)
.not.toBeNull()
await expect(page.getByRole('heading', { name: 'Remora' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
await expect(page.getByLabel('Synchronization status')).toHaveText('live')
await expect.poll(() => state.paths).toContain('jobs')
await expect(page.getByText('Existing archive')).toBeVisible()
await page.keyboard.press('Tab')
await expect(page.getByRole('button', { name: 'Sign out' })).toBeFocused()
await page.getByRole('button', { name: 'Credentials' }).click()
await page.getByLabel('Label').fill('Archive token')
const newToken = page.getByRole('textbox', {
name: 'New token',
exact: true,
})
await newToken.fill('secret-token')
await page.getByRole('button', { name: 'Create credential' }).click()
await expect(newToken).toHaveValue('')
await page.getByLabel('Replacement token').fill('replacement-token')
await page.getByRole('button', { name: 'Rotate credential' }).click()
await expect(page.getByLabel('Replacement token')).toHaveValue('')
await page.getByRole('button', { name: 'Submit job' }).click()
await page.getByLabel('Label').fill('Imported archive')
await page
.getByLabel('URL')
.fill('https://example.invalid/archive?token=sensitive')
await page.getByLabel('Result path').fill('archive.bin')
await page.getByLabel('Source credential').selectOption('credential_001')
await page.getByRole('button', { name: 'Submit job now' }).click()
await expect(page.getByText('Job accepted')).toBeVisible()
await expect.poll(() => state.submission?.label).toBe('Imported archive')
await expect(
page.getByRole('heading', { name: 'Imported archive' }),
).toBeVisible()
expect(state.submission?.source.credential_id).toBe('credential_001')
await page.getByRole('button', { name: 'Load attempt history' }).click()
await expect(page.getByText('AttemptCommitted')).toBeVisible()
await page.getByRole('button', { name: 'Browse result' }).click()
await expect(page.getByText('archive.bin')).toBeVisible()
const softDelete = page.getByRole('button', { name: 'Soft delete' })
await softDelete.click()
await expect(page.getByRole('alertdialog')).toContainText(
'may still be restored',
)
await expect(page.getByRole('button', { name: 'Keep job' })).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.getByRole('alertdialog')).toBeHidden()
await expect(softDelete).toBeFocused()
await softDelete.click()
await page.getByRole('button', { name: 'Confirm' }).click()
await page.reload()
await expect(page.getByLabel('Synchronization status')).toHaveText('live')
await page.getByRole('button', { name: /Imported archive/ }).click()
expect(state.includeDeletedRequests).toBeGreaterThan(1)
await expect(page.getByRole('button', { name: 'Browse result' })).toBeHidden()
await page.getByRole('button', { name: 'Restore' }).click()
await page.getByRole('button', { name: 'Confirm' }).click()
await page.getByRole('button', { name: 'Revoke execution grant' }).click()
await page.getByRole('button', { name: 'Confirm' }).click()
await page.getByRole('button', { name: 'Hard delete' }).click()
await expect(page.getByRole('alertdialog')).toContainText('irreversible')
await page.getByRole('button', { name: 'Confirm' }).click()
await expect(
page.getByRole('list', { name: 'Deletion operations' }).getByText('queued'),
).toBeVisible()
await page.getByRole('button', { name: 'Credentials' }).click()
await page.getByRole('button', { name: /Archive token/ }).click()
await page.getByRole('button', { name: 'Revoke credential' }).click()
await expect(page.getByRole('alertdialog')).toContainText('future execution')
await page.getByRole('button', { name: 'Confirm' }).click()
await expect(
page.getByText('Credential revoked for future grants'),
).toBeVisible()
expect(state.mutations).toEqual([
'soft-delete',
'restore',
'revoke',
'hard-delete',
])
expect(new Set(state.idempotencyKeys).size).toBe(state.idempotencyKeys.length)
})
async function authenticatedSession(page: Page): Promise<void> {
await page.goto('/')
await page.evaluate(() => {
const authority = `${window.location.origin}/oidc`
sessionStorage.setItem(
`oidc.user:${authority}:remora-web`,
JSON.stringify({
id_token: 'id-token',
access_token: 'access-token',
token_type: 'Bearer',
scope: 'openid profile',
profile: { iss: authority, sub: 'subject-0001' },
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
)
})
await page.reload()
}
async function mockApi(page: Page): Promise<{
submission: MockSubmission | null
mutations: string[]
idempotencyKeys: string[]
paths: string[]
includeDeletedRequests: number
}> {
const state: {
submission: MockSubmission | null
mutations: string[]
idempotencyKeys: string[]
paths: string[]
includeDeletedRequests: number
} = {
submission: null,
mutations: [],
idempotencyKeys: [],
paths: [],
includeDeletedRequests: 0,
}
let credential = sourceCredential('active')
let currentJob = job('Existing archive')
await page.route(/\/api\/v1\//, async (route) => {
const request = route.request()
const url = new URL(request.url())
const path = url.pathname.replace('/api/v1/', '')
state.paths.push(path)
if (
path === 'jobs' &&
request.method() === 'GET' &&
url.searchParams.get('include_deleted') === 'true'
)
state.includeDeletedRequests += 1
if (request.method() === 'POST') {
const key = request.headers()['idempotency-key']
if (path !== 'event-tickets' && key !== undefined)
state.idempotencyKeys.push(key)
}
if (path === 'jobs' && request.method() === 'GET')
return json(route, {
items: [currentJob],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
management_capabilities: { revoke_execution_grants: true },
})
if (path === 'event-tickets')
return json(
route,
{
ticket: 'ticket_00001',
expires_at: '2026-07-18T00:01:00Z',
},
201,
)
if (path === 'source-credentials' && request.method() === 'GET')
return json(route, { items: [credential], next_cursor: null })
if (path === 'source-credentials' && request.method() === 'POST') {
const body = object(request.postDataJSON())
credential = {
...sourceCredential('active'),
label: String(body.label),
}
return json(route, credential, 201)
}
if (path.endsWith('/versions')) {
credential = {
...credential,
active_version: { id: 'version_0002', created_at: observedAt },
}
return json(route, credential, 201)
}
if (path.endsWith('/revoke') && path.startsWith('source-credentials/')) {
credential = { ...credential, status: 'revoked', active_version: null }
return json(route, credential)
}
if (path === 'jobs' && request.method() === 'POST') {
const body = object(request.postDataJSON())
const source = object(body.source)
state.submission = {
...(typeof body.label === 'string' ? { label: body.label } : {}),
source: {
...(typeof source.credential_id === 'string'
? { credential_id: source.credential_id }
: {}),
},
}
currentJob = {
...job(state.submission.label ?? 'Submitted job'),
source: {
kind: 'http',
display_url: 'https://example.invalid/archive',
result_path: 'archive.bin',
...(state.submission.source.credential_id === undefined
? {}
: { credential_id: state.submission.source.credential_id }),
},
}
return json(route, currentJob, 202)
}
if (path.endsWith('/attempts'))
return json(route, {
items: [
{
id: 'attempt_0001',
fence: 1,
worker_incarnation: 'worker_00001',
state: 'AttemptCommitted',
progress: null,
result,
failure: null,
created_at: observedAt,
updated_at: observedAt,
},
],
next_cursor: null,
})
if (path.endsWith('/result')) return json(route, result)
if (path.endsWith('/result/members'))
return json(route, {
items: [
{
id: 'member_00001',
path: 'archive.bin',
size: 12,
object: result.manifest,
},
],
next_cursor: null,
})
for (const operation of ['soft-delete', 'restore', 'revoke']) {
if (path.endsWith(`/${operation}`)) {
state.mutations.push(operation)
currentJob = {
...currentJob,
revision: currentJob.revision + 1,
lifecycle:
operation === 'soft-delete'
? 'soft_deleted'
: operation === 'restore'
? 'visible'
: currentJob.lifecycle,
}
return json(route, currentJob, operation === 'revoke' ? 202 : 200)
}
}
if (path.endsWith('/hard-delete')) {
state.mutations.push('hard-delete')
return json(
route,
{
id: 'deletion_001',
job_id: currentJob.id,
state: 'queued',
problem: null,
created_at: observedAt,
updated_at: observedAt,
},
202,
)
}
return route.fulfill({ status: 404 })
})
return state
}
function job(label: string): MockJob {
return {
resource_kind: 'job',
id: 'job_00000001',
owner: 'owner_000001',
label,
revision: 1,
phase: 'Succeeded',
lifecycle: 'visible',
source: {
kind: 'http',
display_url: 'https://example.invalid/archive',
result_path: 'archive.bin',
},
target: {
store: 'target-main',
bucket: 'fixture-bucket',
prefix: 'results',
},
authoritative_attempt: null,
progress: null,
checkpoint: null,
result,
failure: null,
created_at: observedAt,
updated_at: observedAt,
event_cursor: 'cursor-snapshot-0001',
}
}
function sourceCredential(status: MockCredential['status']): MockCredential {
return {
id: 'credential_001',
owner: 'owner_000001',
label: 'Fixture credential',
kind: 'bearer_token',
status,
active_version:
status === 'active'
? { id: 'version_0001', created_at: observedAt }
: null,
created_at: observedAt,
updated_at: observedAt,
}
}
function object(value: unknown): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value))
throw new Error('Expected object request')
return Object.fromEntries(Object.entries(value))
}
interface MockSubmission {
label?: string
source: { credential_id?: string }
}
interface MockResult {
kind: 'manifest'
manifest_id: string
schema_version: number
manifest: {
store: string
bucket: string
key: string
checksum: string
size: number
}
member_count: number
total_bytes: number
}
interface MockJob {
resource_kind: 'job'
id: string
owner: string
label: string
revision: number
phase: 'Succeeded'
lifecycle: 'visible' | 'soft_deleted'
source: {
kind: 'http'
display_url: string
result_path: string
credential_id?: string
}
target: { store: string; bucket: string; prefix: string }
authoritative_attempt: null
progress: null
checkpoint: null
result: MockResult
failure: null
created_at: string
updated_at: string
event_cursor: string
}
interface MockCredential {
id: string
owner: string
label: string
kind: 'bearer_token'
status: 'active' | 'revoked'
active_version: { id: string; created_at: string } | null
created_at: string
updated_at: string
}
function json(route: Route, body: unknown, status = 200): Promise<void> {
return route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
})
}