diff --git a/README.md b/README.md index 95d8fe5a..7fe3a572 100644 --- a/README.md +++ b/README.md @@ -2504,6 +2504,10 @@ inventories and validates their ownership; its convenience fields are unchanged. Portable artifacts remain bounded to 4 MiB. Version 1 artifacts must be regenerated with the matching CLI; they are not accepted as version 2. +Modeled projection contracts [share repeated operations and selector schemas](docs/compact-projection-contracts.md) +when smaller, preserving every current and retained event selector within the +same manifest budget. Runtime program identities and execution are unchanged. + For a full application, run `distributed build` or `distributed dev` from its Cargo workspace root. The CLI discovers the typed application, runtime binary, conventional `ui/` SvelteKit app, and `@hops-ops/distributed` dependency. A diff --git a/docs/compact-projection-contracts.md b/docs/compact-projection-contracts.md new file mode 100644 index 00000000..d20f3231 --- /dev/null +++ b/docs/compact-projection-contracts.md @@ -0,0 +1,36 @@ +# Shared projection material in application manifests + +Several exact event selectors can apply the same mutation. This is common when +retained state events and current events update the same read model. The +`projection!` authoring form already accepts multiple events per mutation; +application manifests now share identical operation lists and selector body +schemas when that reduces their encoded size. No authoring change is required. + +The modeled projection's `program` value uses the explicit +`shared_projection_program_v1` encoding when smaller than the expanded form. +Its `program` retains all original fields and every exact arm, replacing only +`operations` with `operations_ref` and selector `body_schema` with +`body_schema_ref`. These zero-based references address the wrapper's canonical +`operation_sets` and `body_schemas` tables. Operations are interned by their +complete canonical JSON, including ordering, IDs, expressions and effects; +schemas are interned by exact string equality. No historical selector is +removed, merged, treated as current, or exempted from replay validation. + +The runtime projection IR, program and binding IDs, server execution and client +projection-program exports are unchanged. The application Surface's artifact +fingerprints change deterministically when shared storage is used. Rebuild the +service and generated clients together. Existing expanded manifests remain accepted +and retain their existing encoding and fingerprints when decoded and re-encoded. + +Tools reading the opaque modeled `program` JSON can use +`distributed::application::expand_projection_program_contract(&value)` to read +either representation. Expansion returns exactly the original program JSON. +Unsupported encodings, invalid references, inline/reference ambiguity, duplicate +or unused table entries, and noncanonical table order fail validation. + +The complete encoded application manifest remains bounded at 4 MiB. Each opaque +modeled value remains bounded at 1 MiB; an expanded program also must fit the +existing 1 MiB budget. The decoder checks repeated byte costs before copying +shared values and validates programs individually, without retaining an expanded +copy of the whole application. Sharing is storage normalization, not an increase +in wire limits or a replacement for retained-history replay. diff --git a/docs/unsigned-command-inputs.md b/docs/unsigned-command-inputs.md index ae183f9a..1766768c 100644 --- a/docs/unsigned-command-inputs.md +++ b/docs/unsigned-command-inputs.md @@ -53,3 +53,8 @@ Code that manually constructs `CommandTypeField` or `SurfaceTypeField` must set `unsigned_integer` to the matching `CommandUnsignedInteger` variant, or `None` for unrefined fields. Prefer derives so this metadata follows the Rust type. A refinement on a non-`BigInt` field is rejected. + +Typed application manifests preserve `unsigned_integer` in canonical Surface +command input and output contracts, including nested types. Decoding and +re-encoding retains the same contract bytes and fingerprints. Unrefined fields +omit this metadata, preserving their existing canonical representation. diff --git a/src/application/manifest.rs b/src/application/manifest.rs index 51b93121..5e6e14c8 100644 --- a/src/application/manifest.rs +++ b/src/application/manifest.rs @@ -879,6 +879,11 @@ fn validate_projection( let fields = modeled.as_object().ok_or_else(|| { ApplicationError::InvalidSpec("modeled projection must be an object".into()) })?; + if let Some(program) = fields.get("program") { + // Validate each shared value independently without retaining the + // expanded copies in the manifest or increasing the wire budget. + super::expand_projection_program_contract(program)?; + } let program_id = fields .get("program_id") .and_then(serde_json::Value::as_str) @@ -1852,14 +1857,18 @@ fn surface_command_type_value( serde_json::json!({ "name": definition.name, "fields": definition.fields.iter().map(|field| { - serde_json::json!({ + let mut value = serde_json::json!({ "name": field.name, "type_name": field.type_name, "nullable": field.nullable, "list": field.list, "item_nullable": field.item_nullable, "nested": field.nested.as_deref().map(|nested| surface_command_type_value(Some(nested))), - }) + }); + if let Some(unsigned) = field.unsigned_integer { + value["unsigned_integer"] = serde_json::json!(unsigned); + } + value }).collect::>(), }) } diff --git a/src/application/mod.rs b/src/application/mod.rs index 5e3be21d..d34c10a0 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -14,6 +14,7 @@ mod manifest; mod module; mod mount; mod plan; +mod projection_contract; mod registration; mod runtime; mod runtime_host; @@ -50,6 +51,8 @@ pub use plan::{ compile_deployment_plan, DeploymentPlan, PlanFingerprint, ProcessIntent, ProcessPlan, DEPLOYMENT_PLAN_SCHEMA_VERSION, MAX_DEPLOYMENT_PLAN_BYTES, }; +pub(crate) use projection_contract::compact_projection_program_contract; +pub use projection_contract::expand_projection_program_contract; pub use registration::{Application, ApplicationBuilder, ContractCompiler}; pub use runtime::{Runtime, RuntimeDialect}; pub use runtime_host::{bind_single_process, CapabilityProviders, RuntimeHost}; diff --git a/src/application/projection_contract.rs b/src/application/projection_contract.rs new file mode 100644 index 00000000..9e95fc4d --- /dev/null +++ b/src/application/projection_contract.rs @@ -0,0 +1,383 @@ +//! Lossless sharing in application artifacts, independent of runtime program IR. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +use super::{ApplicationError, ApplicationResult, MAX_MANIFEST_JSON_BYTES}; + +const ENCODING: &str = "shared_projection_program_v1"; + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SharedProgram { + encoding: String, + program: Value, + operation_sets: Vec, + body_schemas: Vec, +} + +fn invalid(reason: &str) -> ApplicationError { + ApplicationError::InvalidSpec(format!("shared projection program: {reason}")) +} + +fn json_len(value: &impl Serialize) -> ApplicationResult { + // Stop counting before allocating an oversized encoding or expanding refs. + struct Counter(usize); + impl std::io::Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(bytes.len()); + if self.0 > MAX_MANIFEST_JSON_BYTES { + return Err(std::io::Error::other( + "projection program exceeds JSON byte budget", + )); + } + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut counter = Counter(0); + serde_json::to_writer(&mut counter, value).map_err(|error| invalid(&error.to_string()))?; + Ok(counter.0) +} + +pub(crate) fn compact_projection_program_contract(value: Value) -> ApplicationResult { + let expanded_len = json_len(&value)?; + let mut program = value.clone(); + let arms = program + .get_mut("arms") + .and_then(Value::as_array_mut) + .ok_or_else(|| invalid("missing arms"))?; + let mut operations = BTreeMap::new(); + let mut schemas = BTreeMap::new(); + for arm in arms.iter() { + let operation = arm + .get("operations") + .filter(|value| value.is_array()) + .ok_or_else(|| invalid("missing operations"))?; + operations.insert( + serde_json::to_string(&super::canonical_json(operation))?, + operation.clone(), + ); + let schema = arm + .pointer("/selector/body_schema") + .and_then(Value::as_str) + .ok_or_else(|| invalid("missing selector body schema"))?; + schemas.insert(schema.to_owned(), ()); + } + let operation_sets: Vec<_> = operations.values().cloned().collect(); + let operation_indexes: BTreeMap<_, _> = operations + .keys() + .enumerate() + .map(|(i, key)| (key.clone(), i)) + .collect(); + let body_schemas: Vec<_> = schemas.into_keys().collect(); + for arm in arms { + let fields = arm + .as_object_mut() + .ok_or_else(|| invalid("arm must be an object"))?; + let operation = fields.remove("operations").unwrap(); + let key = serde_json::to_string(&super::canonical_json(&operation))?; + fields.insert( + "operations_ref".into(), + Value::from(operation_indexes[&key]), + ); + let selector = fields + .get_mut("selector") + .and_then(Value::as_object_mut) + .ok_or_else(|| invalid("selector must be an object"))?; + let schema = selector.remove("body_schema").unwrap(); + let index = body_schemas + .binary_search(&schema.as_str().unwrap().to_owned()) + .unwrap(); + selector.insert("body_schema_ref".into(), Value::from(index)); + } + let shared = serde_json::to_value(SharedProgram { + encoding: ENCODING.into(), + program, + operation_sets, + body_schemas, + })?; + // Single-arm/small programs keep their exact historical representation. + if json_len(&shared).is_ok_and(|len| len < expanded_len) { + Ok(shared) + } else { + Ok(value) + } +} + +/// Expand a modeled application-manifest program into its original program JSON. +/// +/// Accepts existing expanded values and `shared_projection_program_v1`. Sharing +/// changes artifact storage only: selectors, arm IDs, operations, runtime IR and +/// program digests are unchanged. References and table order are canonical, and +/// expansion is bounded by the existing 1 MiB opaque-contract budget before any +/// repeated operation list or schema is cloned. +pub fn expand_projection_program_contract(value: &Value) -> ApplicationResult { + json_len(value)?; + if value.get("encoding").is_none() { + return Ok(value.clone()); + } + let shared: SharedProgram = + serde_json::from_value(value.clone()).map_err(|error| invalid(&error.to_string()))?; + if shared.encoding != ENCODING { + return Err(invalid("unsupported encoding")); + } + if shared.operation_sets.iter().any(|value| !value.is_array()) { + return Err(invalid("operation sets must be arrays")); + } + let arms = shared + .program + .get("arms") + .and_then(Value::as_array) + .ok_or_else(|| invalid("missing arms"))?; + let mut expanded_len = json_len(&shared.program)? as i128; + let mut refs = Vec::with_capacity(arms.len()); + for arm in arms { + let operation_ref = arm + .get("operations_ref") + .and_then(Value::as_u64) + .and_then(|index| usize::try_from(index).ok()) + .ok_or_else(|| invalid("invalid operations reference"))?; + let schema_ref = arm + .pointer("/selector/body_schema_ref") + .and_then(Value::as_u64) + .and_then(|index| usize::try_from(index).ok()) + .ok_or_else(|| invalid("invalid body schema reference"))?; + let operation = shared + .operation_sets + .get(operation_ref) + .ok_or_else(|| invalid("operations reference out of bounds"))?; + let schema = shared + .body_schemas + .get(schema_ref) + .ok_or_else(|| invalid("body schema reference out of bounds"))?; + if arm.get("operations").is_some() || arm.pointer("/selector/body_schema").is_some() { + return Err(invalid("reference and inline material cannot coexist")); + } + // Replacing each `*_ref` key removes four key bytes and its index. + expanded_len += json_len(operation)? as i128 + json_len(schema)? as i128 + - json_len(&operation_ref)? as i128 + - json_len(&schema_ref)? as i128 + - 8; + if expanded_len > MAX_MANIFEST_JSON_BYTES as i128 { + return Err(invalid("expanded program exceeds JSON byte budget")); + } + refs.push((operation_ref, schema_ref)); + } + let mut expanded = shared.program; + for (arm, (operation_ref, schema_ref)) in expanded["arms"] + .as_array_mut() + .unwrap() + .iter_mut() + .zip(refs) + { + let fields = arm + .as_object_mut() + .ok_or_else(|| invalid("arm must be an object"))?; + fields.remove("operations_ref"); + fields.insert( + "operations".into(), + shared.operation_sets[operation_ref].clone(), + ); + let selector = fields + .get_mut("selector") + .and_then(Value::as_object_mut) + .ok_or_else(|| invalid("selector must be an object"))?; + selector.remove("body_schema_ref"); + selector.insert( + "body_schema".into(), + Value::from(shared.body_schemas[schema_ref].clone()), + ); + } + if compact_projection_program_contract(expanded.clone())? != *value { + return Err(invalid("noncanonical tables or references")); + } + Ok(expanded) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn history_program() -> Value { + let operations = json!([{ + "operation_id": "save", "staging_ordinal": 0, "kind": "upsert", + "model": "Repository", "storage": "repositories", + "fields": (0..40).map(|index| json!({ + "name": format!("field_{index}"), + "expression": {"kind": "body_path", "value_type": "string", "path": [format!("field_{index}")]} + })).collect::>() + }]); + json!({ + "ir_version": 1, "name": "repository", "version": 2, + "source_snapshots": true, + "arms": (0..32).map(|index| json!({ + "arm_id": format!("fact-{}", index % 16), + "selector": { + "occurrence_version": 1, "event_name": format!("repository.fact_{}", index % 16), + "event_version": index / 16 + 1, "body_kind": "state", + "body_type_name": "RepositoryState", "body_version": index / 16 + 1, + "body_schema": format!("state:v{}:{}", index / 16 + 1, "fields;".repeat(100)), + "body_fingerprint": format!("sha256:{:064x}", index / 16 + 1), + "body_codec": "json", "body_codec_version": 1, + }, + "operations": operations, + })).collect::>() + }) + } + + #[test] + fn shared_program_round_trip_preserves_all_current_and_retained_selectors() { + let original = history_program(); + let compact = compact_projection_program_contract(original.clone()).unwrap(); + assert_eq!(compact["encoding"], ENCODING); + assert_eq!(compact["operation_sets"].as_array().unwrap().len(), 1); + assert_eq!(compact["body_schemas"].as_array().unwrap().len(), 2); + assert_eq!(compact["program"]["arms"].as_array().unwrap().len(), 32); + assert!(json_len(&compact).unwrap() * 4 < json_len(&original).unwrap()); + assert_eq!( + expand_projection_program_contract(&compact).unwrap(), + original + ); + assert_eq!( + compact_projection_program_contract(original.clone()).unwrap(), + compact + ); + assert_eq!( + expand_projection_program_contract(&original).unwrap(), + original + ); + } + + #[test] + fn single_arm_keeps_existing_expanded_encoding() { + let mut original = history_program(); + original["arms"].as_array_mut().unwrap().truncate(1); + assert_eq!( + compact_projection_program_contract(original.clone()).unwrap(), + original + ); + } + + #[test] + fn shared_programs_fit_manifest_cap_and_legacy_artifacts_still_round_trip() { + use crate::application::{Application, ApplicationManifest, Module, ProjectionSpec}; + + let original = history_program(); + let compact = compact_projection_program_contract(original.clone()).unwrap(); + let module = |index: usize, program: &Value| { + let id = format!("history-{index:02}"); + let mut projection = + ProjectionSpec::try_new(&id, Vec::::new(), Vec::::new()).unwrap(); + projection.modeled_programs = vec!["program-history".into()]; + projection.modeled = vec![ + json!({"program_id": "program-history", "output_models": [], "program": program}), + ]; + // Recompute the ordinary ProjectionSpec fingerprint after setting + // its opaque behavior material, without altering that material. + let projection = projection.with_direct(false).unwrap(); + Module::new(id).projection(projection).build().unwrap() + }; + let expanded_modules = (0..32) + .map(|index| module(index, &original)) + .collect::>(); + let error = Application::try_new("history-app", expanded_modules, []).unwrap_err(); + assert!(error + .to_string() + .contains("application manifest exceeds 4194304 bytes")); + let modules = (0..32) + .map(|index| module(index, &compact)) + .collect::>(); + let application = Application::try_new("history-app", modules.clone(), []).unwrap(); + let bytes = application.manifest().canonical_bytes().unwrap(); + assert!(bytes.len() < crate::application::MAX_APPLICATION_MANIFEST_BYTES); + assert_eq!( + ApplicationManifest::from_canonical_bytes(&bytes).unwrap(), + *application.manifest() + ); + assert_eq!( + Application::try_new("history-app", modules.into_iter().rev(), []) + .unwrap() + .manifest() + .canonical_bytes() + .unwrap(), + bytes + ); + for projection in &application.manifest().projections { + assert_eq!( + expand_projection_program_contract(&projection.modeled[0]["program"]).unwrap(), + original + ); + } + + let legacy = Application::try_new("legacy", [module(0, &original)], []).unwrap(); + let legacy_bytes = legacy.manifest().canonical_bytes().unwrap(); + assert_eq!( + ApplicationManifest::from_canonical_bytes(&legacy_bytes) + .unwrap() + .canonical_bytes() + .unwrap(), + legacy_bytes + ); + } + + #[test] + fn shared_program_rejects_invalid_ambiguous_and_noncanonical_references() { + let compact = compact_projection_program_contract(history_program()).unwrap(); + let mut variants = Vec::new(); + for bad_ref in [ + json!(-1), + json!(0.5), + json!("0"), + json!(null), + json!(999999), + ] { + let mut invalid = compact.clone(); + invalid["program"]["arms"][0]["operations_ref"] = bad_ref; + variants.push(invalid); + } + let mut invalid = compact.clone(); + invalid["program"]["arms"][0]["selector"]["body_schema_ref"] = json!(999); + variants.push(invalid); + let mut invalid = compact.clone(); + invalid["program"]["arms"][0]["operations"] = json!([]); + variants.push(invalid); + let mut invalid = compact.clone(); + invalid["operation_sets"] + .as_array_mut() + .unwrap() + .push(json!([])); + variants.push(invalid); + let mut invalid = compact.clone(); + invalid["body_schemas"].as_array_mut().unwrap().reverse(); + for arm in invalid["program"]["arms"].as_array_mut().unwrap() { + arm["selector"]["body_schema_ref"] = + json!(1 - arm["selector"]["body_schema_ref"].as_u64().unwrap()); + } + variants.push(invalid); + let mut invalid = compact.clone(); + invalid["encoding"] = json!("future_encoding"); + variants.push(invalid); + let mut invalid = compact; + invalid["extra"] = json!(true); + variants.push(invalid); + for invalid in variants { + assert!(expand_projection_program_contract(&invalid).is_err()); + } + } + + #[test] + fn shared_program_rejects_expansion_beyond_existing_opaque_budget() { + let mut compact = compact_projection_program_contract(history_program()).unwrap(); + compact["operation_sets"][0] = json!([{"fields": "x".repeat(40_000)}]); + assert!(json_len(&compact).unwrap() < MAX_MANIFEST_JSON_BYTES); + assert!(expand_projection_program_contract(&compact) + .unwrap_err() + .to_string() + .contains("expanded program exceeds")); + } +} diff --git a/src/graphql/surface/projections.rs b/src/graphql/surface/projections.rs index 0e2e9657..253b38e9 100644 --- a/src/graphql/surface/projections.rs +++ b/src/graphql/surface/projections.rs @@ -124,6 +124,8 @@ impl SurfaceModeledProjection { } else { return Err("modeled projection has no canonical program material".to_owned()); }; + let program = crate::application::compact_projection_program_contract(program) + .map_err(|error| error.to_string())?; let binding = self.raw_binding.as_ref().map(|binding| { serde_json::json!({ "identity_version": binding.identity_version(), diff --git a/tests/application_composition.rs b/tests/application_composition.rs index d19bbd42..70e3c40d 100644 --- a/tests/application_composition.rs +++ b/tests/application_composition.rs @@ -143,6 +143,164 @@ fn full_surface() -> Surface { .expect("non-empty Surface should compile") } +#[allow(dead_code)] +#[derive(Clone, Deserialize, CommandInput)] +struct UnsignedContractInput { + small: u8, + optional: Option, + items: Vec>, + revision: u64, + signed: i64, + title: String, +} + +#[derive(Clone, Serialize, CommandOutput)] +struct UnsignedContractOutput { + small: u8, + optional: Option, + items: Vec>, + revision: u64, + signed: i64, + title: String, +} + +#[allow(dead_code)] +#[derive(Clone, Deserialize, CommandInput)] +struct UnsignedContractInputEnvelope { + nested: UnsignedContractInput, +} + +#[derive(Clone, Serialize, CommandOutput)] +struct UnsignedContractOutputEnvelope { + nested: UnsignedContractOutput, +} + +#[derive(Default)] +struct UnsignedContractAggregate { + entity: distributed::Entity, +} + +impl distributed::Aggregate for UnsignedContractAggregate { + type ReplayError = String; + + fn aggregate_type() -> &'static str { + "unsigned-contract" + } + fn entity(&self) -> &distributed::Entity { + &self.entity + } + fn entity_mut(&mut self) -> &mut distributed::Entity { + &mut self.entity + } + fn replay_event(&mut self, _: &distributed::EventRecord) -> Result<(), String> { + Ok(()) + } +} + +#[test] +fn unsigned_surface_command_contract_round_trips() { + use distributed::microsvc::{CausalCommandContext, Routes, Service}; + use distributed::{AggregateRepository, InMemoryRepository}; + + let routes = Routes::new().with_repo(AggregateRepository::<_, UnsignedContractAggregate>::new( + InMemoryRepository::new(), + )); + let service = Service::new().named("unsigned-app").routes( + routes + .typed_command(typed_command::< + UnsignedContractInput, + Succeeded, + >("unsigned.flat")) + .handle( + |_: &CausalCommandContext<'_, UnsignedContractAggregate>, + _: UnsignedContractInput| async { + unreachable!("manifest compilation must not execute handlers") + }, + ) + .typed_command(typed_command::< + UnsignedContractInputEnvelope, + Succeeded, + >("unsigned.nested")) + .handle( + |_: &CausalCommandContext<'_, UnsignedContractAggregate>, + _: UnsignedContractInputEnvelope| async { + unreachable!("manifest compilation must not execute handlers") + }, + ), + ); + let surface = full_surface().with_service(&service).unwrap(); + let spec = SurfaceSpec::from_surface("web", &surface).unwrap(); + for command in spec.contract["commands"].as_array().unwrap() { + for direction in ["input", "output"] { + let mut fields = command[direction]["fields"].as_array().unwrap(); + if fields[0]["name"] == "nested" { + assert!(fields[0].get("unsigned_integer").is_none()); + fields = fields[0]["nested"]["fields"].as_array().unwrap(); + } + for (name, width) in [ + ("small", "u8"), + ("optional", "u16"), + ("items", "u32"), + ("revision", "u64"), + ] { + let field = fields.iter().find(|field| field["name"] == name).unwrap(); + assert_eq!(field["unsigned_integer"], width); + assert_eq!(field["type_name"], "BigInt"); + } + for name in ["signed", "title"] { + let field = fields.iter().find(|field| field["name"] == name).unwrap(); + assert!(field.get("unsigned_integer").is_none()); + } + } + } + let application = service.application("unsigned-app", spec).unwrap(); + let manifest = application.manifest(); + let bytes = manifest.canonical_bytes().unwrap(); + let decoded = ApplicationManifest::from_canonical_bytes(&bytes).unwrap(); + assert_eq!(&decoded, manifest); + assert_eq!(decoded.canonical_bytes().unwrap(), bytes); + + let mut tampered = manifest.clone(); + tampered.surfaces[0].contract["commands"][0]["input"]["fields"][0]["unsigned_integer"] = + serde_json::json!("u64"); + tampered.surfaces[0].fingerprint = distributed::application::sha256_fingerprint( + &tampered.surfaces[0].canonical_bytes().unwrap(), + ); + assert!(tampered + .refresh_fingerprints() + .unwrap_err() + .to_string() + .contains("surface contract material")); +} + +#[test] +fn ordinary_surface_command_contract_keeps_unrefined_field_shape() { + let module = command_module(); + let surface = full_surface().with_module(&module).unwrap(); + let spec = SurfaceSpec::from_surface("web", &surface).unwrap(); + for command in spec.contract["commands"].as_array().unwrap() { + for (direction, name) in [("input", "title"), ("output", "id")] { + assert_eq!( + command[direction]["fields"], + serde_json::json!([{ + "name": name, "type_name": "String", "nullable": false, + "list": false, "item_nullable": false, "nested": null, + }]) + ); + } + } + let application = Application::new("ordinary-app") + .module(module) + .surface(spec) + .build() + .unwrap(); + let bytes = application.manifest().canonical_bytes().unwrap(); + assert_eq!( + ApplicationManifest::from_canonical_bytes(&bytes).unwrap(), + *application.manifest() + ); +} + #[test] fn generated_surface_above_opaque_json_budget_round_trips() { let tables = (0..500) diff --git a/tests/e2e-ui/ui/src/auth.ts b/tests/e2e-ui/ui/src/auth.ts index 49e888ad..160b086b 100644 --- a/tests/e2e-ui/ui/src/auth.ts +++ b/tests/e2e-ui/ui/src/auth.ts @@ -489,6 +489,18 @@ export const handle: Handle = ({ event, resolve }) => { } return setCookie(name, value, options); }; - return authHandle({ event, resolve }); + return authHandle({ + event, + resolve: (event) => { + const auth = event.locals.auth; + // Auth.js reads the original request cookie each time. Share the promise + // so hooks and loaders cannot refresh it into different credentials. + // Keep null sessions and failures cached for this request as well. + let session: ReturnType | undefined; + event.locals.auth = () => (session ??= Promise.resolve().then(() => auth())); + event.locals.getSession = event.locals.auth; + return resolve(event); + } + }); }; export { signIn, signOut }; diff --git a/tests/gateway-auth/run.mjs b/tests/gateway-auth/run.mjs index ed4e211f..1d0199c8 100644 --- a/tests/gateway-auth/run.mjs +++ b/tests/gateway-auth/run.mjs @@ -52,6 +52,14 @@ export async function exerciseAuth(fixture) { const context = await browser.newContext(); const page = await context.newPage(); assert.equal((await context.request.get(`${publicOrigin}/private`)).status(), 401); + const readSessions = async () => { + const response = await context.request.get(`${publicOrigin}/api/auth/session-reads`); + assert.equal(response.status(), 200); + const result = await response.json(); + assert.equal(result.sameSession, true, 'all request consumers must share one session'); + return result; + }; + assert.deepEqual(await readSessions(), { sameSession: true, authenticated: false, error: null, tokenDigest: null }); await page.goto(publicOrigin); await page.getByRole('link', { name: 'Log in' }).click(); await page.getByRole('button', { name: 'Continue as Alice' }).click(); @@ -66,6 +74,24 @@ export async function exerciseAuth(fixture) { // The real provider issues a 61-second access token. Cross the app's existing // 60-second refresh skew; no fake auth clock or forged session is involved. await new Promise(resolve => setTimeout(resolve, 2200)); + const refreshesBeforeReads = idp.refreshes(); + const firstReads = await readSessions(); + assert.equal(firstReads.authenticated, true); + assert.equal(idp.refreshes(), refreshesBeforeReads + 1, 'concurrent and sequential reads must refresh only once'); + // A later request must use its renewed cookie; after expiry it must refresh + // again. This also rejects a cache accidentally shared across requests. + await new Promise(resolve => setTimeout(resolve, 2200)); + const laterReads = await readSessions(); + assert.equal(laterReads.authenticated, true); + assert.notEqual(laterReads.tokenDigest, firstReads.tokenDigest); + assert.equal(idp.refreshes(), refreshesBeforeReads + 2); + const anonymous = await browser.newContext(); + try { + const response = await anonymous.request.get(`${publicOrigin}/api/auth/session-reads`); + assert.equal((await response.json()).authenticated, false, 'another browser must not inherit the session'); + } finally { await anonymous.close(); } + console.log('PASS request-local session reads share one refresh and later requests remain independent'); + await new Promise(resolve => setTimeout(resolve, 2200)); const refreshed = await context.request.post(`${publicOrigin}/api/auth/refresh`, { headers: { origin: publicOrigin } }); assert.equal(refreshed.status(), 200, JSON.stringify({ url: refreshed.url(), body: await refreshed.text(), cookies: (await context.cookies()).map(({name, expires, path}) => ({name, expires, path})), privateStatus: (await context.request.get(`${publicOrigin}/private`)).status() })); const refreshBody = await refreshed.json(); @@ -79,6 +105,9 @@ export async function exerciseAuth(fixture) { assert.equal((await context.request.get(`${publicOrigin}/private`)).status(), 200); idp.failRefresh(); await new Promise(resolve => setTimeout(resolve, 2200)); + const failedReads = await readSessions(); + assert.equal(failedReads.authenticated, false); + assert.equal(failedReads.error, 'RefreshAccessTokenError'); const failedRefresh = await context.request.post(`${publicOrigin}/api/auth/refresh`, { headers: { origin: publicOrigin } }); assert.equal(failedRefresh.status(), 401); assert.equal((await failedRefresh.json()).error, 'RefreshAccessTokenError'); diff --git a/tests/gateway-auth/src/routes/api/auth/session-reads/+server.ts b/tests/gateway-auth/src/routes/api/auth/session-reads/+server.ts new file mode 100644 index 00000000..65989b2c --- /dev/null +++ b/tests/gateway-auth/src/routes/api/auth/session-reads/+server.ts @@ -0,0 +1,17 @@ +import { createHash } from 'node:crypto'; +import { json } from '@sveltejs/kit'; +import { isCurrentSession } from '$lib/server/require-auth'; + +// Exercise concurrent first reads and later consumers against the real Auth.js +// request cookie, including the compatibility alias. Never expose credentials. +export async function GET({ locals }) { + const sessions = await Promise.all([locals.auth(), locals.auth(), locals.getSession()]); + sessions.push(await locals.auth(), await locals.getSession()); + const session = sessions[0]; + return json({ + sameSession: sessions.every(value => value === session), + authenticated: isCurrentSession(session), + error: session?.error ?? null, + tokenDigest: session?.accessToken ? createHash('sha256').update(session.accessToken).digest('hex') : null + }); +}