Implement authorization-fenced web workflows

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

View file

@ -9,9 +9,9 @@ use remora_admission::{AdmissionDecision, AdmissionService};
use remora_attempt::{AttemptService, CommittedResultRecord, ProgressSnapshot};
use remora_contracts::{
ApiErrorCodeDto, ApiErrorDto, CredentialMaterialDto, DateTimeDto, JobSubmissionDto,
NullableDto, SourceCredentialCreateDto, SourceCredentialDto, SourceCredentialKindDto,
SourceCredentialPageDto, SourceCredentialRotateDto, SourceCredentialStatusDto,
SourceCredentialVersionDto,
NullableDto, ResultMemberAccessDto, SourceCredentialCreateDto, SourceCredentialDto,
SourceCredentialKindDto, SourceCredentialPageDto, SourceCredentialRotateDto,
SourceCredentialStatusDto, SourceCredentialVersionDto,
};
use remora_credentials::{
CreateCredential, CredentialCaller, CredentialError, CredentialRegistry, RotateTxOutcome,
@ -384,7 +384,10 @@ async fn list_jobs(
Ok(HttpResponse::Ok().json(json!({
"items": items,
"next_cursor": next_cursor,
"event_cursor": event_cursor
"event_cursor": event_cursor,
"management_capabilities": {
"revoke_execution_grants": principal.administrator
}
})))
}
@ -493,14 +496,20 @@ async fn access_result_member(
.targets
.resolve(member.object().store(), member.object().bucket())
.ok_or_else(PublicApiError::unavailable)?;
let issued_at = OffsetDateTime::now_utc();
let access = target
.presign_read(&member.exact_identity(), state.member_access_ttl)
.await
.map_err(|_| PublicApiError::unavailable())?;
Ok(HttpResponse::TemporaryRedirect()
.insert_header((header::LOCATION, access.url))
let duration =
time::Duration::try_from(access.expires_in).map_err(|_| PublicApiError::internal())?;
Ok(HttpResponse::Ok()
.insert_header((header::CACHE_CONTROL, "no-store"))
.finish())
.insert_header((header::PRAGMA, "no-cache"))
.json(ResultMemberAccessDto {
url: access.url,
expires_at: datetime(issued_at + duration)?,
}))
}
async fn cancel_job(

View file

@ -290,6 +290,10 @@ async fn public_routes_are_authenticated_replayable_authorized_and_redacted() {
assert_eq!(listed.status(), StatusCode::OK);
let page: Value = listed.json().await.unwrap();
serde_json::from_value::<JobPageDto>(page.clone()).unwrap();
assert_eq!(
page["management_capabilities"]["revoke_execution_grants"],
true
);
let first_page_id = page["items"][0]["id"].as_str().unwrap();
assert!(page["event_cursor"].as_str().unwrap().len() >= 16);
let next_cursor = page["next_cursor"].as_str().unwrap();
@ -616,8 +620,13 @@ async fn public_routes_are_authenticated_replayable_authorized_and_redacted() {
.send()
.await
.unwrap();
assert_eq!(access.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(access.status(), StatusCode::OK);
assert_eq!(access.headers()["cache-control"], "no-store");
assert_eq!(access.headers()["pragma"], "no-cache");
let access: Value = access.json().await.unwrap();
let access_url = reqwest::Url::parse(access["url"].as_str().unwrap()).unwrap();
assert!(matches!(access_url.scheme(), "http" | "https"));
assert!(access["expires_at"].is_string());
let attempts = harness
.request(reqwest::Method::GET, &format!("/jobs/{job_id}/attempts"))
@ -743,6 +752,16 @@ async fn public_routes_are_authenticated_replayable_authorized_and_redacted() {
.await
.unwrap();
assert_eq!(lost_scope.status(), StatusCode::NOT_FOUND);
let unprivileged_page = harness
.request_with_token(reqwest::Method::GET, "/jobs", &unprivileged_token)
.send()
.await
.unwrap();
assert_eq!(unprivileged_page.status(), StatusCode::OK);
assert_eq!(
unprivileged_page.json::<Value>().await.unwrap()["management_capabilities"]["revoke_execution_grants"],
false
);
let hidden_submit_replay = harness
.request_with_token(reqwest::Method::POST, "/jobs", &unprivileged_token)
.header("Idempotency-Key", &submit_key)

View file

@ -42,8 +42,9 @@ pub use public::{
DeletionProblemDto, DeletionStateDto as HttpDeletionStateDto, DurableDescriptorKindDto,
DurableDescriptorRefDto, HttpSourceProjectionDto, HuggingFaceSourceProjectionDto, JobDto,
JobKindDto, JobPageDto, JobResourceDto, JobTombstoneDto, LeaseProjectionDto,
ObjectStoreSourceProjectionDto, ProgressValueDto, ResultMemberDto, ResultMemberPageDto,
SourceProjectionDto, StagingTargetDto, TerminalJobPhaseDto, TombstoneKindDto,
ManagementCapabilitiesDto, ObjectStoreSourceProjectionDto, ProgressValueDto,
ResultMemberAccessDto, ResultMemberDto, ResultMemberPageDto, SourceProjectionDto,
StagingTargetDto, TerminalJobPhaseDto, TombstoneKindDto,
};
pub use wire::{
CursorDto, DateTimeDto, NullableDto, PositiveIntegerDto, SafeIntegerDto, SchemaVersionDto,

View file

@ -132,6 +132,13 @@ pub struct ResultMemberPageDto {
pub next_cursor: NullableDto<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ResultMemberAccessDto {
pub url: String,
pub expires_at: DateTimeDto,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum JobKindDto {
@ -195,12 +202,19 @@ pub enum JobResourceDto {
Tombstone(Box<JobTombstoneDto>),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ManagementCapabilitiesDto {
pub revoke_execution_grants: bool,
}
#[derive(Clone, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct JobPageDto {
pub items: Vec<JobResourceDto>,
pub next_cursor: NullableDto<String>,
pub event_cursor: CursorDto,
pub management_capabilities: ManagementCapabilitiesDto,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]

View file

@ -101,6 +101,11 @@ unauthorized, absent, and never-committed results are not exposed. The hard
delete endpoint returns the durable deletion resource established by the
architecture.
Exact member access returns a non-cacheable time-limited URL after current
authorization is revalidated. This JSON exchange lets a bearer-authenticated
browser deliberately navigate to the target URL without relying on an
unobservable cross-origin fetch redirect.
Before hard deletion, job reads return the complete `job` projection. Once only
the permanent minimal record remains, authorized reads and deleted-job pages use
the distinct `tombstone` projection rather than inventing discarded job fields.

View file

@ -120,22 +120,20 @@ paths:
$ref: '#/components/schemas/OpaqueId'
get:
operationId: accessJobResultMember
description: Revalidates current authorization and redirects to a time-limited URL for the exact committed member identity.
description: Revalidates current authorization and returns a non-cacheable time-limited URL for the exact committed member identity.
responses:
'307':
description: Temporary redirect to the exact committed member. The URL is sensitive and must not be logged or cached.
'200':
description: Time-limited exact-member access grant. The URL is sensitive and must not be logged or cached.
headers:
Location:
description: Time-limited member URL.
required: true
schema:
type: string
format: uri
Cache-Control:
description: Prevents caching of the credential-bearing redirect response.
description: Prevents caching of the credential-bearing response.
required: true
schema:
const: no-store
content:
application/json:
schema:
$ref: '#/components/schemas/ResultMemberAccess'
'404':
$ref: '#/components/responses/NotFound'
'409':
@ -1450,6 +1448,19 @@ components:
$ref: '#/components/schemas/ResultMember'
next_cursor:
type: [string, 'null']
ResultMemberAccess:
type: object
additionalProperties: false
required: [url, expires_at]
properties:
url:
type: string
format: uri
pattern: '^https?://'
description: Sensitive time-limited HTTP(S) exact-object URL.
expires_at:
type: string
format: date-time
Job:
type: object
additionalProperties: false
@ -1546,7 +1557,7 @@ components:
JobPage:
type: object
additionalProperties: false
required: [items, next_cursor, event_cursor]
required: [items, next_cursor, event_cursor, management_capabilities]
properties:
items:
type: array
@ -1557,6 +1568,16 @@ components:
event_cursor:
type: string
minLength: 16
management_capabilities:
$ref: '#/components/schemas/ManagementCapabilities'
ManagementCapabilities:
type: object
additionalProperties: false
required: [revoke_execution_grants]
properties:
revoke_execution_grants:
type: boolean
description: Current caller may administratively revoke accepted execution grants.
AttemptRecord:
type: object
additionalProperties: false

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 response = await this.#fetch(url, {
method,
headers: {
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: 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))
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()
}
}

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