Implement replay-safe browser synchronization
This commit is contained in:
parent
06a7ac56b7
commit
459ffa04c6
26 changed files with 2734 additions and 25 deletions
10
crates/remora-api/migrations/0002_live_event_tickets.sql
Normal file
10
crates/remora-api/migrations/0002_live_event_tickets.sql
Normal 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);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use std::time::Duration;
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
|
||||
use actix_web::{
|
||||
HttpRequest, HttpResponse, ResponseError,
|
||||
|
|
@ -51,6 +51,8 @@ pub struct PublicApiConfig {
|
|||
pub event_retention: Duration,
|
||||
pub event_poll_interval: Duration,
|
||||
pub event_heartbeat_interval: Duration,
|
||||
pub event_ticket_ttl: Duration,
|
||||
pub event_allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -68,6 +70,8 @@ pub struct PublicApiState {
|
|||
event_retention: Duration,
|
||||
event_poll_interval: Duration,
|
||||
event_heartbeat_interval: Duration,
|
||||
event_ticket_ttl: Duration,
|
||||
event_allowed_origins: HashSet<String>,
|
||||
}
|
||||
|
||||
pub struct PublicApiServices {
|
||||
|
|
@ -93,9 +97,28 @@ impl PublicApiState {
|
|||
if config.event_retention.is_zero()
|
||||
|| config.event_poll_interval.is_zero()
|
||||
|| config.event_heartbeat_interval.is_zero()
|
||||
|| config.event_ticket_ttl.is_zero()
|
||||
{
|
||||
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 {
|
||||
|
|
@ -112,6 +135,8 @@ impl PublicApiState {
|
|||
event_retention: config.event_retention,
|
||||
event_poll_interval: config.event_poll_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}",
|
||||
web::get().to(get_deletion_operation),
|
||||
)
|
||||
.route("/event-tickets", web::post().to(events::issue_ticket))
|
||||
.route("/events", web::get().to(events::connect)),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use actix_web::{HttpRequest, HttpResponse, http::header, web};
|
||||
use futures_util::StreamExt as _;
|
||||
use remora_contracts::{
|
||||
CursorDto, DateTimeDto, DeletionChangedDto, DeletionStateDto, EventAttemptDto, EventFrameDto,
|
||||
EventProgressDto, HeartbeatDto, JobChangedDto, NullableDto, PositiveIntegerDto,
|
||||
EventProgressDto, EventTicketDto, HeartbeatDto, JobChangedDto, NullableDto, PositiveIntegerDto,
|
||||
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_identity::{ManagementOperation, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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 super::{
|
||||
|
|
@ -24,6 +27,60 @@ const EVENT_BATCH: i64 = 100;
|
|||
#[derive(Deserialize)]
|
||||
pub(super) struct EventQuery {
|
||||
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(
|
||||
|
|
@ -32,7 +89,13 @@ pub(super) async fn connect(
|
|||
body: web::Payload,
|
||||
query: web::Query<EventQuery>,
|
||||
) -> 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 retention =
|
||||
time::Duration::try_from(state.event_retention).map_err(|_| PublicApiError::internal())?;
|
||||
|
|
@ -54,6 +117,94 @@ pub(super) async fn connect(
|
|||
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(
|
||||
pool: &PgPool,
|
||||
before: OffsetDateTime,
|
||||
|
|
|
|||
|
|
@ -29,9 +29,12 @@ use time::OffsetDateTime;
|
|||
use tokio_tungstenite::{
|
||||
MaybeTlsStream, WebSocketStream, connect_async,
|
||||
tungstenite::{
|
||||
Message,
|
||||
Error as WebSocketError, Message,
|
||||
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};
|
||||
|
||||
type Socket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
const BROWSER_ORIGIN: &str = "https://remora.test";
|
||||
|
||||
struct Harness {
|
||||
pool: PgPool,
|
||||
|
|
@ -158,6 +162,8 @@ impl Harness {
|
|||
event_retention: Duration::from_millis(500),
|
||||
event_poll_interval: Duration::from_millis(250),
|
||||
event_heartbeat_interval: Duration::from_millis(100),
|
||||
event_ticket_ttl: Duration::from_secs(30),
|
||||
event_allowed_origins: vec!["https://remora.test".into()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -227,7 +233,50 @@ impl Harness {
|
|||
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 {
|
||||
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(
|
||||
|| self.ws_base.clone(),
|
||||
|cursor| format!("{}?cursor={cursor}", self.ws_base),
|
||||
|
|
@ -255,6 +304,60 @@ async fn live_event_replay_authorization_progress_and_resync_converge() {
|
|||
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;
|
||||
assert_resync(next_frame(&mut absent).await, ResyncReasonDto::CursorAbsent);
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,8 @@ impl Harness {
|
|||
event_retention: Duration::from_secs(60),
|
||||
event_poll_interval: Duration::from_millis(50),
|
||||
event_heartbeat_interval: Duration::from_secs(1),
|
||||
event_ticket_ttl: Duration::from_secs(30),
|
||||
event_allowed_origins: vec!["https://remora.test".into()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ pub enum EventFrameDto {
|
|||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct JobChangedDto {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ pub use dto::{
|
|||
};
|
||||
pub use events::{
|
||||
DeletionChangedDto, DeletionStateDto, EventAttemptDto, EventFrameDto, EventProgressDto,
|
||||
HeartbeatDto, JobChangedDto, ResyncReasonDto, ResyncRequiredDto,
|
||||
EventTicketDto, HeartbeatDto, JobChangedDto, ResyncReasonDto, ResyncRequiredDto,
|
||||
};
|
||||
pub use internal::{
|
||||
ActivationResponseDto, AttemptActivateRequestDto, AttemptCheckpointRequestDto,
|
||||
|
|
|
|||
|
|
@ -13,13 +13,17 @@ path or media type.
|
|||
|
||||
## 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,
|
||||
including the authorization target, with RFC 8785 and stores its SHA-256
|
||||
fingerprint and complete response atomically with the mutation. The default
|
||||
retention is 24 hours and is never shorter than the advertised
|
||||
`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
|
||||
body after duplicate or ambiguous delivery. A different fingerprint returns
|
||||
`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
|
||||
subscription frame. Server frames are ordered by increasing `sequence` for a
|
||||
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
|
||||
resource replacement or patch. A client compares `job_revision` with the last
|
||||
|
|
|
|||
|
|
@ -384,10 +384,35 @@ paths:
|
|||
$ref: '#/components/responses/NotFound'
|
||||
default:
|
||||
$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:
|
||||
get:
|
||||
operationId: connectEvents
|
||||
security:
|
||||
- oidc: []
|
||||
- eventTicket: []
|
||||
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
|
||||
in: query
|
||||
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
|
||||
serviceAuth:
|
||||
type: mutualTLS
|
||||
eventTicket:
|
||||
type: apiKey
|
||||
in: query
|
||||
name: ticket
|
||||
parameters:
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
|
|
@ -1619,6 +1648,16 @@ components:
|
|||
updated_at:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ flowchart TD
|
|||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"test:e2e:full": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"oidc-client-ts": "3.5.0",
|
||||
"svelte": "5.56.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
23
web/pnpm-lock.yaml
generated
23
web/pnpm-lock.yaml
generated
|
|
@ -7,6 +7,9 @@ settings:
|
|||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
oidc-client-ts:
|
||||
specifier: 3.5.0
|
||||
version: 3.5.0
|
||||
svelte:
|
||||
specifier: 5.56.5
|
||||
version: 5.56.5(@typescript-eslint/[email protected])
|
||||
|
|
@ -1592,6 +1595,13 @@ packages:
|
|||
}
|
||||
engines: { node: '>=0.10.0' }
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==,
|
||||
}
|
||||
engines: { node: '>=18' }
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -1924,6 +1934,13 @@ packages:
|
|||
}
|
||||
engines: { node: '>=12.20.0' }
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==,
|
||||
}
|
||||
engines: { node: '>=18' }
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -3514,6 +3531,8 @@ snapshots:
|
|||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
|
|
@ -3670,6 +3689,10 @@ snapshots:
|
|||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
jwt-decode: 4.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
|
|
|||
|
|
@ -1,12 +1,182 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
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 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 () => {
|
||||
const screen = await render(App)
|
||||
test('renders deterministic synchronization across replay and gaps', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
const screen = await render(App, { runtime: fixture.runtime })
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('heading', { name: 'Remora' }))
|
||||
.toBeVisible()
|
||||
await expect.element(screen.getByText('live', { exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByText('Initial snapshot')).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 () => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
<script lang="ts">
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -7,8 +25,59 @@
|
|||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<p>Durable transfer service</p>
|
||||
<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>
|
||||
|
||||
<style>
|
||||
|
|
@ -32,11 +101,17 @@
|
|||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
padding: clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
header,
|
||||
.section-heading,
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
|
|
@ -49,7 +124,50 @@
|
|||
|
||||
h1 {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: clamp(3rem, 12vw, 7rem);
|
||||
font-size: clamp(2.5rem, 8vw, 5rem);
|
||||
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>
|
||||
|
|
|
|||
180
web/src/api/client.ts
Normal file
180
web/src/api/client.ts
Normal 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')
|
||||
}
|
||||
177
web/src/api/client.unit.test.ts
Normal file
177
web/src/api/client.unit.test.ts
Normal 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
|
||||
}
|
||||
|
|
@ -177,6 +177,11 @@ export interface HeartbeatDto extends EventBaseDto {
|
|||
kind: 'heartbeat'
|
||||
}
|
||||
|
||||
export interface EventTicketDto {
|
||||
ticket: OpaqueIdDto
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export type SourceCredentialKindDto =
|
||||
| 'basic_auth'
|
||||
| 'bearer_token'
|
||||
|
|
|
|||
|
|
@ -2,14 +2,22 @@ import { runtimeFailureClass, type JobPhase } from '../domain/vocabulary'
|
|||
import { opaqueId } from '../domain/validation'
|
||||
import type {
|
||||
AttemptOutcomeRequestDto,
|
||||
CheckpointProjectionDto,
|
||||
DeletionOperationDto,
|
||||
EventFrameDto,
|
||||
EventProgressDto,
|
||||
EventTicketDto,
|
||||
HuggingFaceSelectionDto,
|
||||
JobFailureDto,
|
||||
JobPageDto,
|
||||
JobResourceDto,
|
||||
JobSubmissionDto,
|
||||
ObjectIdentityDto,
|
||||
ProgressDto,
|
||||
ResultDescriptorDto,
|
||||
SourceProjectionDto,
|
||||
SourceSubmissionDto,
|
||||
TargetDto,
|
||||
TargetOverrideDto,
|
||||
} 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 {
|
||||
const object = record(value)
|
||||
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')
|
||||
}
|
||||
|
||||
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 {
|
||||
const object = record(value)
|
||||
const credential =
|
||||
|
|
|
|||
129
web/src/auth/session.browser.test.ts
Normal file
129
web/src/auth/session.browser.test.ts
Normal 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
175
web/src/auth/session.ts
Normal 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
Loading…
Reference in a new issue