Implement replay-safe browser synchronization

This commit is contained in:
ms2k 2026-07-18 00:16:21 +09:00
commit 459ffa04c6
26 changed files with 2734 additions and 25 deletions

View file

@ -0,0 +1,10 @@
CREATE TABLE live_event_tickets (
ticket_digest BYTEA PRIMARY KEY CHECK (octet_length(ticket_digest) = 32),
principal JSONB NOT NULL,
origin TEXT NOT NULL CHECK (origin <> ''),
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL CHECK (expires_at > created_at)
);
CREATE INDEX live_event_tickets_expiry_idx
ON live_event_tickets (expires_at);

View file

@ -1,4 +1,4 @@
use std::time::Duration; use std::{collections::HashSet, time::Duration};
use actix_web::{ use actix_web::{
HttpRequest, HttpResponse, ResponseError, HttpRequest, HttpResponse, ResponseError,
@ -51,6 +51,8 @@ pub struct PublicApiConfig {
pub event_retention: Duration, pub event_retention: Duration,
pub event_poll_interval: Duration, pub event_poll_interval: Duration,
pub event_heartbeat_interval: Duration, pub event_heartbeat_interval: Duration,
pub event_ticket_ttl: Duration,
pub event_allowed_origins: Vec<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -68,6 +70,8 @@ pub struct PublicApiState {
event_retention: Duration, event_retention: Duration,
event_poll_interval: Duration, event_poll_interval: Duration,
event_heartbeat_interval: Duration, event_heartbeat_interval: Duration,
event_ticket_ttl: Duration,
event_allowed_origins: HashSet<String>,
} }
pub struct PublicApiServices { pub struct PublicApiServices {
@ -93,9 +97,28 @@ impl PublicApiState {
if config.event_retention.is_zero() if config.event_retention.is_zero()
|| config.event_poll_interval.is_zero() || config.event_poll_interval.is_zero()
|| config.event_heartbeat_interval.is_zero() || config.event_heartbeat_interval.is_zero()
|| config.event_ticket_ttl.is_zero()
{ {
return Err(DurableError::Config( return Err(DurableError::Config(
"event retention and intervals must be positive".into(), "event retention, intervals, and ticket expiry must be positive".into(),
));
}
if config.event_ticket_ttl > Duration::from_secs(60) {
return Err(DurableError::Config(
"event ticket expiry must not exceed 60 seconds".into(),
));
}
let event_allowed_origins = config
.event_allowed_origins
.into_iter()
.collect::<HashSet<_>>();
if event_allowed_origins.is_empty()
|| event_allowed_origins
.iter()
.any(|origin| origin.is_empty() || origin.trim() != origin)
{
return Err(DurableError::Config(
"at least one exact event origin is required".into(),
)); ));
} }
Ok(Self { Ok(Self {
@ -112,6 +135,8 @@ impl PublicApiState {
event_retention: config.event_retention, event_retention: config.event_retention,
event_poll_interval: config.event_poll_interval, event_poll_interval: config.event_poll_interval,
event_heartbeat_interval: config.event_heartbeat_interval, event_heartbeat_interval: config.event_heartbeat_interval,
event_ticket_ttl: config.event_ticket_ttl,
event_allowed_origins,
}) })
} }
@ -193,6 +218,7 @@ pub fn configure_public_api(config: &mut web::ServiceConfig) {
"/deletions/{operation_id}", "/deletions/{operation_id}",
web::get().to(get_deletion_operation), web::get().to(get_deletion_operation),
) )
.route("/event-tickets", web::post().to(events::issue_ticket))
.route("/events", web::get().to(events::connect)), .route("/events", web::get().to(events::connect)),
); );
} }

View file

