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::{
|
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)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
23
web/pnpm-lock.yaml
generated
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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'
|
kind: 'heartbeat'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EventTicketDto {
|
||||||
|
ticket: OpaqueIdDto
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SourceCredentialKindDto =
|
export type SourceCredentialKindDto =
|
||||||
| 'basic_auth'
|
| 'basic_auth'
|
||||||
| 'bearer_token'
|
| 'bearer_token'
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
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