Implement authorization-fenced web workflows
This commit is contained in:
parent
459ffa04c6
commit
267665bb35
24 changed files with 3853 additions and 156 deletions
|
|
@ -9,9 +9,9 @@ use remora_admission::{AdmissionDecision, AdmissionService};
|
||||||
use remora_attempt::{AttemptService, CommittedResultRecord, ProgressSnapshot};
|
use remora_attempt::{AttemptService, CommittedResultRecord, ProgressSnapshot};
|
||||||
use remora_contracts::{
|
use remora_contracts::{
|
||||||
ApiErrorCodeDto, ApiErrorDto, CredentialMaterialDto, DateTimeDto, JobSubmissionDto,
|
ApiErrorCodeDto, ApiErrorDto, CredentialMaterialDto, DateTimeDto, JobSubmissionDto,
|
||||||
NullableDto, SourceCredentialCreateDto, SourceCredentialDto, SourceCredentialKindDto,
|
NullableDto, ResultMemberAccessDto, SourceCredentialCreateDto, SourceCredentialDto,
|
||||||
SourceCredentialPageDto, SourceCredentialRotateDto, SourceCredentialStatusDto,
|
SourceCredentialKindDto, SourceCredentialPageDto, SourceCredentialRotateDto,
|
||||||
SourceCredentialVersionDto,
|
SourceCredentialStatusDto, SourceCredentialVersionDto,
|
||||||
};
|
};
|
||||||
use remora_credentials::{
|
use remora_credentials::{
|
||||||
CreateCredential, CredentialCaller, CredentialError, CredentialRegistry, RotateTxOutcome,
|
CreateCredential, CredentialCaller, CredentialError, CredentialRegistry, RotateTxOutcome,
|
||||||
|
|
@ -384,7 +384,10 @@ async fn list_jobs(
|
||||||
Ok(HttpResponse::Ok().json(json!({
|
Ok(HttpResponse::Ok().json(json!({
|
||||||
"items": items,
|
"items": items,
|
||||||
"next_cursor": next_cursor,
|
"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
|
.targets
|
||||||
.resolve(member.object().store(), member.object().bucket())
|
.resolve(member.object().store(), member.object().bucket())
|
||||||
.ok_or_else(PublicApiError::unavailable)?;
|
.ok_or_else(PublicApiError::unavailable)?;
|
||||||
|
let issued_at = OffsetDateTime::now_utc();
|
||||||
let access = target
|
let access = target
|
||||||
.presign_read(&member.exact_identity(), state.member_access_ttl)
|
.presign_read(&member.exact_identity(), state.member_access_ttl)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| PublicApiError::unavailable())?;
|
.map_err(|_| PublicApiError::unavailable())?;
|
||||||
Ok(HttpResponse::TemporaryRedirect()
|
let duration =
|
||||||
.insert_header((header::LOCATION, access.url))
|
time::Duration::try_from(access.expires_in).map_err(|_| PublicApiError::internal())?;
|
||||||
|
Ok(HttpResponse::Ok()
|
||||||
.insert_header((header::CACHE_CONTROL, "no-store"))
|
.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(
|
async fn cancel_job(
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,10 @@ async fn public_routes_are_authenticated_replayable_authorized_and_redacted() {
|
||||||
assert_eq!(listed.status(), StatusCode::OK);
|
assert_eq!(listed.status(), StatusCode::OK);
|
||||||
let page: Value = listed.json().await.unwrap();
|
let page: Value = listed.json().await.unwrap();
|
||||||
serde_json::from_value::<JobPageDto>(page.clone()).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();
|
let first_page_id = page["items"][0]["id"].as_str().unwrap();
|
||||||
assert!(page["event_cursor"].as_str().unwrap().len() >= 16);
|
assert!(page["event_cursor"].as_str().unwrap().len() >= 16);
|
||||||
let next_cursor = page["next_cursor"].as_str().unwrap();
|
let next_cursor = page["next_cursor"].as_str().unwrap();
|
||||||
|
|
@ -616,8 +620,13 @@ async fn public_routes_are_authenticated_replayable_authorized_and_redacted() {
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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()["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
|
let attempts = harness
|
||||||
.request(reqwest::Method::GET, &format!("/jobs/{job_id}/attempts"))
|
.request(reqwest::Method::GET, &format!("/jobs/{job_id}/attempts"))
|
||||||
|
|
@ -743,6 +752,16 @@ async fn public_routes_are_authenticated_replayable_authorized_and_redacted() {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(lost_scope.status(), StatusCode::NOT_FOUND);
|
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
|
let hidden_submit_replay = harness
|
||||||
.request_with_token(reqwest::Method::POST, "/jobs", &unprivileged_token)
|
.request_with_token(reqwest::Method::POST, "/jobs", &unprivileged_token)
|
||||||
.header("Idempotency-Key", &submit_key)
|
.header("Idempotency-Key", &submit_key)
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,9 @@ pub use public::{
|
||||||
DeletionProblemDto, DeletionStateDto as HttpDeletionStateDto, DurableDescriptorKindDto,
|
DeletionProblemDto, DeletionStateDto as HttpDeletionStateDto, DurableDescriptorKindDto,
|
||||||
DurableDescriptorRefDto, HttpSourceProjectionDto, HuggingFaceSourceProjectionDto, JobDto,
|
DurableDescriptorRefDto, HttpSourceProjectionDto, HuggingFaceSourceProjectionDto, JobDto,
|
||||||
JobKindDto, JobPageDto, JobResourceDto, JobTombstoneDto, LeaseProjectionDto,
|
JobKindDto, JobPageDto, JobResourceDto, JobTombstoneDto, LeaseProjectionDto,
|
||||||
ObjectStoreSourceProjectionDto, ProgressValueDto, ResultMemberDto, ResultMemberPageDto,
|
ManagementCapabilitiesDto, ObjectStoreSourceProjectionDto, ProgressValueDto,
|
||||||
SourceProjectionDto, StagingTargetDto, TerminalJobPhaseDto, TombstoneKindDto,
|
ResultMemberAccessDto, ResultMemberDto, ResultMemberPageDto, SourceProjectionDto,
|
||||||
|
StagingTargetDto, TerminalJobPhaseDto, TombstoneKindDto,
|
||||||
};
|
};
|
||||||
pub use wire::{
|
pub use wire::{
|
||||||
CursorDto, DateTimeDto, NullableDto, PositiveIntegerDto, SafeIntegerDto, SchemaVersionDto,
|
CursorDto, DateTimeDto, NullableDto, PositiveIntegerDto, SafeIntegerDto, SchemaVersionDto,
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,13 @@ pub struct ResultMemberPageDto {
|
||||||
pub next_cursor: NullableDto<String>,
|
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)]
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum JobKindDto {
|
pub enum JobKindDto {
|
||||||
|
|
@ -195,12 +202,19 @@ pub enum JobResourceDto {
|
||||||
Tombstone(Box<JobTombstoneDto>),
|
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)]
|
#[derive(Clone, Deserialize, PartialEq, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct JobPageDto {
|
pub struct JobPageDto {
|
||||||
pub items: Vec<JobResourceDto>,
|
pub items: Vec<JobResourceDto>,
|
||||||
pub next_cursor: NullableDto<String>,
|
pub next_cursor: NullableDto<String>,
|
||||||
pub event_cursor: CursorDto,
|
pub event_cursor: CursorDto,
|
||||||
|
pub management_capabilities: ManagementCapabilitiesDto,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
|
|
||||||
|
|
@ -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
|
delete endpoint returns the durable deletion resource established by the
|
||||||
architecture.
|
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
|
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 permanent minimal record remains, authorized reads and deleted-job pages use
|
||||||
the distinct `tombstone` projection rather than inventing discarded job fields.
|
the distinct `tombstone` projection rather than inventing discarded job fields.
|
||||||
|
|
|
||||||
|
|
@ -120,22 +120,20 @@ paths:
|
||||||
$ref: '#/components/schemas/OpaqueId'
|
$ref: '#/components/schemas/OpaqueId'
|
||||||
get:
|
get:
|
||||||
operationId: accessJobResultMember
|
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:
|
responses:
|
||||||
'307':
|
'200':
|
||||||
description: Temporary redirect to the exact committed member. The URL is sensitive and must not be logged or cached.
|
description: Time-limited exact-member access grant. The URL is sensitive and must not be logged or cached.
|
||||||
headers:
|
headers:
|
||||||
Location:
|
|
||||||
description: Time-limited member URL.
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
Cache-Control:
|
Cache-Control:
|
||||||
description: Prevents caching of the credential-bearing redirect response.
|
description: Prevents caching of the credential-bearing response.
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
const: no-store
|
const: no-store
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ResultMemberAccess'
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
'409':
|
'409':
|
||||||
|
|
@ -1450,6 +1448,19 @@ components:
|
||||||
$ref: '#/components/schemas/ResultMember'
|
$ref: '#/components/schemas/ResultMember'
|
||||||
next_cursor:
|
next_cursor:
|
||||||
type: [string, 'null']
|
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:
|
Job:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|
@ -1546,7 +1557,7 @@ components:
|
||||||
JobPage:
|
JobPage:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [items, next_cursor, event_cursor]
|
required: [items, next_cursor, event_cursor, management_capabilities]
|
||||||
properties:
|
properties:
|
||||||
items:
|
items:
|
||||||
type: array
|
type: array
|
||||||
|
|
@ -1557,6 +1568,16 @@ components:
|
||||||
event_cursor:
|
event_cursor:
|
||||||
type: string
|
type: string
|
||||||
minLength: 16
|
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:
|
AttemptRecord:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,24 @@ import type {
|
||||||
EventConnection,
|
EventConnection,
|
||||||
EventSocket,
|
EventSocket,
|
||||||
EventSocketListener,
|
EventSocketListener,
|
||||||
PublicApi,
|
|
||||||
} from './api/client'
|
} from './api/client'
|
||||||
|
import { ApiResponseError } from './api/client'
|
||||||
|
import { SynchronizationApiFake } from './api/testing'
|
||||||
import type {
|
import type {
|
||||||
|
AttemptPageDto,
|
||||||
DeletionOperationDto,
|
DeletionOperationDto,
|
||||||
EventFrameDto,
|
EventFrameDto,
|
||||||
EventTicketDto,
|
EventTicketDto,
|
||||||
JobPageDto,
|
JobPageDto,
|
||||||
JobResourceDto,
|
JobResourceDto,
|
||||||
|
JobSubmissionDto,
|
||||||
|
ResultDescriptorDto,
|
||||||
|
ResultMemberAccessDto,
|
||||||
|
ResultMemberPageDto,
|
||||||
|
SourceCredentialCreateDto,
|
||||||
|
SourceCredentialDto,
|
||||||
|
SourceCredentialPageDto,
|
||||||
|
SourceCredentialRotateDto,
|
||||||
} from './api/contracts'
|
} from './api/contracts'
|
||||||
import App from './App.svelte'
|
import App from './App.svelte'
|
||||||
import type { Session, SessionState } from './auth/session'
|
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'])
|
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(): {
|
function runtimeFixture(): {
|
||||||
runtime: WebClientRuntime
|
runtime: WebClientRuntime
|
||||||
session: FakeSession
|
session: FakeSession
|
||||||
|
|
@ -116,20 +415,32 @@ class FakeSession implements Session {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeApi implements PublicApi {
|
class FakeApi extends SynchronizationApiFake {
|
||||||
snapshot = job(1, { label: 'Initial snapshot' })
|
snapshot: JobResourceDto = job(1, { label: 'Initial snapshot' })
|
||||||
|
snapshots: JobResourceDto[] | null = null
|
||||||
refetched = job(2)
|
refetched = job(2)
|
||||||
listRequests = 0
|
listRequests = 0
|
||||||
jobRequests = 0
|
jobRequests = 0
|
||||||
readonly tokens: string[] = []
|
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> {
|
async listJobs(accessToken: string): Promise<JobPageDto> {
|
||||||
this.listRequests += 1
|
this.listRequests += 1
|
||||||
this.tokens.push(accessToken)
|
this.tokens.push(accessToken)
|
||||||
return {
|
return {
|
||||||
items: [this.snapshot],
|
items: this.snapshots ?? [this.snapshot],
|
||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
event_cursor: 'cursor-snapshot-0001',
|
event_cursor: 'cursor-snapshot-0001',
|
||||||
|
management_capabilities: {
|
||||||
|
revoke_execution_grants: this.canRevokeExecutionGrants,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +456,7 @@ class FakeApi implements PublicApi {
|
||||||
_accessToken: string,
|
_accessToken: string,
|
||||||
_operationId: string,
|
_operationId: string,
|
||||||
): Promise<DeletionOperationDto | null> {
|
): Promise<DeletionOperationDto | null> {
|
||||||
return null
|
return this.deletion
|
||||||
}
|
}
|
||||||
|
|
||||||
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
|
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
|
||||||
|
|
@ -154,6 +465,178 @@ class FakeApi implements PublicApi {
|
||||||
expires_at: '2026-07-17T00:01:00Z',
|
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 {
|
class FakeSocket implements EventSocket {
|
||||||
|
|
@ -180,3 +663,39 @@ class ImmediateScheduler implements Scheduler {
|
||||||
return () => {}
|
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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1010
web/src/App.svelte
1010
web/src/App.svelte
File diff suppressed because it is too large
Load diff
92
web/src/ConfirmationDialog.svelte
Normal file
92
web/src/ConfirmationDialog.svelte
Normal 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>
|
||||||
|
|
@ -1,16 +1,32 @@
|
||||||
import {
|
import {
|
||||||
|
decodeApiError,
|
||||||
|
decodeAttemptPage,
|
||||||
decodeDeletionOperation,
|
decodeDeletionOperation,
|
||||||
decodeEventFrame,
|
decodeEventFrame,
|
||||||
decodeEventTicket,
|
decodeEventTicket,
|
||||||
decodeJobPage,
|
decodeJobPage,
|
||||||
decodeJobResource,
|
decodeJobResource,
|
||||||
|
decodeResultDescriptor,
|
||||||
|
decodeResultMemberAccess,
|
||||||
|
decodeResultMemberPage,
|
||||||
|
decodeSourceCredential,
|
||||||
|
decodeSourceCredentialPage,
|
||||||
} from './decode'
|
} from './decode'
|
||||||
import type {
|
import type {
|
||||||
|
AttemptPageDto,
|
||||||
DeletionOperationDto,
|
DeletionOperationDto,
|
||||||
EventFrameDto,
|
EventFrameDto,
|
||||||
EventTicketDto,
|
EventTicketDto,
|
||||||
JobPageDto,
|
JobPageDto,
|
||||||
JobResourceDto,
|
JobResourceDto,
|
||||||
|
JobSubmissionDto,
|
||||||
|
ResultDescriptorDto,
|
||||||
|
ResultMemberAccessDto,
|
||||||
|
ResultMemberPageDto,
|
||||||
|
SourceCredentialCreateDto,
|
||||||
|
SourceCredentialDto,
|
||||||
|
SourceCredentialPageDto,
|
||||||
|
SourceCredentialRotateDto,
|
||||||
} from './contracts'
|
} from './contracts'
|
||||||
|
|
||||||
export interface PublicApi {
|
export interface PublicApi {
|
||||||
|
|
@ -21,11 +37,71 @@ export interface PublicApi {
|
||||||
operationId: string,
|
operationId: string,
|
||||||
): Promise<DeletionOperationDto | null>
|
): Promise<DeletionOperationDto | null>
|
||||||
createEventTicket(accessToken: string): Promise<EventTicketDto>
|
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 {
|
export class ApiResponseError extends Error {
|
||||||
constructor(readonly status: number) {
|
constructor(
|
||||||
super(`Remora API returned HTTP ${status}`)
|
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 #baseUrl: URL
|
||||||
readonly #fetch: typeof fetch
|
readonly #fetch: typeof fetch
|
||||||
|
|
||||||
constructor(
|
constructor(baseUrl: string | URL, fetchImplementation?: typeof fetch) {
|
||||||
baseUrl: string | URL,
|
this.#baseUrl = new URL(baseUrl, browserBaseUrl())
|
||||||
fetchImplementation: typeof fetch = fetch,
|
this.#fetch = fetchImplementation ?? globalThis.fetch.bind(globalThis)
|
||||||
) {
|
|
||||||
this.#baseUrl = new URL(baseUrl, 'http://localhost/')
|
|
||||||
this.#fetch = fetchImplementation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listJobs(accessToken: string, cursor?: string): Promise<JobPageDto> {
|
async listJobs(accessToken: string, cursor?: string): Promise<JobPageDto> {
|
||||||
const url = this.#url('jobs')
|
const url = this.#url('jobs')
|
||||||
|
url.searchParams.set('include_deleted', 'true')
|
||||||
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
|
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
|
||||||
return decodeJobPage(await this.#request(url, accessToken))
|
return decodeJobPage(await this.#request(url, accessToken))
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +145,154 @@ export class FetchPublicApi implements PublicApi {
|
||||||
|
|
||||||
async createEventTicket(accessToken: string): Promise<EventTicketDto> {
|
async createEventTicket(accessToken: string): Promise<EventTicketDto> {
|
||||||
return decodeEventTicket(
|
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(
|
async #request(
|
||||||
url: URL,
|
url: URL,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
method = 'GET',
|
options: {
|
||||||
|
method?: 'GET' | 'POST'
|
||||||
|
idempotencyKey?: string
|
||||||
|
body?: unknown
|
||||||
|
} = {},
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const response = await this.#fetch(url, {
|
const headers: Record<string, string> = {
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
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',
|
cache: 'no-store',
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new ApiResponseError(response.status)
|
if (!response.ok) throw await apiResponseError(response)
|
||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +362,7 @@ export class BrowserEventSocket implements EventSocket {
|
||||||
readonly #eventsUrl: URL
|
readonly #eventsUrl: URL
|
||||||
|
|
||||||
constructor(eventsUrl: string | URL) {
|
constructor(eventsUrl: string | URL) {
|
||||||
this.#eventsUrl = new URL(eventsUrl, 'http://localhost/')
|
this.#eventsUrl = new URL(eventsUrl, browserBaseUrl())
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(
|
connect(
|
||||||
|
|
@ -175,6 +407,29 @@ function withTrailingSlash(url: URL): URL {
|
||||||
return copy
|
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 {
|
function asError(error: unknown): Error {
|
||||||
return error instanceof Error ? error : new Error('Invalid live event frame')
|
return error instanceof Error ? error : new Error('Invalid live event frame')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ describe('adapted public API client', () => {
|
||||||
items: [job(1)],
|
items: [job(1)],
|
||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
event_cursor: 'cursor-snapshot-0001',
|
event_cursor: 'cursor-snapshot-0001',
|
||||||
|
management_capabilities: { revoke_execution_grants: false },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const api = new FetchPublicApi('https://remora.invalid/v1/', request)
|
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]
|
const [url, init] = request.mock.calls[0]
|
||||||
if (!(url instanceof URL)) throw new Error('expected URL request')
|
if (!(url instanceof URL)) throw new Error('expected URL request')
|
||||||
expect(url.href).toBe(
|
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({
|
expect(init).toMatchObject({
|
||||||
cache: 'no-store',
|
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 () => {
|
test('maps an indistinguishable missing resource without swallowing faults', async () => {
|
||||||
const missing = new FetchPublicApi(
|
const missing = new FetchPublicApi(
|
||||||
'https://remora.invalid/v1/',
|
'https://remora.invalid/v1/',
|
||||||
|
|
@ -68,6 +87,89 @@ describe('adapted public API client', () => {
|
||||||
})
|
})
|
||||||
expect(request.mock.calls[0]?.[1]?.method).toBe('POST')
|
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', () => {
|
describe('snapshot wire decoding', () => {
|
||||||
|
|
@ -77,6 +179,7 @@ describe('snapshot wire decoding', () => {
|
||||||
items: [{ ...job(1), secret: 'must not pass through' }],
|
items: [{ ...job(1), secret: 'must not pass through' }],
|
||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
event_cursor: 'cursor-snapshot-0001',
|
event_cursor: 'cursor-snapshot-0001',
|
||||||
|
management_capabilities: { revoke_execution_grants: false },
|
||||||
}),
|
}),
|
||||||
).toThrow('unknown DTO field')
|
).toThrow('unknown DTO field')
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,11 @@ export interface ResultMemberPageDto {
|
||||||
next_cursor: string | null
|
next_cursor: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResultMemberAccessDto {
|
||||||
|
url: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobDto {
|
export interface JobDto {
|
||||||
resource_kind: 'job'
|
resource_kind: 'job'
|
||||||
id: OpaqueIdDto
|
id: OpaqueIdDto
|
||||||
|
|
@ -360,10 +365,15 @@ export interface JobTombstoneDto {
|
||||||
|
|
||||||
export type JobResourceDto = JobDto | JobTombstoneDto
|
export type JobResourceDto = JobDto | JobTombstoneDto
|
||||||
|
|
||||||
|
export interface ManagementCapabilitiesDto {
|
||||||
|
revoke_execution_grants: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobPageDto {
|
export interface JobPageDto {
|
||||||
items: JobResourceDto[]
|
items: JobResourceDto[]
|
||||||
next_cursor: string | null
|
next_cursor: string | null
|
||||||
event_cursor: string
|
event_cursor: string
|
||||||
|
management_capabilities: ManagementCapabilitiesDto
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttemptRecordDto {
|
export interface AttemptRecordDto {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { runtimeFailureClass, type JobPhase } from '../domain/vocabulary'
|
||||||
import { opaqueId } from '../domain/validation'
|
import { opaqueId } from '../domain/validation'
|
||||||
import type {
|
import type {
|
||||||
AttemptOutcomeRequestDto,
|
AttemptOutcomeRequestDto,
|
||||||
|
AttemptPageDto,
|
||||||
|
AttemptRecordDto,
|
||||||
|
ApiErrorDto,
|
||||||
CheckpointProjectionDto,
|
CheckpointProjectionDto,
|
||||||
DeletionOperationDto,
|
DeletionOperationDto,
|
||||||
EventFrameDto,
|
EventFrameDto,
|
||||||
|
|
@ -15,6 +18,12 @@ import type {
|
||||||
ObjectIdentityDto,
|
ObjectIdentityDto,
|
||||||
ProgressDto,
|
ProgressDto,
|
||||||
ResultDescriptorDto,
|
ResultDescriptorDto,
|
||||||
|
ResultMemberDto,
|
||||||
|
ResultMemberAccessDto,
|
||||||
|
ResultMemberPageDto,
|
||||||
|
SourceCredentialDto,
|
||||||
|
SourceCredentialPageDto,
|
||||||
|
SourceCredentialVersionDto,
|
||||||
SourceProjectionDto,
|
SourceProjectionDto,
|
||||||
SourceSubmissionDto,
|
SourceSubmissionDto,
|
||||||
TargetDto,
|
TargetDto,
|
||||||
|
|
@ -137,13 +146,25 @@ export function decodeEventTicket(value: unknown): EventTicketDto {
|
||||||
|
|
||||||
export function decodeJobPage(value: unknown): JobPageDto {
|
export function decodeJobPage(value: unknown): JobPageDto {
|
||||||
const object = record(value)
|
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')
|
if (!Array.isArray(object.items)) throw new Error('expected job array')
|
||||||
|
const capabilities = record(object.management_capabilities)
|
||||||
|
exactKeys(capabilities, ['revoke_execution_grants'])
|
||||||
return {
|
return {
|
||||||
items: object.items.map(decodeJobResource),
|
items: object.items.map(decodeJobResource),
|
||||||
next_cursor:
|
next_cursor:
|
||||||
object.next_cursor === null ? null : cursor(object.next_cursor),
|
object.next_cursor === null ? null : cursor(object.next_cursor),
|
||||||
event_cursor: cursor(object.event_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 {
|
export function decodeAttemptOutcome(value: unknown): AttemptOutcomeRequestDto {
|
||||||
const object = record(value)
|
const object = record(value)
|
||||||
exactKeys(object, ['operation_id', 'fence', 'worker_incarnation', 'outcome'])
|
exactKeys(object, ['operation_id', 'fence', 'worker_incarnation', 'outcome'])
|
||||||
|
|
@ -427,6 +540,69 @@ function decodeDeletionProblem(value: unknown): {
|
||||||
return { class: object.class, message: string(object.message) }
|
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 {
|
function decodeSourceSubmission(value: unknown): SourceSubmissionDto {
|
||||||
const object = record(value)
|
const object = record(value)
|
||||||
const credential =
|
const credential =
|
||||||
|
|
@ -626,6 +802,11 @@ function string(value: unknown): string {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function booleanValue(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'boolean') throw new Error('expected boolean')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
function cursor(value: unknown): string {
|
function cursor(value: unknown): string {
|
||||||
const decoded = string(value)
|
const decoded = string(value)
|
||||||
const length = Array.from(decoded).length
|
const length = Array.from(decoded).length
|
||||||
|
|
@ -754,6 +935,72 @@ function deletionState(
|
||||||
return value
|
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(
|
function resyncReason(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
):
|
):
|
||||||
|
|
|
||||||
121
web/src/api/testing.ts
Normal file
121
web/src/api/testing.ts
Normal 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'))
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,21 @@ import type {
|
||||||
EventSocketListener,
|
EventSocketListener,
|
||||||
PublicApi,
|
PublicApi,
|
||||||
} from '../api/client'
|
} 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 {
|
import type {
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
Session,
|
Session,
|
||||||
|
|
@ -39,9 +53,11 @@ export class WebClientRuntime {
|
||||||
#sessionKey: string | null = null
|
#sessionKey: string | null = null
|
||||||
#accessToken: string | null = null
|
#accessToken: string | null = null
|
||||||
#generation = 0
|
#generation = 0
|
||||||
|
#snapshotEpoch = 0
|
||||||
#connectionEpoch = 0
|
#connectionEpoch = 0
|
||||||
#connection: EventConnection | null = null
|
#connection: EventConnection | null = null
|
||||||
#cancelRetry: (() => void) | null = null
|
#cancelRetry: (() => void) | null = null
|
||||||
|
#cancelHydrationRetry: (() => void) | null = null
|
||||||
#unsubscribeSession: (() => void) | null = null
|
#unsubscribeSession: (() => void) | null = null
|
||||||
#frames = Promise.resolve()
|
#frames = Promise.resolve()
|
||||||
|
|
||||||
|
|
@ -74,6 +90,7 @@ export class WebClientRuntime {
|
||||||
this.#frames = Promise.resolve()
|
this.#frames = Promise.resolve()
|
||||||
this.#closeConnection()
|
this.#closeConnection()
|
||||||
this.#cancelScheduledRetry()
|
this.#cancelScheduledRetry()
|
||||||
|
this.#cancelScheduledHydration()
|
||||||
this.#unsubscribeSession?.()
|
this.#unsubscribeSession?.()
|
||||||
this.#unsubscribeSession = null
|
this.#unsubscribeSession = null
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +103,117 @@ export class WebClientRuntime {
|
||||||
return this.#session.signOut()
|
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 {
|
subscribe(listener: (state: SynchronizationSnapshot) => void): () => void {
|
||||||
this.#listeners.add(listener)
|
this.#listeners.add(listener)
|
||||||
listener(this.snapshot)
|
listener(this.snapshot)
|
||||||
|
|
@ -105,6 +233,7 @@ export class WebClientRuntime {
|
||||||
this.#frames = Promise.resolve()
|
this.#frames = Promise.resolve()
|
||||||
this.#closeConnection()
|
this.#closeConnection()
|
||||||
this.#cancelScheduledRetry()
|
this.#cancelScheduledRetry()
|
||||||
|
this.#cancelScheduledHydration()
|
||||||
this.#state.clear(state.kind === 'loading' ? 'loading' : 'signed_out')
|
this.#state.clear(state.kind === 'loading' ? 'loading' : 'signed_out')
|
||||||
if (state.kind === 'error') this.#state.setStatus('error', state.message)
|
if (state.kind === 'error') this.#state.setStatus('error', state.message)
|
||||||
this.#publish()
|
this.#publish()
|
||||||
|
|
@ -119,6 +248,7 @@ export class WebClientRuntime {
|
||||||
this.#frames = Promise.resolve()
|
this.#frames = Promise.resolve()
|
||||||
this.#closeConnection()
|
this.#closeConnection()
|
||||||
this.#cancelScheduledRetry()
|
this.#cancelScheduledRetry()
|
||||||
|
this.#cancelScheduledHydration()
|
||||||
this.#state.clear('loading')
|
this.#state.clear('loading')
|
||||||
this.#publish()
|
this.#publish()
|
||||||
void this.#loadSnapshot(this.#generation, 'loading')
|
void this.#loadSnapshot(this.#generation, 'loading')
|
||||||
|
|
@ -130,10 +260,12 @@ export class WebClientRuntime {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const token = this.#accessToken
|
const token = this.#accessToken
|
||||||
if (token === null || generation !== this.#generation) return
|
if (token === null || generation !== this.#generation) return
|
||||||
|
const snapshotEpoch = ++this.#snapshotEpoch
|
||||||
this.#connectionEpoch += 1
|
this.#connectionEpoch += 1
|
||||||
this.#frames = Promise.resolve()
|
this.#frames = Promise.resolve()
|
||||||
this.#closeConnection()
|
this.#closeConnection()
|
||||||
this.#cancelScheduledRetry()
|
this.#cancelScheduledRetry()
|
||||||
|
this.#cancelScheduledHydration()
|
||||||
this.#state.setStatus(status)
|
this.#state.setStatus(status)
|
||||||
this.#publish()
|
this.#publish()
|
||||||
try {
|
try {
|
||||||
|
|
@ -141,29 +273,54 @@ export class WebClientRuntime {
|
||||||
const pageCursors = new Set<string>()
|
const pageCursors = new Set<string>()
|
||||||
let pageCursor: string | undefined
|
let pageCursor: string | undefined
|
||||||
let eventCursor: string | null = null
|
let eventCursor: string | null = null
|
||||||
|
let managementCapabilities: ManagementCapabilitiesDto | null = null
|
||||||
do {
|
do {
|
||||||
const page = await this.#api.listJobs(token, pageCursor)
|
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
|
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)
|
jobs.push(...page.items)
|
||||||
pageCursor = page.next_cursor ?? undefined
|
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')
|
throw new Error('Job pagination cursor repeated')
|
||||||
|
pageCursors.add(pageCursor)
|
||||||
|
}
|
||||||
} while (pageCursor !== undefined)
|
} while (pageCursor !== undefined)
|
||||||
if (eventCursor === null)
|
if (eventCursor === null)
|
||||||
throw new Error('Job snapshot omitted its cursor')
|
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()
|
this.#publish()
|
||||||
await this.#connect(generation)
|
await this.#connect(generation, snapshotEpoch)
|
||||||
|
if (snapshotEpoch !== this.#snapshotEpoch) return
|
||||||
|
void this.#hydrateDeletions(generation, snapshotEpoch, jobs)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (generation !== this.#generation) return
|
if (
|
||||||
|
generation !== this.#generation ||
|
||||||
|
snapshotEpoch !== this.#snapshotEpoch
|
||||||
|
)
|
||||||
|
return
|
||||||
this.#state.setStatus('error', message(error))
|
this.#state.setStatus('error', message(error))
|
||||||
this.#publish()
|
this.#publish()
|
||||||
this.#scheduleSnapshotRetry(generation)
|
this.#scheduleSnapshotRetry(generation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #connect(generation: number): Promise<void> {
|
async #connect(
|
||||||
|
generation: number,
|
||||||
|
snapshotEpoch = this.#snapshotEpoch,
|
||||||
|
): Promise<void> {
|
||||||
const token = this.#accessToken
|
const token = this.#accessToken
|
||||||
const cursor = this.#state.snapshot().cursor
|
const cursor = this.#state.snapshot().cursor
|
||||||
if (token === null || cursor === null || generation !== this.#generation)
|
if (token === null || cursor === null || generation !== this.#generation)
|
||||||
|
|
@ -172,7 +329,11 @@ export class WebClientRuntime {
|
||||||
this.#publish()
|
this.#publish()
|
||||||
try {
|
try {
|
||||||
const ticket = await this.#api.createEventTicket(token)
|
const ticket = await this.#api.createEventTicket(token)
|
||||||
if (generation !== this.#generation) return
|
if (
|
||||||
|
generation !== this.#generation ||
|
||||||
|
snapshotEpoch !== this.#snapshotEpoch
|
||||||
|
)
|
||||||
|
return
|
||||||
const epoch = ++this.#connectionEpoch
|
const epoch = ++this.#connectionEpoch
|
||||||
this.#state.beginConnection()
|
this.#state.beginConnection()
|
||||||
const listener = this.#listener(generation, epoch)
|
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 {
|
#listener(generation: number, epoch: number): EventSocketListener {
|
||||||
const current = (): boolean =>
|
const current = (): boolean =>
|
||||||
generation === this.#generation && epoch === this.#connectionEpoch
|
generation === this.#generation && epoch === this.#connectionEpoch
|
||||||
|
|
@ -294,6 +509,23 @@ export class WebClientRuntime {
|
||||||
}, 1000)
|
}, 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 {
|
#closeConnection(): void {
|
||||||
const connection = this.#connection
|
const connection = this.#connection
|
||||||
this.#connection = null
|
this.#connection = null
|
||||||
|
|
@ -305,6 +537,39 @@ export class WebClientRuntime {
|
||||||
this.#cancelRetry = null
|
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 {
|
#publish(): void {
|
||||||
const snapshot = this.snapshot
|
const snapshot = this.snapshot
|
||||||
for (const listener of this.#listeners) listener(snapshot)
|
for (const listener of this.#listeners) listener(snapshot)
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import type {
|
||||||
EventConnection,
|
EventConnection,
|
||||||
EventSocket,
|
EventSocket,
|
||||||
EventSocketListener,
|
EventSocketListener,
|
||||||
PublicApi,
|
|
||||||
} from '../api/client'
|
} from '../api/client'
|
||||||
|
import { SynchronizationApiFake } from '../api/testing'
|
||||||
import type {
|
import type {
|
||||||
DeletionOperationDto,
|
DeletionOperationDto,
|
||||||
EventTicketDto,
|
EventTicketDto,
|
||||||
JobPageDto,
|
JobPageDto,
|
||||||
JobResourceDto,
|
JobResourceDto,
|
||||||
|
JobSubmissionDto,
|
||||||
} from '../api/contracts'
|
} from '../api/contracts'
|
||||||
import type { Session, SessionState } from '../auth/session'
|
import type { Session, SessionState } from '../auth/session'
|
||||||
import { WebClientRuntime, type Scheduler } from './runtime'
|
import { WebClientRuntime, type Scheduler } from './runtime'
|
||||||
|
|
@ -188,6 +189,163 @@ describe('web client runtime', () => {
|
||||||
expect(fixture.runtime.snapshot.status).toBe('live')
|
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(): {
|
function runtimeFixture(): {
|
||||||
|
|
@ -263,8 +421,9 @@ class FakeSession implements Session {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeApi implements PublicApi {
|
class FakeApi extends SynchronizationApiFake {
|
||||||
snapshot = job(1)
|
snapshot: JobResourceDto = job(1)
|
||||||
|
listItems: JobResourceDto[] | null = null
|
||||||
readonly deletion: DeletionOperationDto = {
|
readonly deletion: DeletionOperationDto = {
|
||||||
id: 'deletion_001',
|
id: 'deletion_001',
|
||||||
job_id: 'job_00000001',
|
job_id: 'job_00000001',
|
||||||
|
|
@ -274,18 +433,27 @@ class FakeApi implements PublicApi {
|
||||||
updated_at: observedAt,
|
updated_at: observedAt,
|
||||||
}
|
}
|
||||||
nextJob: Promise<JobResourceDto | null> | null = null
|
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
|
listRequests = 0
|
||||||
ticketRequests = 0
|
ticketRequests = 0
|
||||||
|
deletionRequests = 0
|
||||||
|
mutationRequests = 0
|
||||||
readonly tokens: string[] = []
|
readonly tokens: string[] = []
|
||||||
readonly jobRequests: 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.listRequests += 1
|
||||||
this.tokens.push(accessToken)
|
this.tokens.push(accessToken)
|
||||||
|
this.pageCursors.push(cursor)
|
||||||
return {
|
return {
|
||||||
items: [this.snapshot],
|
items: this.listItems ?? [this.snapshot],
|
||||||
next_cursor: null,
|
next_cursor: this.repeatPageCursor ? 'cursor-repeat' : null,
|
||||||
event_cursor: 'cursor-snapshot-0001',
|
event_cursor: 'cursor-snapshot-0001',
|
||||||
|
management_capabilities: { revoke_execution_grants: false },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,6 +475,12 @@ class FakeApi implements PublicApi {
|
||||||
_accessToken: string,
|
_accessToken: string,
|
||||||
_operationId: string,
|
_operationId: string,
|
||||||
): Promise<DeletionOperationDto | null> {
|
): Promise<DeletionOperationDto | null> {
|
||||||
|
this.deletionRequests += 1
|
||||||
|
if (this.nextDeletion !== null) {
|
||||||
|
const response = this.nextDeletion
|
||||||
|
this.nextDeletion = null
|
||||||
|
return response
|
||||||
|
}
|
||||||
return this.deletion
|
return this.deletion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,6 +491,30 @@ class FakeApi implements PublicApi {
|
||||||
expires_at: '2026-07-17T00:01:00Z',
|
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 {
|
interface FakeConnection extends EventConnection {
|
||||||
|
|
@ -361,3 +559,9 @@ class ImmediateScheduler implements Scheduler {
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DormantScheduler implements Scheduler {
|
||||||
|
schedule(): () => void {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
EventFrameDto,
|
EventFrameDto,
|
||||||
EventProgressDto,
|
EventProgressDto,
|
||||||
JobResourceDto,
|
JobResourceDto,
|
||||||
|
ManagementCapabilitiesDto,
|
||||||
} from '../api/contracts'
|
} from '../api/contracts'
|
||||||
|
|
||||||
export type SynchronizationStatus =
|
export type SynchronizationStatus =
|
||||||
|
|
@ -19,6 +20,9 @@ export interface SynchronizationSnapshot {
|
||||||
deletions: DeletionOperationDto[]
|
deletions: DeletionOperationDto[]
|
||||||
cursor: string | null
|
cursor: string | null
|
||||||
message: string | null
|
message: string | null
|
||||||
|
managementCapabilities: {
|
||||||
|
revokeExecutionGrants: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SynchronizationEffect =
|
export type SynchronizationEffect =
|
||||||
|
|
@ -35,6 +39,9 @@ export class SynchronizationState {
|
||||||
readonly #progress = new Map<string, EventProgressDto>()
|
readonly #progress = new Map<string, EventProgressDto>()
|
||||||
#cursor: string | null = null
|
#cursor: string | null = null
|
||||||
#nextSequence = 1
|
#nextSequence = 1
|
||||||
|
#managementCapabilities: ManagementCapabilitiesDto = {
|
||||||
|
revoke_execution_grants: false,
|
||||||
|
}
|
||||||
readonly #eventIds = new BoundedSet(2048)
|
readonly #eventIds = new BoundedSet(2048)
|
||||||
|
|
||||||
snapshot(): SynchronizationSnapshot {
|
snapshot(): SynchronizationSnapshot {
|
||||||
|
|
@ -49,6 +56,10 @@ export class SynchronizationState {
|
||||||
),
|
),
|
||||||
cursor: this.#cursor,
|
cursor: this.#cursor,
|
||||||
message: this.#message,
|
message: this.#message,
|
||||||
|
managementCapabilities: {
|
||||||
|
revokeExecutionGrants:
|
||||||
|
this.#managementCapabilities.revoke_execution_grants,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,15 +78,21 @@ export class SynchronizationState {
|
||||||
this.#eventIds.clear()
|
this.#eventIds.clear()
|
||||||
this.#cursor = null
|
this.#cursor = null
|
||||||
this.#nextSequence = 1
|
this.#nextSequence = 1
|
||||||
|
this.#managementCapabilities = { revoke_execution_grants: false }
|
||||||
this.setStatus(status)
|
this.setStatus(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
installSnapshot(resources: JobResourceDto[], cursor: string): void {
|
installSnapshot(
|
||||||
|
resources: JobResourceDto[],
|
||||||
|
cursor: string,
|
||||||
|
managementCapabilities: ManagementCapabilitiesDto,
|
||||||
|
): void {
|
||||||
this.#jobs.clear()
|
this.#jobs.clear()
|
||||||
this.#deletions.clear()
|
this.#deletions.clear()
|
||||||
this.#progress.clear()
|
this.#progress.clear()
|
||||||
for (const resource of resources) this.#jobs.set(resource.id, resource)
|
for (const resource of resources) this.#jobs.set(resource.id, resource)
|
||||||
this.#cursor = cursor
|
this.#cursor = cursor
|
||||||
|
this.#managementCapabilities = managementCapabilities
|
||||||
this.#eventIds.clear()
|
this.#eventIds.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,18 +151,35 @@ export class SynchronizationState {
|
||||||
resource.revision < minimumRevision
|
resource.revision < minimumRevision
|
||||||
)
|
)
|
||||||
return false
|
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)
|
this.#progress.delete(jobId)
|
||||||
if (resource === null) this.#jobs.delete(jobId)
|
if (resource === null) this.#jobs.delete(jobId)
|
||||||
else this.#jobs.set(jobId, resource)
|
else this.#jobs.set(jobId, resource)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeFullJob(jobId: string): void {
|
||||||
|
this.#progress.delete(jobId)
|
||||||
|
if (this.#jobs.get(jobId)?.resource_kind === 'job') this.#jobs.delete(jobId)
|
||||||
|
}
|
||||||
|
|
||||||
replaceDeletion(
|
replaceDeletion(
|
||||||
operationId: string,
|
operationId: string,
|
||||||
operation: DeletionOperationDto | null,
|
operation: DeletionOperationDto | null,
|
||||||
): void {
|
): void {
|
||||||
if (operation === null) this.#deletions.delete(operationId)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ import { describe, expect, test } from 'vitest'
|
||||||
import { SynchronizationState } from './state'
|
import { SynchronizationState } from './state'
|
||||||
import { heartbeat, job, jobChanged } from './test-fixtures'
|
import { heartbeat, job, jobChanged } from './test-fixtures'
|
||||||
|
|
||||||
|
const capabilities = { revoke_execution_grants: false }
|
||||||
|
|
||||||
describe('synchronization state', () => {
|
describe('synchronization state', () => {
|
||||||
test('deduplicates replay while advancing the connection cursor', () => {
|
test('deduplicates replay while advancing the connection cursor', () => {
|
||||||
const state = new SynchronizationState()
|
const state = new SynchronizationState()
|
||||||
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
|
state.installSnapshot([job(1)], 'cursor-snapshot-0001', capabilities)
|
||||||
state.beginConnection()
|
state.beginConnection()
|
||||||
|
|
||||||
expect(state.apply(jobChanged(1, 'event_000001', 2, 20))).toEqual({
|
expect(state.apply(jobChanged(1, 'event_000001', 2, 20))).toEqual({
|
||||||
|
|
@ -31,7 +33,7 @@ describe('synchronization state', () => {
|
||||||
|
|
||||||
test('requires increasing per-connection sequence numbers', () => {
|
test('requires increasing per-connection sequence numbers', () => {
|
||||||
const state = new SynchronizationState()
|
const state = new SynchronizationState()
|
||||||
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
|
state.installSnapshot([job(1)], 'cursor-snapshot-0001', capabilities)
|
||||||
state.beginConnection()
|
state.beginConnection()
|
||||||
|
|
||||||
expect(state.apply(heartbeat(2, 'event_000002'))).toEqual({
|
expect(state.apply(heartbeat(2, 'event_000002'))).toEqual({
|
||||||
|
|
@ -42,7 +44,7 @@ describe('synchronization state', () => {
|
||||||
|
|
||||||
test('retains replay deduplication when a connection restarts', () => {
|
test('retains replay deduplication when a connection restarts', () => {
|
||||||
const state = new SynchronizationState()
|
const state = new SynchronizationState()
|
||||||
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
|
state.installSnapshot([job(1)], 'cursor-snapshot-0001', capabilities)
|
||||||
state.beginConnection()
|
state.beginConnection()
|
||||||
const first = heartbeat(1, 'event_000001')
|
const first = heartbeat(1, 'event_000001')
|
||||||
state.apply(first)
|
state.apply(first)
|
||||||
|
|
@ -59,7 +61,7 @@ describe('synchronization state', () => {
|
||||||
|
|
||||||
test('rejects an HTTP refetch older than its invalidation', () => {
|
test('rejects an HTTP refetch older than its invalidation', () => {
|
||||||
const state = new SynchronizationState()
|
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.replaceJob('job_00000001', 3, job(2))).toBe(false)
|
||||||
expect(state.snapshot().jobs[0]?.resource).toEqual(job(1))
|
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', () => {
|
test('does not expose a progress hint with mismatched authority', () => {
|
||||||
const state = new SynchronizationState()
|
const state = new SynchronizationState()
|
||||||
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
|
state.installSnapshot([job(1)], 'cursor-snapshot-0001', capabilities)
|
||||||
state.beginConnection()
|
state.beginConnection()
|
||||||
const frame = jobChanged(1, 'event_000001', 2, 20)
|
const frame = jobChanged(1, 'event_000001', 2, 20)
|
||||||
if (frame.kind !== 'job_changed') throw new Error('invalid fixture')
|
if (frame.kind !== 'job_changed') throw new Error('invalid fixture')
|
||||||
|
|
@ -79,4 +81,50 @@ describe('synchronization state', () => {
|
||||||
|
|
||||||
expect(state.snapshot().jobs[0]?.provisionalProgress).toBeNull()
|
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
226
web/src/workflows/forms.ts
Normal 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)}`)
|
||||||
|
}
|
||||||
30
web/src/workflows/intents.ts
Normal file
30
web/src/workflows/intents.ts
Normal 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
Loading…
Reference in a new issue