@ -1,16 +1,19 @@
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpRequest, HttpResponse, http::header, web};
use futures_util::StreamExt as _; use futures_util::StreamExt as _;
use remora_contracts::{ use remora_contracts::{
CursorDto, DateTimeDto, DeletionChangedDto, DeletionStateDto, EventAttemptDto, EventFrameDto, CursorDto, DateTimeDto, DeletionChangedDto, DeletionStateDto, EventAttemptDto, EventFrameDto,
EventProgressDto, HeartbeatDto, JobChangedDto, NullableDto, PositiveIntegerDto, EventProgressDto, EventTicketDto, HeartbeatDto, JobChangedDto, NullableDto, PositiveIntegerDto,
ResyncReasonDto, ResyncRequiredDto, SafeIntegerDto, SchemaVersionDto, ResyncReasonDto, ResyncRequiredDto, SafeIntegerDto, SchemaVersionDto,
}; };
use remora_domain::{JobLifecycle, JobPhase, OpaqueId}; use remora_domain::{
BucketName, JobLifecycle, JobPhase, OpaqueId, StorageScope, TargetPrefix, TargetStoreId,
};
use remora_durable::{DurableError, KeysetCursor}; use remora_durable::{DurableError, KeysetCursor};
use remora_identity::{ManagementOperation, Principal}; use remora_identity::{ManagementOperation, Principal};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use sqlx::{FromRow, PgPool}; use sha2::{Digest as _, Sha256};
use sqlx::{FromRow, PgPool, types::Json};
use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{ use super::{
@ -24,6 +27,60 @@ const EVENT_BATCH: i64 = 100;
#[derive(Deserialize)] #[derive(Deserialize)]
pub(super) struct EventQuery { pub(super) struct EventQuery {
cursor: Option<String>, cursor: Option<String>,
ticket: Option<String>,
}
#[derive(Deserialize, Serialize)]
struct TicketPrincipal {
id: String,
issuer: String,
subject: String,
administrator: bool,
scopes: Vec<TicketScope>,
}
#[derive(Deserialize, Serialize)]
struct TicketScope {
store: String,
bucket: String,
prefix: String,
}
pub(super) async fn issue_ticket(
state: web::Data<PublicApiState>,
request: HttpRequest,
) -> Result<HttpResponse, PublicApiError> {
let principal = state.principal(&request).await?;
let origin = allowed_origin(&state, &request)?;
let ticket = random_ticket();
let digest = ticket_digest(ticket.as_str());
let mut tx = state.pool.begin().await?;
sqlx::query("DELETE FROM live_event_tickets WHERE expires_at <= now()")
.execute(tx.as_mut())
.await?;
let expires_at: OffsetDateTime = sqlx::query_scalar(
"INSERT INTO live_event_tickets (
ticket_digest, principal, origin, created_at, expires_at
) VALUES (
$1, $2, $3, now(),
now() + $4 * interval '1 second'
)
RETURNING expires_at",
)
.bind(digest.as_slice())
.bind(Json(ticket_principal_projection(&principal)))
.bind(&origin)
.bind(state.event_ticket_ttl.as_secs_f64())
.fetch_one(tx.as_mut())
.await?;
tx.commit().await?;
Ok(HttpResponse::Created()
.insert_header((header::CACHE_CONTROL, "no-store"))
.insert_header((header::PRAGMA, "no-cache"))
.json(EventTicketDto {
ticket,
expires_at: event_time(expires_at)?,
}))
} }
pub(super) async fn connect( pub(super) async fn connect(
@ -32,7 +89,13 @@ pub(super) async fn connect(
body: web::Payload, body: web::Payload,
query: web::Query<EventQuery>, query: web::Query<EventQuery>,
) -> Result<HttpResponse, PublicApiError> { ) -> Result<HttpResponse, PublicApiError> {
let principal = state.principal(&request).await?; let bearer = request.headers().contains_key(header::AUTHORIZATION);
let principal = match (bearer, query.ticket.as_deref()) {
(true, None) => state.principal(&request).await?,
(false, Some(ticket)) => redeem_ticket(&state, &request, ticket).await?,
(false, None) => return Err(PublicApiError::unauthenticated()),
(true, Some(_)) => return Err(PublicApiError::invalid_request()),
};
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let retention = let retention =
time::Duration::try_from(state.event_retention).map_err(|_| PublicApiError::internal())?; time::Duration::try_from(state.event_retention).map_err(|_| PublicApiError::internal())?;
@ -54,6 +117,94 @@ pub(super) async fn connect(
Ok(response) Ok(response)
} }
fn allowed_origin(state: &PublicApiState, request: &HttpRequest) -> Result<String, PublicApiError> {
let mut origins = request.headers().get_all(header::ORIGIN);
let origin = origins
.next()
.and_then(|value| value.to_str().ok())
.ok_or_else(PublicApiError::forbidden)?;
if origins.next().is_some() || !state.event_allowed_origins.contains(origin) {
return Err(PublicApiError::forbidden());
}
Ok(origin.to_owned())
}
async fn redeem_ticket(
state: &PublicApiState,
request: &HttpRequest,
ticket: &str,
) -> Result<Principal, PublicApiError> {
let origin = allowed_origin(state, request)?;
let digest = ticket_digest(ticket);
let projection: Option<Json<TicketPrincipal>> = sqlx::query_scalar(
"DELETE FROM live_event_tickets
WHERE ticket_digest = $1 AND origin = $2 AND expires_at > now()
RETURNING principal",
)
.bind(digest.as_slice())
.bind(origin)
.fetch_optional(&state.pool)
.await?;
projection
.ok_or_else(PublicApiError::unauthenticated)
.and_then(|projection| ticket_principal(projection.0))
}
fn ticket_principal_projection(principal: &Principal) -> TicketPrincipal {
TicketPrincipal {
id: principal.id.as_str().to_owned(),
issuer: principal.issuer.clone(),
subject: principal.subject.clone(),
administrator: principal.administrator,
scopes: principal
.scopes
.iter()
.map(|scope| TicketScope {
store: scope.store.as_str().to_owned(),
bucket: scope.bucket.as_str().to_owned(),
prefix: scope.prefix.canonical(),
})
.collect(),
}
}
fn ticket_principal(projection: TicketPrincipal) -> Result<Principal, PublicApiError> {
let scopes = projection
.scopes
.into_iter()
.map(|scope| {
Ok(StorageScope {
store: TargetStoreId::parse(scope.store).map_err(|_| PublicApiError::internal())?,
bucket: BucketName::parse(scope.bucket).map_err(|_| PublicApiError::internal())?,
prefix: TargetPrefix::parse(&scope.prefix, std::iter::empty())
.map_err(|_| PublicApiError::internal())?,
})
})
.collect::<Result<Vec<_>, PublicApiError>>()?;
Ok(Principal {
id: OpaqueId::parse(projection.id).map_err(|_| PublicApiError::internal())?,
issuer: projection.issuer,
subject: projection.subject,
administrator: projection.administrator,
scopes,
})
}
fn random_ticket() -> OpaqueId {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes).expect("operating system randomness is required");
let mut value = String::from("t");
for byte in bytes {
use std::fmt::Write as _;
write!(value, "{byte:02x}").expect("writing to String cannot fail");
}
OpaqueId::parse(value).expect("random event ticket is valid")
}
fn ticket_digest(ticket: &str) -> [u8; 32] {
Sha256::digest(ticket.as_bytes()).into()
}
pub(super) async fn prune_history( pub(super) async fn prune_history(
pool: &PgPool, pool: &PgPool,
before: OffsetDateTime, before: OffsetDateTime,

View file

@ -29,9 +29,12 @@ use time::OffsetDateTime;
use tokio_tungstenite::{ use tokio_tungstenite::{
MaybeTlsStream, WebSocketStream, connect_async, MaybeTlsStream, WebSocketStream, connect_async,
tungstenite::{ tungstenite::{
Message, Error as WebSocketError, Message,
client::IntoClientRequest, client::IntoClientRequest,
http::{HeaderValue, header::AUTHORIZATION}, http::{
HeaderValue,
header::{AUTHORIZATION, ORIGIN},
},
}, },
}; };
@ -40,6 +43,7 @@ mod support;
use support::{mint_provider_token, oidc_provider, required_env, revoke_remora_grants}; use support::{mint_provider_token, oidc_provider, required_env, revoke_remora_grants};
type Socket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>; type Socket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
const BROWSER_ORIGIN: &str = "https://remora.test";
struct Harness { struct Harness {
pool: PgPool, pool: PgPool,
@ -158,6 +162,8 @@ impl Harness {
event_retention: Duration::from_millis(500), event_retention: Duration::from_millis(500),
event_poll_interval: Duration::from_millis(250), event_poll_interval: Duration::from_millis(250),
event_heartbeat_interval: Duration::from_millis(100), event_heartbeat_interval: Duration::from_millis(100),
event_ticket_ttl: Duration::from_secs(30),
event_allowed_origins: vec!["https://remora.test".into()],
}, },
) )
.unwrap(); .unwrap();
@ -227,7 +233,50 @@ impl Harness {
response.json().await.unwrap() response.json().await.unwrap()
} }
async fn issue_ticket(&self, token: &str) -> String {
let response = self
.client
.post(format!("{}/event-tickets", self.http_base))
.bearer_auth(token)
.header("Origin", BROWSER_ORIGIN)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(response.headers().get("Cache-Control").unwrap(), "no-store");
response.json::<Value>().await.unwrap()["ticket"]
.as_str()
.unwrap()
.to_owned()
}
async fn connect(&self, token: &str, cursor: Option<&str>) -> Socket { async fn connect(&self, token: &str, cursor: Option<&str>) -> Socket {
let ticket = self.issue_ticket(token).await;
self.connect_ticket(&ticket, cursor, BROWSER_ORIGIN)
.await
.unwrap()
}
async fn connect_ticket(
&self,
ticket: &str,
cursor: Option<&str>,
origin: &str,
) -> Result<Socket, WebSocketError> {
let url = cursor.map_or_else(
|| format!("{}?ticket={ticket}", self.ws_base),
|cursor| format!("{}?ticket={ticket}&cursor={cursor}", self.ws_base),
);
let mut request = url.into_client_request().unwrap();
request
.headers_mut()
.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
let (socket, response) = connect_async(request).await?;
assert_eq!(response.status(), 101);
Ok(socket)
}
async fn connect_bearer(&self, token: &str, cursor: Option<&str>) -> Socket {
let url = cursor.map_or_else( let url = cursor.map_or_else(
|| self.ws_base.clone(), || self.ws_base.clone(),
|cursor| format!("{}?cursor={cursor}", self.ws_base), |cursor| format!("{}?cursor={cursor}", self.ws_base),
@ -255,6 +304,60 @@ async fn live_event_replay_authorization_progress_and_resync_converge() {
return; return;
}; };
let ticket = harness.issue_ticket(&harness.token).await;
let forbidden_ticket = harness
.client
.post(format!("{}/event-tickets", harness.http_base))
.bearer_auth(&harness.token)
.header("Origin", "https://attacker.invalid")
.send()
.await
.unwrap();
assert_eq!(forbidden_ticket.status(), StatusCode::FORBIDDEN);
assert!(
harness
.connect_ticket(&ticket, None, "https://attacker.invalid")
.await
.is_err()
);
let valid = harness
.connect_ticket(&ticket, None, BROWSER_ORIGIN)
.await
.unwrap();
drop(valid);
assert!(
harness
.connect_ticket(&ticket, None, BROWSER_ORIGIN)
.await
.is_err()
);
let concurrent_ticket = harness.issue_ticket(&harness.token).await;
let (left, right) = tokio::join!(
harness.connect_ticket(&concurrent_ticket, None, BROWSER_ORIGIN),
harness.connect_ticket(&concurrent_ticket, None, BROWSER_ORIGIN)
);
assert_eq!(usize::from(left.is_ok()) + usize::from(right.is_ok()), 1);
let expired_ticket = harness.issue_ticket(&harness.token).await;
sqlx::query(
"UPDATE live_event_tickets
SET expires_at = created_at + interval '1 microsecond'
WHERE expires_at > now()",
)
.execute(&harness.pool)
.await
.unwrap();
assert!(
harness
.connect_ticket(&expired_ticket, None, BROWSER_ORIGIN)
.await
.is_err()
);
let mut bearer = harness.connect_bearer(&harness.token, None).await;
assert_resync(next_frame(&mut bearer).await, ResyncReasonDto::CursorAbsent);
let mut absent = harness.connect(&harness.token, None).await; let mut absent = harness.connect(&harness.token, None).await;
assert_resync(next_frame(&mut absent).await, ResyncReasonDto::CursorAbsent); assert_resync(next_frame(&mut absent).await, ResyncReasonDto::CursorAbsent);

View file

@ -144,6 +144,8 @@ impl Harness {
event_retention: Duration::from_secs(60), event_retention: Duration::from_secs(60),
event_poll_interval: Duration::from_millis(50), event_poll_interval: Duration::from_millis(50),
event_heartbeat_interval: Duration::from_secs(1), event_heartbeat_interval: Duration::from_secs(1),
event_ticket_ttl: Duration::from_secs(30),
event_allowed_origins: vec!["https://remora.test".into()],
}, },
) )
.unwrap(); .unwrap();

View file

@ -15,6 +15,13 @@ pub enum EventFrameDto {
Heartbeat(HeartbeatDto), Heartbeat(HeartbeatDto),
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct EventTicketDto {
pub ticket: OpaqueId,
pub expires_at: DateTimeDto,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct JobChangedDto { pub struct JobChangedDto {

View file

@ -25,7 +25,7 @@ pub use dto::{
}; };
pub use events::{ pub use events::{
DeletionChangedDto, DeletionStateDto, EventAttemptDto, EventFrameDto, EventProgressDto, DeletionChangedDto, DeletionStateDto, EventAttemptDto, EventFrameDto, EventProgressDto,
HeartbeatDto, JobChangedDto, ResyncReasonDto, ResyncRequiredDto, EventTicketDto, HeartbeatDto, JobChangedDto, ResyncReasonDto, ResyncRequiredDto,
}; };
pub use internal::{ pub use internal::{
ActivationResponseDto, AttemptActivateRequestDto, AttemptCheckpointRequestDto, ActivationResponseDto, AttemptActivateRequestDto, AttemptCheckpointRequestDto,

View file

@ -13,13 +13,17 @@ path or media type.
## Idempotency ## Idempotency
Every public mutation requires `Idempotency-Key`. The identity is caller Every public domain mutation requires `Idempotency-Key`. The identity is caller
subject, operation, and key. The server canonicalizes the semantic request, subject, operation, and key. The server canonicalizes the semantic request,
including the authorization target, with RFC 8785 and stores its SHA-256 including the authorization target, with RFC 8785 and stores its SHA-256
fingerprint and complete response atomically with the mutation. The default fingerprint and complete response atomically with the mutation. The default
retention is 24 hours and is never shorter than the advertised retention is 24 hours and is never shorter than the advertised
`Idempotency-Expires-At`. `Idempotency-Expires-At`.
Event-ticket issuance is an ephemeral authentication exchange rather than a
domain mutation. Each retry independently mints a short-lived credential; it
does not refine a `JobProtocol` effect or enter the public mutation ledger.
The same identity and fingerprint replays the original status, resource, and The same identity and fingerprint replays the original status, resource, and
body after duplicate or ambiguous delivery. A different fingerprint returns body after duplicate or ambiguous delivery. A different fingerprint returns
`idempotency_conflict` and cannot affect either request. Naturally idempotent `idempotency_conflict` and cannot affect either request. Naturally idempotent
@ -118,7 +122,15 @@ An HTTP job or list snapshot carries `event_cursor` at its consistency
watermark. The client connects to `/v1/events` with that cursor; there is no watermark. The client connects to `/v1/events` with that cursor; there is no
subscription frame. Server frames are ordered by increasing `sequence` for a subscription frame. Server frames are ordered by increasing `sequence` for a
connection and carry an opaque next cursor. Delivery is at least once, and connection and carry an opaque next cursor. Delivery is at least once, and
clients deduplicate event IDs. clients deduplicate event IDs. Each connection starts at sequence one and
increments it by one for every frame, including heartbeats and replayed event
IDs; a client resnapshots when it observes a gap.
Browser clients first create a short-lived, single-use event ticket through an
OIDC bearer-authenticated HTTP request. The server stores only its digest and
binds it to the authenticated principal and exact allowed origin. Redemption
atomically consumes it during the WebSocket handshake. Native clients may
authenticate that handshake directly with the OIDC bearer header.
`job_changed` is an invalidation with an attempt-aware display hint, not a `job_changed` is an invalidation with an attempt-aware display hint, not a
resource replacement or patch. A client compares `job_revision` with the last resource replacement or patch. A client compares `job_revision` with the last

View file

@ -384,10 +384,35 @@ paths:
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
default: default:
$ref: '#/components/responses/Error' $ref: '#/components/responses/Error'
/v1/event-tickets:
post:
operationId: createEventTicket
description: >-
Creates an independent short-lived, single-use browser WebSocket
credential bound to the authenticated principal and request origin.
This ephemeral authentication exchange does not enter the public
domain mutation idempotency ledger.
responses:
'201':
description: Event ticket created. The response is not cacheable.
content:
application/json:
schema:
$ref: '#/components/schemas/EventTicket'
default:
$ref: '#/components/responses/Error'
/v1/events: /v1/events:
get: get:
operationId: connectEvents operationId: connectEvents
security:
- oidc: []
- eventTicket: []
parameters: parameters:
- name: ticket
in: query
description: Short-lived single-use browser credential created by createEventTicket. Native clients may use OIDC bearer authentication instead.
schema:
$ref: '#/components/schemas/OpaqueId'
- name: cursor - name: cursor
in: query in: query
description: Opaque authorization-bound replay cursor from an HTTP snapshot or prior frame. description: Opaque authorization-bound replay cursor from an HTTP snapshot or prior frame.
@ -643,6 +668,10 @@ components:
openIdConnectUrl: https://identity.invalid/.well-known/openid-configuration openIdConnectUrl: https://identity.invalid/.well-known/openid-configuration
serviceAuth: serviceAuth:
type: mutualTLS type: mutualTLS
eventTicket:
type: apiKey
in: query
name: ticket
parameters: parameters:
IdempotencyKey: IdempotencyKey:
name: Idempotency-Key name: Idempotency-Key
@ -1619,6 +1648,16 @@ components:
updated_at: updated_at:
type: string type: string
format: date-time format: date-time
EventTicket:
type: object
additionalProperties: false
required: [ticket, expires_at]
properties:
ticket:
$ref: '#/components/schemas/OpaqueId'
expires_at:
type: string
format: date-time
ApiError: ApiError:
type: object type: object
additionalProperties: false additionalProperties: false

View file

@ -204,7 +204,7 @@ flowchart TD
## API and Live Updates ## API and Live Updates
- Mutable HTTP and WebSocket implementation contracts live under [`docs/api`](../api/). They are non-authoritative projections of `JobProtocol` and this document and must be corrected when they conflict with either source. - Mutable HTTP and WebSocket implementation contracts live under [`docs/api`](../api/). They are non-authoritative projections of `JobProtocol` and this document and must be corrected when they conflict with either source.
- Public mutation idempotency refines `RemoraApiMutation`: operation scope is the caller subject and operation, the request fingerprint is RFC 8785 canonical JSON hashed with SHA-256, and records are retained for at least the advertised 24-hour default window. - Public domain mutation idempotency refines `RemoraApiMutation`: operation scope is the caller subject and operation, the request fingerprint is RFC 8785 canonical JSON hashed with SHA-256, and records are retained for at least the advertised 24-hour default window. Ephemeral authentication exchanges that do not refine a `JobProtocol` effect, including browser event-ticket issuance, independently mint short-lived credentials on retry and do not enter that ledger.
- Public job status projects `JobProtocol` state and adds deletion lifecycle, concrete progress, checkpoint, result, and failure fields. - Public job status projects `JobProtocol` state and adds deletion lifecycle, concrete progress, checkpoint, result, and failure fields.
- Job listing applies current authorization before stable keyset pagination. Cursors are opaque, integrity-protected, and bound to the subject, filters, and membership snapshot. - Job listing applies current authorization before stable keyset pagination. Cursors are opaque, integrity-protected, and bound to the subject, filters, and membership snapshot.
- HTTP snapshots carry a live-event cursor. WebSocket replay is ordered and at least once; invalid, expired, authorization-incompatible, or unavailable history requires a new HTTP snapshot. Event emission exposes progress only when it matches the protocol projection being published. - HTTP snapshots carry a live-event cursor. WebSocket replay is ordered and at least once; invalid, expired, authorization-incompatible, or unavailable history requires a new HTTP snapshot. Event emission exposes progress only when it matches the protocol projection being published.

View file

@ -23,6 +23,7 @@
"test:e2e:full": "playwright test" "test:e2e:full": "playwright test"
}, },
"dependencies": { "dependencies": {
"oidc-client-ts": "3.5.0",
"svelte": "5.56.5" "svelte": "5.56.5"
}, },
"devDependencies": { "devDependencies": {

23
web/pnpm-lock.yaml generated
View file

@ -7,6 +7,9 @@ settings:
importers: importers:
.: .:
dependencies: dependencies:
oidc-client-ts:
specifier: 3.5.0
version: 3.5.0
svelte: svelte:
specifier: 5.56.5 specifier: 5.56.5
version: 5.56.5(@typescript-eslint/[email protected]) version: 5.56.5(@typescript-eslint/[email protected])
@ -1592,6 +1595,13 @@ packages:
} }
engines: { node: '>=0.10.0' } engines: { node: '>=0.10.0' }
[email protected]:
resolution:
{
integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==,
}
engines: { node: '>=18' }
[email protected]: [email protected]:
resolution: resolution:
{ {
@ -1924,6 +1934,13 @@ packages:
} }
engines: { node: '>=12.20.0' } engines: { node: '>=12.20.0' }
[email protected]:
resolution:
{
integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==,
}
engines: { node: '>=18' }
[email protected]: [email protected]:
resolution: resolution:
{ {
@ -3514,6 +3531,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@ -3670,6 +3689,10 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
jwt-decode: 4.0.0
[email protected]: [email protected]:
dependencies: dependencies:
'@types/json-schema': 7.0.15 '@types/json-schema': 7.0.15

View file

@ -1,12 +1,182 @@
import { expect, test } from 'vitest' import { expect, test, vi } from 'vitest'
import { render } from 'vitest-browser-svelte' import { render } from 'vitest-browser-svelte'
import type {
EventConnection,
EventSocket,
EventSocketListener,
PublicApi,
} from './api/client'
import type {
DeletionOperationDto,
EventFrameDto,
EventTicketDto,
JobPageDto,
JobResourceDto,
} from './api/contracts'
import App from './App.svelte' import App from './App.svelte'
import type { Session, SessionState } from './auth/session'
import { WebClientRuntime, type Scheduler } from './sync/runtime'
import { heartbeat, job, jobChanged } from './sync/test-fixtures'
test('renders the application shell', async () => { test('renders deterministic synchronization across replay and gaps', async () => {
const screen = await render(App) const fixture = runtimeFixture()
const screen = await render(App, { runtime: fixture.runtime })
fixture.session.emit(authenticated('token-one'))
await expect await expect.element(screen.getByText('live', { exact: true })).toBeVisible()
.element(screen.getByRole('heading', { name: 'Remora' })) await expect.element(screen.getByText('Initial snapshot')).toBeVisible()
.toBeVisible()
fixture.api.refetched = job(2, { label: 'Invalidation refetch' })
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
await expect.element(screen.getByText('Invalidation refetch')).toBeVisible()
fixture.socket.frame(jobChanged(2, 'event_000001', 2, 20))
await vi.waitFor(() => {
expect(fixture.api.jobRequests).toBe(1)
})
fixture.api.snapshot = job(3, { label: 'Gap resnapshot' })
fixture.socket.frame(heartbeat(4, 'event_000004'))
await expect.element(screen.getByText('Gap resnapshot')).toBeVisible()
expect(fixture.api.listRequests).toBe(2)
expect(fixture.api.jobRequests).toBe(1)
}) })
test('clears rendered authority on logout and reloads it on token 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()
fixture.session.emit({ kind: 'signed_out' })
await expect
.element(screen.getByText('Sign in to view authorized jobs.'))
.toBeVisible()
await expect
.element(screen.getByRole('button', { name: 'Sign in' }))
.toBeVisible()
fixture.api.snapshot = job(4, { label: 'Changed authorization' })
fixture.session.emit(authenticated('token-two'))
await expect.element(screen.getByText('Changed authorization')).toBeVisible()
expect(fixture.api.tokens).toEqual(['token-one', 'token-two'])
})
function runtimeFixture(): {
runtime: WebClientRuntime
session: FakeSession
api: FakeApi
socket: FakeSocket
} {
const session = new FakeSession()
const api = new FakeApi()
const socket = new FakeSocket()
return {
runtime: new WebClientRuntime(
session,
api,
socket,
new ImmediateScheduler(),
),
session,
api,
socket,
}
}
function authenticated(accessToken: string): SessionState {
return {
kind: 'authenticated',
session: { accessToken, identity: 'issuer\u0000subject' },
}
}
class FakeSession implements Session {
state: SessionState = { kind: 'loading' }
readonly #listeners = new Set<(state: SessionState) => void>()
async start(): Promise<void> {}
async signIn(): Promise<void> {}
async signOut(): Promise<void> {
this.emit({ kind: 'signed_out' })
}
subscribe(listener: (state: SessionState) => void): () => void {
this.#listeners.add(listener)
listener(this.state)
return () => {
this.#listeners.delete(listener)
}
}
emit(state: SessionState): void {
this.state = state
for (const listener of this.#listeners) listener(state)
}
}
class FakeApi implements PublicApi {
snapshot = job(1, { label: 'Initial snapshot' })
refetched = job(2)
listRequests = 0
jobRequests = 0
readonly tokens: string[] = []
async listJobs(accessToken: string): Promise<JobPageDto> {
this.listRequests += 1
this.tokens.push(accessToken)
return {
items: [this.snapshot],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
}
}
async getJob(
_accessToken: string,
_jobId: string,
): Promise<JobResourceDto | null> {
this.jobRequests += 1
return this.refetched
}
async getDeletion(
_accessToken: string,
_operationId: string,
): Promise<DeletionOperationDto | null> {
return null
}
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
return {
ticket: 'ticket_00001',
expires_at: '2026-07-17T00:01:00Z',
}
}
}
class FakeSocket implements EventSocket {
#listener: EventSocketListener | null = null
connect(
_ticket: string,
_cursor: string,
listener: EventSocketListener,
): EventConnection {
this.#listener = listener
listener.opened()
return { close(): void {} }
}
frame(frame: EventFrameDto): void {
this.#listener?.frame(frame)
}
}
class ImmediateScheduler implements Scheduler {
schedule(callback: () => void): () => void {
queueMicrotask(callback)
return () => {}
}
}

View file

@ -1,5 +1,23 @@
<script lang="ts"> <script lang="ts">
import { applicationName } from './lib/application' import { applicationName } from './lib/application'
import { createBrowserRuntime } from './runtime'
import type { WebClientRuntime } from './sync/runtime'
import type { SynchronizationSnapshot } from './sync/state'
let { runtime = createBrowserRuntime() }: { runtime?: WebClientRuntime } =
$props()
let snapshot = $state<SynchronizationSnapshot>()
$effect(() => {
const unsubscribe = runtime.subscribe((next) => {
snapshot = next
})
void runtime.start()
return () => {
unsubscribe()
runtime.stop()
}
})
</script> </script>
<svelte:head> <svelte:head>
@ -7,8 +25,59 @@
</svelte:head> </svelte:head>
<main> <main>
<header>
<div>
<p>Durable transfer service</p> <p>Durable transfer service</p>
<h1>{applicationName}</h1> <h1>{applicationName}</h1>
</div>
{#if snapshot !== undefined}
{#if snapshot.status === 'signed_out'}
<button type="button" onclick={() => runtime.signIn()}>Sign in</button>
{:else if snapshot.status !== 'loading'}
<button type="button" onclick={() => runtime.signOut()}>Sign out</button
>
{/if}
{/if}
</header>
{#if snapshot !== undefined}
<section aria-labelledby="synchronization-heading">
<div class="section-heading">
<h2 id="synchronization-heading">Synchronization</h2>
<output data-status={snapshot.status}>{snapshot.status}</output>
</div>
{#if snapshot.message !== null}
<p role="alert">{snapshot.message}</p>
{/if}
{#if snapshot.status === 'signed_out'}
<p>Sign in to view authorized jobs.</p>
{:else if snapshot.jobs.length === 0}
<p>No authorized jobs in the current snapshot.</p>
{:else}
<ul aria-label="Authorized jobs">
{#each snapshot.jobs as cached (cached.resource.id)}
<li>
<strong>
{cached.resource.resource_kind === 'job'
? (cached.resource.label ?? cached.resource.id)
: cached.resource.id}
</strong>
<span>
{cached.resource.resource_kind === 'job'
? cached.resource.phase
: 'Deleted'}
</span>
{#if cached.provisionalProgress !== null}
<span>
{cached.provisionalProgress.completed_bytes} bytes observed
</span>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</main> </main>
<style> <style>
@ -32,11 +101,17 @@
} }
main { main {
display: grid;
min-height: 100vh; min-height: 100vh;
place-content: center; padding: clamp(1.5rem, 5vw, 4rem);
padding: 2rem; }
text-align: center;
header,
.section-heading,
li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
} }
p { p {
@ -49,7 +124,50 @@
h1 { h1 {
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
font-size: clamp(3rem, 12vw, 7rem); font-size: clamp(2.5rem, 8vw, 5rem);
letter-spacing: -0.06em; letter-spacing: -0.06em;
} }
section {
max-width: 60rem;
margin-top: 4rem;
padding: 1.5rem;
border: 1px solid #d9dee8;
border-radius: 1rem;
background: #fff;
}
h2 {
margin: 0;
}
output {
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: #e9f1fb;
font-size: 0.875rem;
}
button {
padding: 0.7rem 1rem;
border: 0;
border-radius: 0.5rem;
color: white;
background: #14213d;
cursor: pointer;
}
ul {
display: grid;
gap: 0.75rem;
margin: 1.5rem 0 0;
padding: 0;
list-style: none;
}
li {
padding: 0.85rem;
border-radius: 0.5rem;
background: #f8f7f2;
}
</style> </style>

180
web/src/api/client.ts Normal file
View file

@ -0,0 +1,180 @@
import {
decodeDeletionOperation,
decodeEventFrame,
decodeEventTicket,
decodeJobPage,
decodeJobResource,
} from './decode'
import type {
DeletionOperationDto,
EventFrameDto,
EventTicketDto,
JobPageDto,
JobResourceDto,
} from './contracts'
export interface PublicApi {
listJobs(accessToken: string, cursor?: string): Promise<JobPageDto>
getJob(accessToken: string, jobId: string): Promise<JobResourceDto | null>
getDeletion(
accessToken: string,
operationId: string,
): Promise<DeletionOperationDto | null>
createEventTicket(accessToken: string): Promise<EventTicketDto>
}
export class ApiResponseError extends Error {
constructor(readonly status: number) {
super(`Remora API returned HTTP ${status}`)
}
}
export class FetchPublicApi implements PublicApi {
readonly #baseUrl: URL
readonly #fetch: typeof fetch
constructor(
baseUrl: string | URL,
fetchImplementation: typeof fetch = fetch,
) {
this.#baseUrl = new URL(baseUrl, 'http://localhost/')
this.#fetch = fetchImplementation
}
async listJobs(accessToken: string, cursor?: string): Promise<JobPageDto> {
const url = this.#url('jobs')
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
return decodeJobPage(await this.#request(url, accessToken))
}
async getJob(
accessToken: string,
jobId: string,
): Promise<JobResourceDto | null> {
const response = await this.#requestOrMissing(
this.#url(`jobs/${encodeURIComponent(jobId)}`),
accessToken,
)
return response === null ? null : decodeJobResource(response)
}
async getDeletion(
accessToken: string,
operationId: string,
): Promise<DeletionOperationDto | null> {
const response = await this.#requestOrMissing(
this.#url(`deletions/${encodeURIComponent(operationId)}`),
accessToken,
)
return response === null ? null : decodeDeletionOperation(response)
}
async createEventTicket(accessToken: string): Promise<EventTicketDto> {
return decodeEventTicket(
await this.#request(this.#url('event-tickets'), accessToken, 'POST'),
)
}
#url(path: string): URL {
return new URL(path, withTrailingSlash(this.#baseUrl))
}
async #request(
url: URL,
accessToken: string,
method = 'GET',
): Promise<unknown> {
const response = await this.#fetch(url, {
method,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
cache: 'no-store',
credentials: 'omit',
})
if (!response.ok) throw new ApiResponseError(response.status)
return response.json()
}
async #requestOrMissing(url: URL, accessToken: string): Promise<unknown> {
try {
return await this.#request(url, accessToken)
} catch (error) {
if (error instanceof ApiResponseError && error.status === 404) return null
throw error
}
}
}
export interface EventConnection {
close(): void
}
export interface EventSocket {
connect(
ticket: string,
cursor: string,
listener: EventSocketListener,
): EventConnection
}
export interface EventSocketListener {
opened(): void
frame(frame: EventFrameDto): void
closed(): void
failed(error: Error): void
}
export class BrowserEventSocket implements EventSocket {
readonly #eventsUrl: URL
constructor(eventsUrl: string | URL) {
this.#eventsUrl = new URL(eventsUrl, 'http://localhost/')
}
connect(
ticket: string,
cursor: string,
listener: EventSocketListener,
): EventConnection {
const url = new URL(this.#eventsUrl)
url.searchParams.set('ticket', ticket)
url.searchParams.set('cursor', cursor)
const socket = new WebSocket(url)
let closed = false
socket.addEventListener('open', () => {
listener.opened()
})
socket.addEventListener('message', (event) => {
try {
listener.frame(decodeEventFrame(JSON.parse(String(event.data))))
} catch (error) {
socket.close(1002, 'invalid event frame')
listener.failed(asError(error))
}
})
socket.addEventListener('error', () => {
listener.failed(new Error('Live event connection failed'))
})
socket.addEventListener('close', () => {
if (!closed) listener.closed()
})
return {
close(): void {
closed = true
socket.close(1000)
},
}
}
}
function withTrailingSlash(url: URL): URL {
const copy = new URL(url)
if (!copy.pathname.endsWith('/')) copy.pathname += '/'
return copy
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error('Invalid live event frame')
}

View file

@ -0,0 +1,177 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { heartbeat, job } from '../sync/test-fixtures'
import type { EventFrameDto } from './contracts'
import { ApiResponseError, BrowserEventSocket, FetchPublicApi } from './client'
import { decodeEventTicket, decodeJobPage } from './decode'
describe('adapted public API client', () => {
test('authenticates, encodes cursors, and decodes snapshots', async () => {
const request = vi.fn<typeof fetch>(async () =>
Response.json({
items: [job(1)],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
}),
)
const api = new FetchPublicApi('https://remora.invalid/v1/', request)
const page = await api.listJobs('access-token', 'cursor page/one')
expect(page.items).toEqual([job(1)])
const [url, init] = request.mock.calls[0]
if (!(url instanceof URL)) throw new Error('expected URL request')
expect(url.href).toBe(
'https://remora.invalid/v1/jobs?cursor=cursor+page%2Fone',
)
expect(init).toMatchObject({
cache: 'no-store',
credentials: 'omit',
headers: {
Accept: 'application/json',
Authorization: 'Bearer access-token',
},
})
})
test('maps an indistinguishable missing resource without swallowing faults', async () => {
const missing = new FetchPublicApi(
'https://remora.invalid/v1/',
async () => new Response(null, { status: 404 }),
)
await expect(missing.getJob('token', 'job_00000001')).resolves.toBeNull()
const unavailable = new FetchPublicApi(
'https://remora.invalid/v1/',
async () => new Response(null, { status: 503 }),
)
await expect(unavailable.getJob('token', 'job_00000001')).rejects.toEqual(
new ApiResponseError(503),
)
})
test('creates a one-time event ticket through authenticated HTTP', async () => {
const request = vi.fn<typeof fetch>(async () =>
Response.json(
{
ticket: 'ticket_00001',
expires_at: '2026-07-17T00:01:00Z',
},
{ status: 201 },
),
)
const api = new FetchPublicApi('https://remora.invalid/v1/', request)
await expect(api.createEventTicket('token')).resolves.toEqual({
ticket: 'ticket_00001',
expires_at: '2026-07-17T00:01:00Z',
})
expect(request.mock.calls[0]?.[1]?.method).toBe('POST')
})
})
describe('snapshot wire decoding', () => {
test('rejects unknown and malformed resource fields', () => {
expect(() =>
decodeJobPage({
items: [{ ...job(1), secret: 'must not pass through' }],
next_cursor: null,
event_cursor: 'cursor-snapshot-0001',
}),
).toThrow('unknown DTO field')
expect(() =>
decodeEventTicket({
ticket: 'short',
expires_at: '2026-07-17T00:01:00Z',
}),
).toThrow('invalid opaque identifier')
})
})
describe('browser event adapter', () => {
afterEach(() => {
FakeWebSocket.instances.length = 0
vi.unstubAllGlobals()
})
test('uses a ticket and cursor and decodes server frames', () => {
vi.stubGlobal('WebSocket', FakeWebSocket)
const opened = vi.fn<() => void>()
const frame = vi.fn<(event: EventFrameDto) => void>()
const closed = vi.fn<() => void>()
const failed = vi.fn<(error: Error) => void>()
const socket = new BrowserEventSocket('wss://remora.invalid/api/v1/events')
socket.connect('ticket_00001', 'cursor-snapshot-0001', {
opened,
frame,
closed,
failed,
})
const browser = FakeWebSocket.instances[0]
if (browser === undefined) throw new Error('missing WebSocket')
expect(browser.url instanceof URL ? browser.url.href : browser.url).toBe(
'wss://remora.invalid/api/v1/events?ticket=ticket_00001&cursor=cursor-snapshot-0001',
)
browser.emit('open', {})
browser.emit('message', {
data: JSON.stringify(heartbeat(1, 'event_000001')),
})
expect(opened).toHaveBeenCalledOnce()
expect(frame).toHaveBeenCalledWith(heartbeat(1, 'event_000001'))
expect(closed).not.toHaveBeenCalled()
expect(failed).not.toHaveBeenCalled()
})
test('closes a connection that sends an invalid frame', () => {
vi.stubGlobal('WebSocket', FakeWebSocket)
const failed = vi.fn<(error: Error) => void>()
const socket = new BrowserEventSocket('wss://remora.invalid/api/v1/events')
socket.connect('ticket_00001', 'cursor-snapshot-0001', {
opened: vi.fn<() => void>(),
frame: vi.fn<(event: EventFrameDto) => void>(),
closed: vi.fn<() => void>(),
failed,
})
const browser = FakeWebSocket.instances[0]
if (browser === undefined) throw new Error('missing WebSocket')
browser.emit('message', { data: '{}' })
expect(browser.closeCode).toBe(1002)
expect(failed).toHaveBeenCalledOnce()
})
})
class FakeWebSocket {
static readonly instances: FakeWebSocket[] = []
readonly #listeners = new Map<string, ((event: FakeSocketEvent) => void)[]>()
closeCode: number | null = null
constructor(readonly url: string | URL) {
FakeWebSocket.instances.push(this)
}
addEventListener(
type: string,
listener: (event: FakeSocketEvent) => void,
): void {
const listeners = this.#listeners.get(type) ?? []
listeners.push(listener)
this.#listeners.set(type, listeners)
}
close(code?: number): void {
this.closeCode = code ?? 1000
}
emit(type: string, event: FakeSocketEvent): void {
for (const listener of this.#listeners.get(type) ?? []) listener(event)
}
}
interface FakeSocketEvent {
data?: unknown
}

View file

@ -177,6 +177,11 @@ export interface HeartbeatDto extends EventBaseDto {
kind: 'heartbeat' kind: 'heartbeat'
} }
export interface EventTicketDto {
ticket: OpaqueIdDto
expires_at: string
}
export type SourceCredentialKindDto = export type SourceCredentialKindDto =
| 'basic_auth' | 'basic_auth'
| 'bearer_token' | 'bearer_token'

View file

@ -2,14 +2,22 @@ import { runtimeFailureClass, type JobPhase } from '../domain/vocabulary'
import { opaqueId } from '../domain/validation' import { opaqueId } from '../domain/validation'
import type { import type {
AttemptOutcomeRequestDto, AttemptOutcomeRequestDto,
CheckpointProjectionDto,
DeletionOperationDto,
EventFrameDto, EventFrameDto,
EventProgressDto, EventProgressDto,
EventTicketDto,
HuggingFaceSelectionDto, HuggingFaceSelectionDto,
JobFailureDto, JobFailureDto,
JobPageDto,
JobResourceDto,
JobSubmissionDto, JobSubmissionDto,
ObjectIdentityDto, ObjectIdentityDto,
ProgressDto,
ResultDescriptorDto, ResultDescriptorDto,
SourceProjectionDto,
SourceSubmissionDto, SourceSubmissionDto,
TargetDto,
TargetOverrideDto, TargetOverrideDto,
} from './contracts' } from './contracts'
@ -118,6 +126,132 @@ export function decodeEventFrame(value: unknown): EventFrameDto {
} }
} }
export function decodeEventTicket(value: unknown): EventTicketDto {
const object = record(value)
exactKeys(object, ['ticket', 'expires_at'])
return {
ticket: opaqueId(string(object.ticket)),
expires_at: dateTime(object.expires_at),
}
}
export function decodeJobPage(value: unknown): JobPageDto {
const object = record(value)
exactKeys(object, ['items', 'next_cursor', 'event_cursor'])
if (!Array.isArray(object.items)) throw new Error('expected job array')
return {
items: object.items.map(decodeJobResource),
next_cursor:
object.next_cursor === null ? null : cursor(object.next_cursor),
event_cursor: cursor(object.event_cursor),
}
}
export function decodeJobResource(value: unknown): JobResourceDto {
const object = record(value)
if (object.resource_kind === 'job') {
exactKeys(object, [
'resource_kind',
'id',
'owner',
'label',
'revision',
'phase',
'lifecycle',
'source',
'target',
'authoritative_attempt',
'progress',
'checkpoint',
'result',
'failure',
'created_at',
'updated_at',
'event_cursor',
])
return {
resource_kind: 'job',
id: opaqueId(string(object.id)),
owner: opaqueId(string(object.owner)),
label: object.label === null ? null : string(object.label),
revision: positiveInteger(object.revision),
phase: jobPhase(object.phase),
lifecycle: lifecycle(object.lifecycle),
source: decodeSourceProjection(object.source),
target: decodeTarget(object.target),
authoritative_attempt:
object.authoritative_attempt === null
? null
: decodeAttemptAuthority(object.authoritative_attempt),
progress:
object.progress === null ? null : decodeProgress(object.progress),
checkpoint:
object.checkpoint === null ? null : decodeCheckpoint(object.checkpoint),
result: object.result === null ? null : decodeResult(object.result),
failure:
object.failure === null ? null : decodeJobFailure(object.failure),
created_at: dateTime(object.created_at),
updated_at: dateTime(object.updated_at),
event_cursor: cursor(object.event_cursor),
}
}
if (object.resource_kind === 'tombstone') {
exactKeys(object, [
'resource_kind',
'id',
'owner',
'target',
'terminal_phase',
'deletion_operation_id',
'created_at',
'hard_delete_started_at',
'updated_at',
'event_cursor',
])
const terminalPhase = jobPhase(object.terminal_phase)
if (
terminalPhase !== 'Succeeded' &&
terminalPhase !== 'Failed' &&
terminalPhase !== 'Cancelled'
)
throw new Error('invalid tombstone terminal phase')
return {
resource_kind: 'tombstone',
id: opaqueId(string(object.id)),
owner: opaqueId(string(object.owner)),
target: decodeTarget(object.target),
terminal_phase: terminalPhase,
deletion_operation_id: opaqueId(string(object.deletion_operation_id)),
created_at: dateTime(object.created_at),
hard_delete_started_at: dateTime(object.hard_delete_started_at),
updated_at: dateTime(object.updated_at),
event_cursor: cursor(object.event_cursor),
}
}
throw new Error('unknown job resource kind')
}
export function decodeDeletionOperation(value: unknown): DeletionOperationDto {
const object = record(value)
exactKeys(object, [
'id',
'job_id',
'state',
'problem',
'created_at',
'updated_at',
])
return {
id: opaqueId(string(object.id)),
job_id: opaqueId(string(object.job_id)),
state: deletionState(object.state),
problem:
object.problem === null ? null : decodeDeletionProblem(object.problem),
created_at: dateTime(object.created_at),
updated_at: dateTime(object.updated_at),
}
}
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'])
@ -155,6 +289,144 @@ export function decodeAttemptOutcome(value: unknown): AttemptOutcomeRequestDto {
throw new Error('unknown attempt outcome kind') throw new Error('unknown attempt outcome kind')
} }
function decodeSourceProjection(value: unknown): SourceProjectionDto {
const object = record(value)
const credential =
object.credential_id === undefined
? {}
: { credential_id: opaqueId(string(object.credential_id)) }
switch (object.kind) {
case 'http':
exactKeys(object, ['kind', 'display_url', 'result_path', 'credential_id'])
return {
kind: 'http',
display_url: string(object.display_url),
result_path: string(object.result_path),
...credential,
}
case 'object_store':
exactKeys(object, [
'kind',
'store',
'bucket',
'key',
'version_id',
'result_path',
'credential_id',
])
return {
kind: 'object_store',
store: string(object.store),
bucket: string(object.bucket),
key: string(object.key),
...(object.version_id === undefined
? {}
: { version_id: string(object.version_id) }),
result_path: string(object.result_path),
...credential,
}
case 'hugging_face':
exactKeys(object, [
'kind',
'repository_type',
'repository_id',
'revision',
'selection',
'credential_id',
])
return {
kind: 'hugging_face',
repository_type: repositoryType(object.repository_type),
repository_id: string(object.repository_id),
revision: string(object.revision),
...(object.selection === undefined
? {}
: { selection: decodeSelection(object.selection) }),
...credential,
}
default:
throw new Error('unknown source projection kind')
}
}
function decodeTarget(value: unknown): TargetDto {
const object = record(value)
exactKeys(object, ['store', 'bucket', 'prefix'])
return {
store: string(object.store),
bucket: string(object.bucket),
prefix: string(object.prefix),
}
}
function decodeAttemptAuthority(value: unknown): {
attempt_id: string
fence: number
worker_incarnation: string
} {
const object = record(value)
exactKeys(object, ['attempt_id', 'fence', 'worker_incarnation'])
return {
attempt_id: opaqueId(string(object.attempt_id)),
fence: positiveInteger(object.fence),
worker_incarnation: opaqueId(string(object.worker_incarnation)),
}
}
function decodeProgress(value: unknown): ProgressDto {
const object = record(value)
exactKeys(object, [
'attempt_id',
'fence',
'completed_bytes',
'total_bytes',
'completed_members',
'total_members',
'observed_at',
])
return {
attempt_id: opaqueId(string(object.attempt_id)),
fence: positiveInteger(object.fence),
completed_bytes: nonnegativeInteger(object.completed_bytes),
...(object.total_bytes === undefined
? {}
: { total_bytes: nullableNonnegativeInteger(object.total_bytes) }),
completed_members: nonnegativeInteger(object.completed_members),
...(object.total_members === undefined
? {}
: { total_members: nullableNonnegativeInteger(object.total_members) }),
observed_at: dateTime(object.observed_at),
}
}
function decodeCheckpoint(value: unknown): CheckpointProjectionDto {
const object = record(value)
exactKeys(object, ['reusable', 'validated_bytes', 'validated_members'])
if (typeof object.reusable !== 'boolean')
throw new Error('invalid checkpoint reusable flag')
return {
reusable: object.reusable,
validated_bytes: nonnegativeInteger(object.validated_bytes),
validated_members: nonnegativeInteger(object.validated_members),
}
}
function decodeDeletionProblem(value: unknown): {
class: 'authorization' | 'target_unavailable' | 'configuration' | 'internal'
message: string
} {
const object = record(value)
exactKeys(object, ['class', 'message'])
if (
object.class !== 'authorization' &&
object.class !== 'target_unavailable' &&
object.class !== 'configuration' &&
object.class !== 'internal'
)
throw new Error('unknown deletion problem class')
return { class: object.class, message: string(object.message) }
}
function decodeSourceSubmission(value: unknown): SourceSubmissionDto { function decodeSourceSubmission(value: unknown): SourceSubmissionDto {
const object = record(value) const object = record(value)
const credential = const credential =

View file

@ -0,0 +1,129 @@
import { User, UserManager } from 'oidc-client-ts'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { BrowserOidcSession, type BrowserOidcConfig } from './session'
const originalUrl = window.location.href
const config: BrowserOidcConfig = {
authority: 'https://identity.invalid',
clientId: 'remora-web',
redirectUri: window.location.origin + '/',
postLogoutRedirectUri: window.location.origin + '/',
scope: 'openid profile',
}
afterEach(() => {
window.history.replaceState({}, document.title, originalUrl)
})
describe('browser OIDC session', () => {
test('processes the authorization-code callback and removes its parameters', async () => {
const manager = oidcManager()
const authenticated = user('access-token')
const callback = vi
.spyOn(manager, 'signinCallback')
.mockResolvedValue(authenticated)
window.history.replaceState(
{},
document.title,
`${window.location.pathname}?code=authorization-code&state=oidc-state`,
)
const session = new BrowserOidcSession(
config,
window.sessionStorage,
manager,
)
await session.start()
expect(callback).toHaveBeenCalledOnce()
expect(session.state).toEqual({
kind: 'authenticated',
session: {
accessToken: 'access-token',
identity: 'https://identity.invalid\u0000subject-1',
},
})
expect(window.location.search).toBe('')
})
test('processes logout callbacks and fails closed on expired users', async () => {
const manager = oidcManager()
const callback = vi
.spyOn(manager, 'signoutCallback')
.mockResolvedValue(undefined)
window.history.replaceState(
{},
document.title,
`${window.location.pathname}?state=logout-state`,
)
const logout = new BrowserOidcSession(
config,
window.sessionStorage,
manager,
)
await logout.start()
expect(callback).toHaveBeenCalledOnce()
expect(logout.state).toEqual({ kind: 'signed_out' })
const expiredManager = oidcManager()
vi.spyOn(expiredManager, 'getUser').mockResolvedValue(
user('expired-token', 1),
)
const expired = new BrowserOidcSession(
config,
window.sessionStorage,
expiredManager,
)
await expired.start()
expect(expired.state).toEqual({ kind: 'signed_out' })
})
test('clears the local session when provider logout cannot start', async () => {
const manager = oidcManager()
vi.spyOn(manager, 'signoutRedirect').mockRejectedValue(
new Error('provider unavailable'),
)
const remove = vi.spyOn(manager, 'removeUser').mockResolvedValue()
const session = new BrowserOidcSession(
config,
window.sessionStorage,
manager,
)
await session.signOut()
expect(remove).toHaveBeenCalledOnce()
expect(session.state).toEqual({ kind: 'signed_out' })
})
})
function oidcManager(): UserManager {
return new UserManager({
authority: config.authority,
client_id: config.clientId,
redirect_uri: config.redirectUri,
post_logout_redirect_uri: config.postLogoutRedirectUri,
response_type: 'code',
scope: config.scope,
})
}
function user(accessToken: string, expiresAt?: number): User {
return new User({
session_state: null,
access_token: accessToken,
token_type: 'Bearer',
scope: 'openid profile',
profile: {
iss: 'https://identity.invalid',
sub: 'subject-1',
aud: 'remora-web',
exp: expiresAt ?? Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
},
...(expiresAt === undefined ? {} : { expires_at: expiresAt }),
})
}

175
web/src/auth/session.ts Normal file
View file

@ -0,0 +1,175 @@
import {
UserManager,
WebStorageStateStore,
type User,
type UserManagerSettings,
} from 'oidc-client-ts'
export interface AuthenticatedSession {
accessToken: string
identity: string
}
export type SessionState =
| { kind: 'loading' }
| { kind: 'signed_out' }
| { kind: 'authenticated'; session: AuthenticatedSession }
| { kind: 'error'; message: string }
export interface Session {
readonly state: SessionState
start(): Promise<void>
signIn(): Promise<void>
signOut(): Promise<void>
subscribe(listener: (state: SessionState) => void): () => void
}
export interface BrowserOidcConfig {
authority: string
clientId: string
redirectUri: string
postLogoutRedirectUri: string
scope: string
}
export type OidcManager = Pick<
UserManager,
| 'events'
| 'getUser'
| 'removeUser'
| 'signinCallback'
| 'signinRedirect'
| 'signoutCallback'
| 'signoutRedirect'
>
export class BrowserOidcSession implements Session {
#state: SessionState = { kind: 'loading' }
readonly #listeners = new Set<(state: SessionState) => void>()
readonly #manager: OidcManager
constructor(
config: BrowserOidcConfig,
storage: Storage = window.sessionStorage,
manager?: OidcManager,
) {
this.#manager = manager ?? createManager(config, storage)
this.#manager.events.addUserLoaded((user) => {
this.#setState(authenticated(user))
})
this.#manager.events.addUserUnloaded(() => {
this.#setState({ kind: 'signed_out' })
})
this.#manager.events.addAccessTokenExpired(() => {
this.#setState({ kind: 'signed_out' })
})
this.#manager.events.addSilentRenewError(() => {
this.#setState({ kind: 'error', message: 'Session renewal failed' })
})
}
get state(): SessionState {
return this.#state
}
async start(): Promise<void> {
this.#setState({ kind: 'loading' })
const parameters = new URLSearchParams(window.location.search)
const hasState = parameters.has('state')
const signinCallback =
hasState && (parameters.has('code') || parameters.has('error'))
try {
let user: User | null | undefined
if (signinCallback) user = await this.#manager.signinCallback()
else if (hasState) {
await this.#manager.signoutCallback()
user = null
} else user = await this.#manager.getUser()
this.#setState(
user === null || user === undefined
? { kind: 'signed_out' }
: authenticated(user),
)
} catch {
this.#setState({
kind: 'error',
message: 'Unable to restore the session',
})
} finally {
if (hasState)
window.history.replaceState(
{},
document.title,
window.location.pathname + window.location.hash,
)
}
}
async signIn(): Promise<void> {
try {
await this.#manager.signinRedirect()
} catch {
this.#setState({ kind: 'error', message: 'Unable to start sign in' })
}
}
async signOut(): Promise<void> {
try {
await this.#manager.signoutRedirect()
} catch {
await this.#manager.removeUser().catch(() => undefined)
this.#setState({ kind: 'signed_out' })
}
}
subscribe(listener: (state: SessionState) => void): () => void {
this.#listeners.add(listener)
listener(this.#state)
return () => {
this.#listeners.delete(listener)
}
}
#setState(state: SessionState): void {
this.#state = state
for (const listener of this.#listeners) listener(state)
}
}
function authenticated(user: User): SessionState {
const issuer = user.profile.iss
const subject = user.profile.sub
if (
issuer === undefined ||
subject === undefined ||
user.access_token === '' ||
user.expired
)
return { kind: 'signed_out' }
return {
kind: 'authenticated',
session: {
accessToken: user.access_token,
identity: `${issuer}\u0000${subject}`,
},
}
}
function createManager(
config: BrowserOidcConfig,
storage: Storage,
): UserManager {
const settings: UserManagerSettings = {
authority: config.authority,
client_id: config.clientId,
redirect_uri: config.redirectUri,
post_logout_redirect_uri: config.postLogoutRedirectUri,
response_type: 'code',
scope: config.scope,
automaticSilentRenew: true,
monitorSession: true,
userStore: new WebStorageStateStore({ store: storage }),
stateStore: new WebStorageStateStore({ store: storage }),
}
return new UserManager(settings)
}

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