20 Commits
Author SHA1 Message Date
Hare 5b12c3e085 fix: make mobile sidebar swipes track touch gestures 2026-10-27 06:49:28 +09:00
Hare 623c31615d fix: assign unique event refs to merge integration comments 2026-10-27 06:15:48 +09:00
Hare af719e387d fix: align reviewer guidance with Bash-enabled scope 2026-10-27 06:15:48 +09:00
Hare d12bc2ea0b fix: deduplicate live and committed task updates 2026-10-26 10:13:36 +09:00
Hare a1df3742b1 fix: integrate passkey results with current dogfooding shell
Preserve authenticated home/account navigation and guarded mobile sidebar swipes. Keep both browser task selections. Complete the new account fixture contract and synchronize lifecycle probes on visible registered-key results, without changing their success or security assertions.
2026-10-26 09:50:13 +09:00
Hare d8818b776b feat: open and close mobile sidebar with guarded swipes 2026-10-26 09:39:35 +09:00
Hare 40c36ccb1d fix: restore authenticated home and account navigation 2026-10-26 09:39:35 +09:00
Hare 80c151d084 fix: fence recovered passkey results against cross-tab changes 2026-10-26 07:52:06 +09:00
Hare c6a8759e7b fix: fence activation and setup against delayed cross-tab events
Capture the shared authentication stamp as well as generation before dispatch and across response/confirmation/application awaits. Do not let stale setup failure reconciliation overwrite a newer tab's stamp. Add no-event-yet logout and account-change regressions for activation and setup, including prompt, pre-complete and post-dispatch confirmation boundaries.

Addresses MR AC4_CROSS_TAB_ACTIVATION. Validation: activation21/setup40 narrow tests; Web720 unit+625 component, check0/0/build, activation and hosting browser suites; root check/fmt/diff; both explicit real Backend browser suites including native CTAP2. Existing Server exclusions and 15 identical baseline naming findings remain unchanged. No verifier, binding, migration or rendering contract weakened.
2026-10-26 07:34:04 +09:00
Hare f17b5212c7 fix(auth): confirm passkey operations and preserve safe legacy migration
Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions.

Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
2026-10-26 07:18:23 +09:00
Hare b284934e34 test: add captured Codex worker restore fixture 2026-10-24 12:57:28 +09:00
Hare 2d33321fab fix: validate live fixture snapshots and shutdown boundaries 2026-10-24 12:57:28 +09:00
Hare 0975471986 fix: preserve diagnostic context in backend test assertions 2026-10-24 12:51:28 +09:00
Hare 214d6e21dd fix: assert classified workdir session closure in tests 2026-10-24 12:40:37 +09:00
Hare fca5c23345 fix: reject unrestorable and opaque fixture records 2026-10-24 12:39:22 +09:00
Hare f9b1500ba2 fix: align panel tests with current ticket case states 2026-10-24 12:33:31 +09:00
Hare 931bf3b27d test: add isolated saved worker restore scenarios 2026-10-24 12:33:31 +09:00
Hare 6bb671be21 test: add typed stopped worker fixture transport 2026-10-24 12:19:43 +09:00
Hare b8f2fa479c test: add isolated backend fixture seeding 2026-10-24 12:15:10 +09:00
Hare 1639be9841 fix: allow reviewers to read related tickets 2026-10-23 03:22:45 +09:00
115 changed files with 17325 additions and 682 deletions
Generated
+6
View File
@@ -6707,15 +6707,21 @@ name = "yoi-e2e"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"config-source",
"libc", "libc",
"manifest",
"protocol",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"server-api", "server-api",
"session-store",
"sha2 0.11.0", "sha2 0.11.0",
"tempfile", "tempfile",
"tungstenite 0.29.0",
"webauthn-authenticator-rs", "webauthn-authenticator-rs",
"webauthn-rs-proto", "webauthn-rs-proto",
"worker-runtime",
"yoi-workspace-server", "yoi-workspace-server",
] ]
+17 -1
View File
@@ -1747,7 +1747,23 @@ fn ticket_event(
) -> Result<(), MergeRequestError> { ) -> Result<(), MergeRequestError> {
let ticket = &mr.ticket_ids[0]; let ticket = &mr.ticket_ids[0];
let n:i64=t.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,ticket],|r|r.get(0))?; let n:i64=t.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,ticket],|r|r.get(0))?;
t.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6)",params![mr.workspace_id,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",mr.merge_request_id,e.approved_source_ref)])?; t.execute(
"INSERT INTO typed_ticket_events(
workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body,event_ref
) VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6,?7)",
params![
mr.workspace_id,
ticket,
n,
format!("worker:{}:{}", e.merged_by.runtime_id, e.merged_by.worker_id),
e.created_at.to_rfc3339(),
format!(
"Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",
mr.merge_request_id, e.approved_source_ref
),
format!("{ticket}:{n}"),
],
)?;
for (k, v) in [ for (k, v) in [
("implementation_assignment_id", a), ("implementation_assignment_id", a),
("merge_request_id", &mr.merge_request_id), ("merge_request_id", &mr.merge_request_id),
+116 -5
View File
@@ -53,8 +53,8 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let d = tempfile::tempdir().unwrap(); let d = tempfile::tempdir().unwrap();
let p = d.path().join("db"); let p = d.path().join("db");
let c = Connection::open(&p).unwrap(); let c = Connection::open(&p).unwrap();
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap(); c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,event_ref TEXT NOT NULL DEFAULT '',PRIMARY KEY(workspace_id,ticket_id,event_index),UNIQUE(workspace_id,ticket_id,event_ref));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('W','T',-1,'comment','Requirements')",[]).unwrap(); c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref) VALUES('W','T',-1,'comment','Requirements','T:-1')",[]).unwrap();
drop(c); drop(c);
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment { let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
assignment_id: "A".into(), assignment_id: "A".into(),
@@ -457,6 +457,18 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
.unwrap(); .unwrap();
assert_eq!(replayed, merged); assert_eq!(replayed, merged);
let connection = Connection::open(d.path().join("db")).unwrap(); let connection = Connection::open(d.path().join("db")).unwrap();
let ticket_refs = connection
.prepare("SELECT event_ref FROM typed_ticket_events WHERE event_index >= 0")
.unwrap()
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
ticket_refs,
vec!["T:0"],
"replay must not append another post"
);
let (state, active): (String, bool) = connection.query_row( let (state, active): (String, bool) = connection.query_row(
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", "SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
[], [],
@@ -477,6 +489,87 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
assert!(!json.contains(banned), "{banned} in {json}") assert!(!json.contains(banned), "{banned} in {json}")
} }
} }
#[test]
fn integration_post_failure_rolls_back_and_same_operation_recovers_with_legacy_empty_ref() {
let (dir, store) = fixture();
open(&store);
let review = approve(&store, "source", "approved");
request(&store, "source", "pending");
let completion = CompleteMergeRequest {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
operation_id: "recover-integration".into(),
approval_event_id: review.event_id,
current_subject_ref: "source".into(),
target_ref_before: "before".into(),
target_ref_after: "after".into(),
strategy: MergeStrategy::FastForward,
resolution: ConflictResolution::None,
auth: auth(),
now: at(5),
};
let connection = Connection::open(dir.path().join("db")).unwrap();
// Retain a comment written before integration populated event_ref.
connection.execute_batch(
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref)
VALUES('W','T',0,'comment','Legacy integration','');
CREATE TRIGGER reject_integration_post BEFORE INSERT ON typed_ticket_events
WHEN NEW.heading='Merge Request integrated'
BEGIN SELECT RAISE(ABORT,'injected integration post failure'); END;",
).unwrap();
let before = store.get_by_id("W", "MR").unwrap();
let error = store.complete(completion.clone()).unwrap_err();
assert!(
error
.to_string()
.contains("injected integration post failure")
);
let failed = store.get_by_id("W", "MR").unwrap();
assert_eq!(failed.state, MergeRequestState::Open);
assert_eq!(failed.thread, before.thread);
assert!(store.authorize_review_submission("MR", "pending").is_ok());
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM typed_ticket_event_attributes",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0
);
connection
.execute_batch("DROP TRIGGER reject_integration_post")
.unwrap();
let merged = store.complete(completion.clone()).unwrap();
assert_eq!(store.complete(completion).unwrap(), merged);
assert_eq!(
store.get_by_id("W", "MR").unwrap().state,
MergeRequestState::Merged
);
assert!(store.authorize_review_submission("MR", "pending").is_err());
let posts = connection
.prepare("SELECT event_ref,body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY event_index")
.unwrap()
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
posts.len(),
2,
"retry and replay must append exactly one post"
);
assert_eq!(posts[0], (String::new(), "Legacy integration".into()));
assert_eq!(posts[1].0, "T:1");
assert!(posts[1].1.contains("`MR`"));
assert_eq!(connection.query_row(
"SELECT value FROM typed_ticket_event_attributes WHERE event_index=1 AND key='operation_id'",
[], |row| row.get::<_, String>(0),
).unwrap(), "recover-integration");
}
#[test] #[test]
fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_returns() { fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_returns() {
let (_d, s) = fixture(); let (_d, s) = fixture();
@@ -955,7 +1048,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
let connection = Connection::open(dir.path().join("db")).unwrap(); let connection = Connection::open(dir.path().join("db")).unwrap();
connection connection
.execute_batch( .execute_batch(
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", "UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL,'T:1')",
) )
.unwrap(); .unwrap();
connection connection
@@ -1082,7 +1175,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
let connection = Connection::open(dir.path().join("db")).unwrap(); let connection = Connection::open(dir.path().join("db")).unwrap();
connection connection
.execute_batch( .execute_batch(
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)", "UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL,'T:1')",
) )
.unwrap(); .unwrap();
connection connection
@@ -1307,6 +1400,24 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
assert_eq!(second_request.merged_result().unwrap(), &second_merge); assert_eq!(second_request.merged_result().unwrap(), &second_merge);
assert_eq!(first_request.integration_approval().unwrap(), &first); assert_eq!(first_request.integration_approval().unwrap(), &first);
assert_eq!(second_request.integration_approval().unwrap(), &second); assert_eq!(second_request.integration_approval().unwrap(), &second);
let posts = connection
.prepare(
"SELECT event.event_ref, attribute.value
FROM typed_ticket_events event
JOIN typed_ticket_event_attributes attribute USING(workspace_id,ticket_id,event_index)
WHERE attribute.key='merge_request_id' ORDER BY event.event_index",
)
.unwrap()
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
posts,
vec![("T:0".into(), "MR".into()), ("T:1".into(), "MR-2".into())]
);
} }
#[test] #[test]
@@ -1324,7 +1435,7 @@ fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migrat
}"#; }"#;
connection.execute_batch( connection.execute_batch(
"UPDATE typed_tickets SET workflow_state='done' WHERE workspace_id='W' AND ticket_id='T'; "UPDATE typed_tickets SET workflow_state='done' WHERE workspace_id='W' AND ticket_id='T';
INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body');" INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body','T:0');"
).unwrap(); ).unwrap();
connection connection
.execute( .execute(
+112 -5
View File
@@ -225,6 +225,10 @@ impl_openapi_schema!(
PasskeyLoginOptionsResponse, PasskeyLoginOptionsResponse,
PasskeyLoginCompleteRequest, PasskeyLoginCompleteRequest,
PasskeyAdditionOptionsRequest, PasskeyAdditionOptionsRequest,
PasskeySummary,
PasskeyListResponse,
PasskeyAdditionResultRequest,
PasskeyAdditionResultResponse,
PasskeyRegistrationOptionsRequest, PasskeyRegistrationOptionsRequest,
PasskeyRegistrationOptionsResponse, PasskeyRegistrationOptionsResponse,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationCompleteRequest,
@@ -648,6 +652,33 @@ pub trait ServerApi {
#[body] request: PasskeyRegistrationCompleteRequest, #[body] request: PasskeyRegistrationCompleteRequest,
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>; ) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
#[get(
"/api/auth/passkeys",
status = 200,
error_status = 400,
additional_error_statuses = [403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_list(
&self,
#[extension] context: ServerRequestContext,
) -> Result<PasskeyListResponse, RepositoryApiError>;
#[post(
"/api/auth/passkeys/addition/result",
status = 200,
error_status = 400,
additional_error_statuses = [403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_addition_result(
&self,
#[extension] context: ServerRequestContext,
#[body] request: PasskeyAdditionResultRequest,
) -> Result<PasskeyAdditionResultResponse, RepositoryApiError>;
#[post( #[post(
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
status = 200, status = 200,
@@ -4115,6 +4146,8 @@ impl RequestActor {
pub struct WhoamiResponse { pub struct WhoamiResponse {
pub actor: Option<RequestActor>, pub actor: Option<RequestActor>,
pub session: BrowserSessionState, pub session: BrowserSessionState,
/// Non-secret comparison evidence, never an authentication credential.
pub authentication_reference: Option<String>,
} }
/// Browser navigation evidence, not an authorization grant or a generic 401 classification. /// Browser navigation evidence, not an authorization grant or a generic 401 classification.
@@ -4142,6 +4175,8 @@ pub struct AuthBootstrapUserRequest {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AuthUserResponse { pub struct AuthUserResponse {
pub user: AuthenticatedUser, pub user: AuthenticatedUser,
/// Comparison evidence for the exact newly issued session, not a credential.
pub authentication_reference: String,
} }
/// Starts new-account registration only; an existing handle is never interpreted as key addition. /// Starts new-account registration only; an existing handle is never interpreted as key addition.
@@ -4158,16 +4193,64 @@ pub struct PasskeyRegistrationOptionsRequest {
pub browser_origin: Option<String>, pub browser_origin: Option<String>,
} }
/// Adds a credential to the current authenticated actor; target identity is never public input. /// Adds to the current actor. Expected identity/binding fields only fence stale UI;
/// they never select an owner or authorize an addition.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct PasskeyAdditionOptionsRequest { pub struct PasskeyAdditionOptionsRequest {
pub expected_user_id: String,
pub expected_account_id: String,
pub expected_authentication_reference: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))] #[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub browser_origin: Option<String>, pub browser_origin: Option<String>,
} }
/// Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.
/// Normal-login verification is observed verifier success, not inferred discoverability.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeySummary {
pub key_reference: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub normal_login_verified_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyListResponse {
pub user: AuthenticatedUser,
pub keys: Vec<PasskeySummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyAdditionResultRequest {
pub challenge_id: String,
pub expected_user_id: String,
pub expected_account_id: String,
/// Original options authentication reference; the current same-owner session may differ.
pub expected_authentication_reference: String,
}
/// Unknown is not proof that registration failed: completion may still be in flight.
/// Saved proves the credential commit, not session issuance or current session health.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
pub enum PasskeyAdditionResultResponse {
Unknown {},
Saved {
user: AuthenticatedUser,
key: PasskeySummary,
},
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -11642,6 +11725,10 @@ pub fn auth_api_typescript() -> String {
AuthBootstrapUserRequest::decl(&config), AuthBootstrapUserRequest::decl(&config),
AuthUserResponse::decl(&config), AuthUserResponse::decl(&config),
PasskeyAdditionOptionsRequest::decl(&config), PasskeyAdditionOptionsRequest::decl(&config),
PasskeySummary::decl(&config),
PasskeyListResponse::decl(&config),
PasskeyAdditionResultRequest::decl(&config),
PasskeyAdditionResultResponse::decl(&config),
PasskeyRegistrationOptionsRequest::decl(&config), PasskeyRegistrationOptionsRequest::decl(&config),
PasskeyRegistrationOptionsResponse::decl(&config), PasskeyRegistrationOptionsResponse::decl(&config),
PasskeyRegistrationCompleteRequest::decl(&config), PasskeyRegistrationCompleteRequest::decl(&config),
@@ -14069,7 +14156,8 @@ mod tests {
"display_name": "Hare", "display_name": "Hare",
"auth_method": "browser_session" "auth_method": "browser_session"
}, },
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" } "session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" },
"authentication_reference": "synthetic-session-reference"
}); });
let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone()) let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
.expect("server whoami fixture should match shared DTO"); .expect("server whoami fixture should match shared DTO");
@@ -14091,7 +14179,8 @@ mod tests {
"account_id": "account-1", "account_id": "account-1",
"handle": "hare", "handle": "hare",
"display_name": "Hare" "display_name": "Hare"
} },
"authentication_reference": "synthetic-session-reference"
}); });
let decoded = serde_json::from_value::<AuthUserResponse>(auth_user.clone()) let decoded = serde_json::from_value::<AuthUserResponse>(auth_user.clone())
.expect("server auth-user fixture should match shared DTO"); .expect("server auth-user fixture should match shared DTO");
@@ -14264,7 +14353,7 @@ mod tests {
serde_json::json!({"kind":"expired"}), serde_json::json!({"kind":"expired"}),
serde_json::json!({"kind":"authenticated", "expires_at":null}), serde_json::json!({"kind":"authenticated", "expires_at":null}),
] { ] {
let value = serde_json::json!({"actor":null, "session":session}); let value = serde_json::json!({"actor":null, "session":session, "authentication_reference": null});
let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap(); let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), value); assert_eq!(serde_json::to_value(response).unwrap(), value);
} }
@@ -14802,10 +14891,28 @@ mod openapi_artifact_tests {
); );
} }
assert!( assert!(
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_ok() serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_err()
); );
} }
#[test]
fn passkey_addition_requires_expected_owner_and_binding() {
let expected = serde_json::json!({
"expected_user_id": "user-a", "expected_account_id": "account-a",
"expected_authentication_reference": "reference"
});
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(expected.clone()).is_ok());
for field in [
"expected_user_id",
"expected_account_id",
"expected_authentication_reference",
] {
let mut missing = expected.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(missing).is_err());
}
}
#[test] #[test]
fn auth_workspace_catalog_and_typed_header_contract_is_strict() { fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
+2
View File
@@ -19,6 +19,8 @@ required-features = ["ws-server", "fs-store"]
[features] [features]
default = ["ws-server", "fs-store"] default = ["ws-server", "fs-store"]
fs-store = ["dep:fs4"] fs-store = ["dep:fs4"]
# Offline fixture tooling only; never enabled by the product defaults.
test-support = ["fs-store", "ws-server"]
http-server = ["dep:axum", "dep:tower", "dep:reqwest"] http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"] ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
+31
View File
@@ -8,3 +8,34 @@ Manages Worker creation, stopping, and Restore, execution connections within the
- [`execution`] — execution backends and operation-specific results. - [`execution`] — execution backends and operation-specific results.
- [`working_directory`] — repository materialization, session resources, and safe deletion. - [`working_directory`] — repository materialization, session resources, and safe deletion.
- [`workspace_issuer`] — Workspace signature verification and replay prevention. - [`workspace_issuer`] — Workspace signature verification and replay prevention.
## Offline stopped-Worker fixtures
The nondefault `test-support` feature exposes `test_support::StoppedWorkerTemplate`.
It is intentionally limited to current-schema, stopped, attachment-free artificial
conversations with one Segment, text history, and no pending execution/activation.
It does not add a product endpoint or change create/restore authorization.
1. With the source Runtime offline, call `StoppedWorkerTemplate::extract(worker_dir)`.
The source must have its existing Runtime owner lock and identity. Extraction is
read-only; the returned serde template excludes source auth, saved Manifest,
request/config references, run directories, and Runtime operation history.
2. Initialize **new** Runtime identity and Workspace trust in a fresh destination
using normal setup. Preserve the template's Runtime and Workspace IDs.
3. Call `template.install(&options, request, &resolved_manifest)` before starting
the Runtime. Supply a fresh typed `CreateWorkerRequest` (including config/profile
references and trusted memory settings) and `manifest::WorkerManifest` resolved
for that environment. Its Worker name must equal `template.worker_name()`.
4. Start normally and use the ordinary explicit restore operation. Installation
itself creates no execution and leaves the Worker stopped with a fresh guard.
The template preserves the original Session manifest/JSONL bytes, including stable
entry IDs, timestamps, and settled submission receipts. It rejects resumable queues
and Feature invocation history. A saved system prompt is required for restore;
checkpoint receipts must match the Worker's typed receipt schema. Preserved records
must use canonical writer field shapes: unknown/duplicate fields (including nested
fields), discarded optional fields, and opaque Feature history payloads are rejected,
not stripped or rewritten. Portability/secret-marker checks do not replace
review of the supplied artificial conversation or its independently verified hashes.
The installer refuses any destination content other than initialized `auth.toml`;
interrupted publication is fail-closed, not an overwrite/recovery facility.
+4
View File
@@ -39,6 +39,10 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
#[cfg(feature = "test-support")]
#[path = "test_support.rs"]
pub mod test_support;
const SCHEMA_VERSION: u32 = 12; const SCHEMA_VERSION: u32 = 12;
// Frozen source schemas: do not move their meaning when adding a migration. // Frozen source schemas: do not move their meaning when adding a migration.
const SOURCE_SCHEMA_VERSION: u32 = 11; const SOURCE_SCHEMA_VERSION: u32 = 11;
+5 -1
View File
@@ -3206,9 +3206,13 @@ mod tests {
.await .await
.expect_err("close must be terminal"); .expect_err("close must be terminal");
assert!(matches!( assert!(matches!(
closed_error, closed_error.classification_source(),
workdir::WorkdirError::Unavailable(_) workdir::WorkdirError::Unavailable(_)
)); ));
assert_eq!(
closed_error.failure_reason(),
Some(workdir::WorkdirFailureReason::SessionClosed)
);
assert!( assert!(
state state
.workdir_sessions .workdir_sessions
+2
View File
@@ -32,6 +32,8 @@ pub mod workspace_issuer;
#[cfg(feature = "http-server")] #[cfg(feature = "http-server")]
pub mod workspace_request; pub mod workspace_request;
#[cfg(feature = "test-support")]
pub use fs_store::test_support;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
pub use management::RuntimeOptions; pub use management::RuntimeOptions;
+859
View File
@@ -0,0 +1,859 @@
//! Offline support for the approved, attachment-free stopped Worker fixture.
//!
//! Enabled only by `test-support`. This is not an import API: extraction accepts a
//! current-schema, settled, single-Segment aggregate; installation accepts only a
//! fresh Runtime directory containing its newly initialized `auth.toml`. No
//! Runtime or execution backend is constructed. Original Session bytes are never
//! rewritten. The caller must separately review/attest the artificial conversation;
//! structural checks and secret-marker checks cannot prove arbitrary prose safe.
use super::*;
use crate::catalog::ProfileSourceArchiveSource;
use crate::workspace_issuer::{
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, validate_workspace_issuer_trust_records,
};
use session_store::{
SegmentId, SessionId, WorkerActiveSegmentRef, WorkerMetadata, WorkerSessionStore,
};
#[cfg(test)]
#[path = "test_support_tests.rs"]
mod tests;
const TEMPLATE_VERSION: u32 = 1;
const MAX_SESSION_BYTES: usize = 8 * 1024 * 1024;
/// Portable fixture, intentionally without Debug (Session material is private).
/// Fields are private and every deserialized template is validated on install.
/// Serialize this value to retain the fixture; do not serialize its source aggregate.
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StoppedWorkerTemplate {
template_version: u32,
runtime_schema_version: u32,
runtime_id: String,
workspace_id: String,
worker_id: WorkerId,
created_at_ms: Option<u64>,
session_id: SessionId,
segment_id: SegmentId,
session_manifest: Vec<u8>,
segment_jsonl: Vec<u8>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SessionManifest {
schema_version: u32,
session_id: SessionId,
}
// Read-only projection: never retain source key material in a template.
#[derive(Deserialize)]
struct AuthIdentity {
identity_id: String,
}
#[derive(Deserialize)]
struct SourceAuth {
identity: AuthIdentity,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct InitializedAuth {
identity: crate::auth::RuntimeIdentityMaterial,
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
#[serde(default)]
archived_legacy_issuers: Vec<String>,
}
#[derive(Deserialize)]
struct GuardState {
active_request_id: Option<String>,
owners: BTreeMap<String, GuardOwner>,
}
#[derive(Deserialize)]
struct GuardOwner {
receipt: Option<serde::de::IgnoredAny>,
}
fn reject(message: &'static str) -> RuntimeError {
RuntimeError::InvalidRequest(format!("stopped Worker fixture: {message}"))
}
fn io<T>(result: std::io::Result<T>) -> Result<T, RuntimeError> {
result.map_err(|_| reject("filesystem operation failed"))
}
fn decode<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, RuntimeError> {
serde_json::from_slice(bytes).map_err(|_| reject("invalid typed document"))
}
fn no_symlink_components(path: &Path) -> Result<(), RuntimeError> {
let mut current = PathBuf::new();
for part in path.components() {
if matches!(part, std::path::Component::ParentDir) {
return Err(reject("parent traversal is unsupported"));
}
current.push(part);
let metadata = io(fs::symlink_metadata(&current))?;
if metadata.file_type().is_symlink() {
return Err(reject("symlinks are unsupported"));
}
}
Ok(())
}
fn check_tree(path: &Path, budget: &mut usize) -> Result<(), RuntimeError> {
if *budget == 0 {
return Err(reject("file count exceeds fixture limit"));
}
*budget -= 1;
let metadata = io(fs::symlink_metadata(path))?;
if metadata.is_dir() {
for entry in io(fs::read_dir(path))? {
check_tree(&io(entry)?.path(), budget)?;
}
} else if !metadata.is_file() {
return Err(reject("symlinks and special files are unsupported"));
}
Ok(())
}
fn regular_file(path: &Path) -> Result<(), RuntimeError> {
no_symlink_components(path)?;
if !io(fs::symlink_metadata(path))?.is_file() {
return Err(reject("expected a regular file"));
}
Ok(())
}
fn bytes(path: &Path) -> Result<Vec<u8>, RuntimeError> {
regular_file(path)?;
let mut options = OpenOptions::new();
options.read(true);
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOATIME | libc::O_NOFOLLOW);
}
let file = io(options.open(path))?;
let mut result = Vec::new();
io(file
.take(MAX_SESSION_BYTES as u64 + 1)
.read_to_end(&mut result))?;
if result.len() > MAX_SESSION_BYTES {
return Err(reject("file exceeds fixture limit"));
}
Ok(result)
}
fn text(path: &Path) -> Result<String, RuntimeError> {
String::from_utf8(bytes(path)?).map_err(|_| reject("invalid UTF-8 document"))
}
fn portable_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 256
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"-_".contains(&c))
}
impl StoppedWorkerTemplate {
pub fn runtime_id(&self) -> &str {
&self.runtime_id
}
pub fn workspace_id(&self) -> &str {
&self.workspace_id
}
pub fn worker_id(&self) -> WorkerId {
self.worker_id
}
pub fn worker_name(&self) -> String {
runtime_worker_name(self.worker_id)
}
pub fn session_id(&self) -> SessionId {
self.session_id
}
pub fn segment_id(&self) -> SegmentId {
self.segment_id
}
/// Extract without migration, rewriting metadata, or copying credentials,
/// resolved manifests, execution-operation receipts, or run directories.
/// The source Runtime must be offline; its existing owner lock is held for
/// the entire read. The path must be `<runtime>/workers/<WorkerId>`.
pub fn extract(worker_dir: &Path) -> Result<Self, RuntimeError> {
no_symlink_components(worker_dir)?;
let workers = worker_dir
.parent()
.ok_or_else(|| reject("invalid aggregate path"))?;
if workers.file_name().and_then(|s| s.to_str()) != Some(WORKERS_DIR) {
return Err(reject("invalid aggregate path"));
}
let root = workers
.parent()
.ok_or_else(|| reject("invalid Runtime path"))?;
// Require the existing lock, rather than creating a file in the source.
let lock_path = root.with_file_name(format!(
".{}.runtime-owner.lock",
root.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| reject("invalid Runtime path"))?
));
regular_file(&lock_path)?;
let lock = io(OpenOptions::new().read(true).open(lock_path))?;
if !io(FileExt::try_lock_shared(&lock))? {
return Err(reject("source Runtime is not offline"));
}
let _lock = RuntimeStoreOwnerLock { file: lock };
check_tree(worker_dir, &mut 256)?;
let snapshot: RuntimeSnapshot = decode(&bytes(&root.join(RUNTIME_FILE))?)?;
snapshot
.validate(&root.join(RUNTIME_FILE))
.map_err(|_| reject("unsupported Runtime schema"))?;
if !snapshot.subject_create_recovery_fences.is_empty()
|| !snapshot.subject_releases.is_empty()
{
return Err(reject("source has lifecycle operations"));
}
let record: WorkerAggregateRecord = decode(&bytes(&worker_dir.join(WORKER_FILE))?)?;
let record = record
.validate(&worker_dir.join(WORKER_FILE))
.map_err(|_| reject("invalid Worker schema or identity"))?;
let guard: GuardState = decode(
&serde_json::to_vec(&record.restore_guard)
.map_err(|_| reject("invalid restore guard"))?,
)?;
if record.status != WorkerStatus::Stopped
|| guard.active_request_id.is_some()
|| guard.owners.values().any(|owner| owner.receipt.is_none())
{
return Err(reject("Worker is not settled and stopped"));
}
let PersistedWorkerExecutionState::Available(execution) = &record.execution_state else {
return Err(reject("pending or unavailable execution metadata"));
};
// A stopped aggregate may retain the empty historical binding marker.
// It is deliberately not copied to the installed Worker.
if execution.restore_intent != WorkerRestoreIntent::Explicit {
return Err(reject("Worker requires explicit restore"));
}
if !record.workdir_attachments.is_empty() || !record.logical_workdir_attachments.is_empty()
{
return Err(reject("attachments are unsupported"));
}
check_request(&execution.request, false)?;
let workspace_id = record
.workspace_id
.ok_or_else(|| reject("missing Workspace identity"))?;
let metadata: WorkerMetadata = decode(&bytes(&worker_dir.join(WORKER_METADATA_FILE))?)?;
crate::worker_backend::validate_fixture_restore_metadata(&metadata, &execution.request)
.map_err(|_| reject("invalid source restore Manifest or settings"))?;
if metadata.worker_name != runtime_worker_name(record.worker_id)
|| metadata.workspace_id.as_deref() != Some(&workspace_id)
|| !metadata.spawned_children.is_empty()
|| !metadata.reclaimed_children.is_empty()
|| !metadata.peers.is_empty()
|| metadata.subjektiv_session_attribution.is_some()
{
return Err(reject("unsupported or inconsistent Worker metadata"));
}
let active = metadata
.active
.ok_or_else(|| reject("missing active Session"))?;
let segment_id = active
.segment_id
.ok_or_else(|| reject("unmaterialized Session"))?;
let source_auth: SourceAuth = toml::from_str(&text(&root.join("auth.toml"))?)
.map_err(|_| reject("missing Runtime identity"))?;
let session = worker_dir.join("session");
let fixture = Self {
template_version: TEMPLATE_VERSION,
runtime_schema_version: SCHEMA_VERSION,
runtime_id: source_auth.identity.identity_id,
workspace_id,
worker_id: record.worker_id,
created_at_ms: record.created_at_ms,
session_id: active.session_id,
segment_id,
session_manifest: bytes(&session.join("session.json"))?,
segment_jsonl: bytes(&session.join("segments").join(format!("{segment_id}.jsonl")))?,
};
fixture.validate_shape()?;
fixture.validate_session(&session)?;
Ok(fixture)
}
/// Install after normal identity/trust initialization, before Runtime start.
/// `request` and `manifest` supply NEW backend/config/profile references and
/// freshly resolved policy, not a launch. The saved source Manifest is never
/// reused. `manifest.worker.name` must equal `worker_name()`.
/// Only `auth.toml` may already exist. The owner lock fences live Runtimes;
/// staged files are synced and published without overwriting any Worker.
/// An interrupted publication fails closed as an incomplete Runtime layout.
pub fn install(
&self,
options: &FsRuntimeStoreOptions,
request: CreateWorkerRequest,
manifest: &manifest::WorkerManifest,
) -> Result<(), RuntimeError> {
self.validate_shape()?;
check_request(&request, true)?;
if manifest.worker.name != self.worker_name() {
return Err(reject("resolved Manifest Worker name mismatch"));
}
let mut metadata = WorkerMetadata::new(
self.worker_name(),
Some(WorkerActiveSegmentRef {
session_id: self.session_id,
segment_id: Some(self.segment_id),
}),
)
.with_workspace_id(&self.workspace_id);
metadata.resolved_manifest_snapshot = Some(
manifest::write_persisted_worker_manifest_snapshot(manifest)
.map_err(|_| reject("invalid resolved Manifest"))?,
);
crate::worker_backend::validate_fixture_restore_metadata(&metadata, &request)
.map_err(|_| reject("resolved Manifest does not match fresh request settings"))?;
if options.runtime_id != self.runtime_id
|| request.worker_id != self.worker_id
|| request
.workspace_api
.as_ref()
.map(|api| api.workspace_id.as_str())
!= Some(&self.workspace_id)
{
return Err(reject("installation identity mismatch"));
}
no_symlink_components(&options.root)?;
check_tree(&options.root, &mut 256)?;
// Validate the lock location before the ordinary owner-lock opener.
let lock_path = options.root.with_file_name(format!(
".{}.runtime-owner.lock",
options
.root
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| reject("invalid Runtime path"))?
));
if fs::symlink_metadata(&lock_path).is_ok() {
regular_file(&lock_path)?;
}
let lock = acquire_runtime_store_owner_lock(&options.root)?;
validate_fresh_runtime_store_layout(&options.root)
.map_err(|_| reject("destination is not fresh; refusing overwrite"))?;
let auth: InitializedAuth = toml::from_str(&text(&options.root.join("auth.toml"))?)
.map_err(|_| reject("initialize Runtime identity and trust before install"))?;
if auth.identity.identity_id != self.runtime_id
|| !auth.archived_legacy_issuers.is_empty()
|| auth.identity.signing_key().is_err()
{
return Err(reject("invalid destination Runtime identity"));
}
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
.map_err(|_| reject("invalid destination Workspace trust"))?;
let api = request
.workspace_api
.as_ref()
.expect("validated Workspace binding");
if !auth.workspace_issuers.iter().any(|trust| {
trust.workspace_id == self.workspace_id
&& trust.backend_url == api.base_url
&& trust.state == WorkspaceIssuerTrustState::Active
}) {
return Err(reject("request does not match initialized Workspace trust"));
}
let stage = options
.root
.join(format!(".fixture-{}", uuid::Uuid::now_v7()));
io(fs::create_dir(&stage))?;
let store = FsRuntimeStore {
root: stage.clone(),
_owner_lock: Some(lock),
worker_write_fault: Arc::new(AtomicU8::new(AtomicWriteFault::None as u8)),
};
let result = (|| {
let worker = PersistedWorkerRecord {
restore_guard: crate::runtime::RestoreGuard::default(),
worker_ref: WorkerRef::new(self.worker_id),
worker_id: self.worker_id,
profile: request.profile.clone(),
display_name: request.display_name.clone(),
profile_source: request.profile_source.reference(),
config_bundle: request.config_bundle.clone(),
created_at_ms: self.created_at_ms,
status: WorkerStatus::Stopped,
last_finished_submission_request_id: None,
workspace_id: Some(self.workspace_id.clone()),
workdir_attachments: Vec::new(),
logical_workdir_attachments: Vec::new(),
execution_state: PersistedWorkerExecutionState::Available(
PersistedWorkerExecution {
request,
binding: None,
restore_intent: WorkerRestoreIntent::Explicit,
},
),
};
WorkerAggregateRecord::from_persisted(&worker)
.validate(&store.worker_dir(&self.worker_id).join(WORKER_FILE))
.map_err(|_| reject("invalid installation request"))?;
store.write_worker_record(&worker)?;
let worker_dir = store.worker_dir(&self.worker_id);
atomic_write_json(
&worker_dir.join(WORKER_METADATA_FILE),
&metadata,
"write fixture metadata",
)?;
let session = worker_dir.join("session");
io(fs::create_dir_all(session.join("segments")))?;
write_new(&session.join("session.json"), &self.session_manifest)?;
write_new(
&session
.join("segments")
.join(format!("{}.jsonl", self.segment_id)),
&self.segment_jsonl,
)?;
self.validate_session(&session)?;
store.write_runtime_snapshot(&PersistedRuntimeState {
subject_create_recovery_fences: BTreeMap::new(),
subject_releases: Vec::new(),
// Match normal store initialization: this is catalog availability,
// not a process or Worker execution start.
display_name: options.display_name.clone(),
status: RuntimeStatus::Running,
next_diagnostic_id: 1,
workers: BTreeMap::new(),
workspace_owners: BTreeMap::new(),
diagnostics: Vec::new(),
})?;
let loaded = store.load_runtime_state()?;
if loaded.workers.len() != 1 || !loaded.diagnostics.is_empty() {
return Err(reject("staged aggregate failed ordinary store roundtrip"));
}
sync_runtime_tree(&stage)?;
// No destination existed when checked under the owner lock. Hard-link
// the snapshot last with create-new semantics; never replace a file.
// Reserve the destination catalog with create-new semantics. Even
// an empty preexisting workers directory must never be replaced.
let destination_workers = options.root.join(WORKERS_DIR);
io(fs::create_dir(&destination_workers))?;
io(fs::rename(
store.worker_dir(&self.worker_id),
destination_workers.join(self.worker_id.to_string()),
))?;
sync_directory(&destination_workers, "publish stopped Worker aggregate")?;
io(fs::hard_link(
stage.join(RUNTIME_FILE),
options.root.join(RUNTIME_FILE),
))?;
sync_directory(&options.root, "publish stopped fixture")?;
Ok(())
})();
// Only remove our private staging directory, never existing destination data.
let cleanup = fs::remove_dir_all(&stage);
result?;
io(cleanup)?;
sync_directory(&options.root, "finish stopped fixture")
}
fn validate_shape(&self) -> Result<(), RuntimeError> {
if self.template_version != TEMPLATE_VERSION
|| self.runtime_schema_version != SCHEMA_VERSION
{
return Err(reject("unsupported template schema"));
}
if !portable_id(&self.runtime_id)
|| !portable_id(&self.workspace_id)
|| self.session_id.get_version_num() != 7
|| self.segment_id.get_version_num() != 7
|| self.session_manifest.len() > MAX_SESSION_BYTES
|| self.segment_jsonl.len() > MAX_SESSION_BYTES
{
return Err(reject("invalid portable identity or size"));
}
let manifest: SessionManifest = decode(&self.session_manifest)?;
if manifest.session_id != self.session_id
|| manifest.schema_version == 0
|| !self.segment_jsonl.ends_with(b"\n")
{
return Err(reject("Session identity or committed boundary mismatch"));
}
// Read-only JSON traversal, never mutation/reconstruction of Session bytes.
// Reject known secret/host markers even in otherwise valid typed records.
for line in self
.segment_jsonl
.split(|b| *b == b'\n')
.filter(|line| !line.is_empty())
{
let _: PortableJson = decode(line)?;
validate_preserved_record(line)?;
}
Ok(())
}
fn validate_history_entry(
&self,
entry: &session_store::LoggedHistoryEntry,
ids: &mut std::collections::BTreeSet<String>,
) -> Result<(), RuntimeError> {
if !portable_id(&entry.metadata.entry_id.0)
|| !ids.insert(entry.metadata.entry_id.0.clone())
{
return Err(reject("duplicate or invalid entry identity"));
}
if entry
.metadata
.input_metadata
.as_ref()
.is_some_and(|metadata| {
metadata.feature_payload().is_some() || metadata.public_item().is_some()
})
{
// These are opaque owner-specific payloads, not part of the narrow
// artificial conversation contract. Never preserve unchecked JSON.
return Err(reject("Feature history payloads are unsupported"));
}
if !matches!(
entry.item,
session_store::LoggedItem::Message { .. }
| session_store::LoggedItem::AssistantResponseBoundary { .. }
) {
return Err(reject("only text conversation records are supported"));
}
if let session_store::LoggedSessionHistoryOrigin::ModelOutput { worker } =
&entry.metadata.origin
{
// Session producer IDs identify the original execution, not the
// Workspace catalog Worker. Preserve them; do not rebind/rewrite them.
if !portable_id(&worker.worker_id)
|| worker
.workspace_id
.as_ref()
.is_some_and(|id| id != &self.workspace_id)
|| worker
.runtime_id
.as_ref()
.is_some_and(|id| id != &self.runtime_id)
{
return Err(reject("Session origin identity mismatch"));
}
}
Ok(())
}
fn validate_session(&self, root: &Path) -> Result<(), RuntimeError> {
for entry in io(fs::read_dir(root))? {
let entry = io(entry)?;
match entry.file_name().to_str() {
Some("session.json" | "segments") => {}
Some("artifacts") => check_empty_artifacts(&entry.path())?,
_ => return Err(reject("unsupported Session files")),
}
}
let entries = io(fs::read_dir(root.join("segments")))?.collect::<Result<Vec<_>, _>>();
let entries = io(entries)?;
if entries.len() != 1
|| entries[0].file_name() != format!("{}.jsonl", self.segment_id).as_str()
{
return Err(reject("only the fixed single-Segment fixture is supported"));
}
let store = WorkerSessionStore::open_read_only(root)
.map_err(|_| reject("unsupported Session schema"))?;
let logs = store
.read_all_read_only_bounded(self.session_id, self.segment_id, MAX_SESSION_BYTES as u64)
.map_err(|_| reject("invalid canonical Session records"))?
.0;
if !matches!(logs.first(), Some(session_store::LogEntry::AnnotatedSegmentStart {
session_id, system_prompt: Some(_), forked_from: None, compacted_from: None, ..
}) if *session_id == self.session_id)
{
return Err(reject(
"missing system prompt, invalid Segment seed, or unsupported lineage",
));
}
let mut ids = std::collections::BTreeSet::new();
for (index, log) in logs.iter().enumerate() {
use session_store::LogEntry;
match log {
LogEntry::AnnotatedSegmentStart { history, .. }
if index == 0 && history.is_empty() => {}
LogEntry::AnnotatedUserInput {
history,
extensions,
..
} => {
for extension in extensions {
validate_settled_activation(extension)?;
}
for entry in history {
self.validate_history_entry(entry, &mut ids)?;
}
}
LogEntry::AnnotatedAssistantItem { entry, .. } => {
self.validate_history_entry(entry, &mut ids)?
}
LogEntry::RunCompleted {
entry_id: Some(id), ..
} => {
if !portable_id(&id.0) || !ids.insert(id.0.clone()) {
return Err(reject("duplicate or invalid entry identity"));
}
}
LogEntry::Invoke { .. } | LogEntry::LlmUsage { .. } | LogEntry::TurnEnd { .. } => {}
_ => {
return Err(reject(
"unsupported conversation record or pending operation",
));
}
}
}
let state = session_store::collect_state(&logs);
if state.last_run_interrupted || state.active_run_turn_count.is_some() {
return Err(reject("Session has pending execution"));
}
for segments in &state.user_segments {
if segments
.iter()
.any(|segment| !matches!(segment, protocol::Segment::Text { .. }))
{
return Err(reject("Session attachments are unsupported"));
}
}
Ok(())
}
}
// Compare *fields*, not re-encoded bytes or scalar values (e.g. f32 JSON
// normalization). Serde's permissive/untagged/flattened compatibility readers
// can drop unknown data without surfacing it to an ignored-field callback. A
// retained field must survive the owner's typed projection at every depth.
// This intentionally accepts only canonical writer shapes: even an explicit
// empty optional field omitted by that writer is rejected, not repaired.
// The projection is validation-only; installation always writes original bytes.
fn validate_preserved_record(bytes: &[u8]) -> Result<(), RuntimeError> {
let original: serde_json::Value = decode(bytes)?;
let record: session_store::LogEntry = decode(bytes)?;
let projection =
serde_json::to_value(&record).map_err(|_| reject("invalid typed Session record"))?;
require_preserved_fields(&original, &projection)
}
fn require_preserved_fields(
original: &serde_json::Value,
projection: &serde_json::Value,
) -> Result<(), RuntimeError> {
use serde_json::Value;
match (original, projection) {
(Value::Object(original), Value::Object(projection)) => {
for (key, value) in original {
let projected = projection
.get(key)
.ok_or_else(|| reject("unknown or noncanonical Session field"))?;
require_preserved_fields(value, projected)?;
}
}
(Value::Array(original), Value::Array(projection))
if original.len() == projection.len() =>
{
for (value, projected) in original.iter().zip(projection) {
require_preserved_fields(value, projected)?;
}
}
(Value::Null, Value::Null)
| (Value::Bool(_), Value::Bool(_))
| (Value::Number(_), Value::Number(_))
| (Value::String(_), Value::String(_)) => {}
_ => return Err(reject("noncanonical Session field shape")),
}
Ok(())
}
// Exact receipt wire fields owned by worker::PendingActivationState. Keep these
// fixture-only mirrors strict; do not change the production compatibility reader.
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SubmissionReceipt {
submission_request_id: String,
source_namespace: String,
submission_id: String,
payload_digest: String,
disposition: protocol::SubmissionDisposition,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct NotificationReceipt {
notification_request_id: String,
source_namespace: String,
payload_digest: String,
}
// The approved conversation has committed submission checkpoints in its original
// Session records. Keep their receipt bytes, but reject any resumable queue or
// Feature invocation history. This projection confers no execution authority.
fn validate_settled_activation(
extension: &session_store::SessionExtension,
) -> Result<(), RuntimeError> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Checkpoint {
#[serde(rename = "next_activation_sequence")]
_next_activation_sequence: u64,
activating: Option<serde::de::IgnoredAny>,
activating_notification: Vec<serde::de::IgnoredAny>,
pending: Vec<serde::de::IgnoredAny>,
pending_notifications: Vec<serde::de::IgnoredAny>,
#[serde(rename = "receipts")]
_receipts: Vec<SubmissionReceipt>,
#[serde(rename = "notification_receipts")]
_notification_receipts: Vec<NotificationReceipt>,
invocation_receipts: Vec<serde::de::IgnoredAny>,
invocation_recovery_blocked: bool,
}
if extension.domain != "worker.pending_activations.v1" {
return Err(reject("unsupported Session extension"));
}
let checkpoint: Checkpoint = serde_json::from_value(extension.payload.clone())
.map_err(|_| reject("invalid Session activation checkpoint"))?;
if checkpoint.activating.is_some()
|| !checkpoint.activating_notification.is_empty()
|| !checkpoint.pending.is_empty()
|| !checkpoint.pending_notifications.is_empty()
|| !checkpoint.invocation_receipts.is_empty()
|| checkpoint.invocation_recovery_blocked
{
return Err(reject(
"Session has pending activation or invocation history",
));
}
Ok(())
}
fn check_request(request: &CreateWorkerRequest, install: bool) -> Result<(), RuntimeError> {
if !request.workdir_attachment_requests.is_empty()
|| !request.workdir_attachments.is_empty()
|| request.backend_job.is_some()
|| request.subjektiv_attached
|| request.worker_observation_enabled
|| !request.worker_observation_grants.is_empty()
|| (install && request.initial_input.is_some())
{
return Err(reject(
"unsupported capabilities, attachments, or initial input",
));
}
if install
&& (request.create_fingerprint.trim().is_empty()
|| request.config_bundle.is_none()
|| !matches!(
request.profile_source,
ProfileSourceArchiveSource::WorkspaceConfig { .. }
))
{
return Err(reject(
"fresh config/profile references and create fingerprint are required",
));
}
Ok(())
}
fn check_empty_artifacts(root: &Path) -> Result<(), RuntimeError> {
for entry in io(fs::read_dir(root))? {
let entry = io(entry)?;
let metadata = io(entry.metadata())?;
if metadata.is_dir() {
check_empty_artifacts(&entry.path())?;
} else if entry.file_name() != ".aggregate.lock" || metadata.len() != 0 {
return Err(reject("Session artifacts are unsupported"));
}
}
Ok(())
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<(), RuntimeError> {
let mut file = io(OpenOptions::new().write(true).create_new(true).open(path))?;
io(file.write_all(bytes))?;
io(file.sync_all())
}
// A streaming validation visitor, not a generic document available to callers.
struct PortableJson;
impl<'de> Deserialize<'de> for PortableJson {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = PortableJson;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("portable fixture JSON")
}
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<PortableJson, E> {
Ok(PortableJson)
}
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<PortableJson, E> {
Ok(PortableJson)
}
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<PortableJson, E> {
Ok(PortableJson)
}
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<PortableJson, E> {
Ok(PortableJson)
}
fn visit_unit<E: serde::de::Error>(self) -> Result<PortableJson, E> {
Ok(PortableJson)
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<PortableJson, E> {
let lower = value.to_ascii_lowercase();
if [
"://",
"private key",
"bearer ",
"sk-",
"api_key",
"access_token",
"refresh_token",
"private_key",
"authorization",
]
.iter()
.any(|marker| lower.contains(marker))
|| value
.split_whitespace()
.any(|word| word.starts_with('/') || word.contains(":\\"))
{
return Err(E::custom("nonportable or sensitive fixture material"));
}
Ok(PortableJson)
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> Result<PortableJson, A::Error> {
while seq.next_element::<PortableJson>()?.is_some() {}
Ok(PortableJson)
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<PortableJson, A::Error> {
let mut keys = std::collections::BTreeSet::new();
while let Some(key) = map.next_key::<String>()? {
// A duplicate key can hide discarded data even inside an
// otherwise opaque extension payload. Never retain it.
if !keys.insert(key.clone()) {
return Err(serde::de::Error::custom("duplicate fixture JSON field"));
}
serde::de::Visitor::visit_str::<A::Error>(Visitor, &key)?;
map.next_value::<PortableJson>()?;
}
Ok(PortableJson)
}
}
deserializer.deserialize_any(Visitor)
}
}
@@ -0,0 +1,885 @@
use super::*;
use crate::auth::RuntimeIdentityMaterial;
use crate::catalog::{ProfileSelector, WorkspaceApiRef};
use crate::profile_archive::ProfileSourceGraphSummary;
use session_store::{LogEntry, Store};
use sha2::{Digest, Sha256};
const RUNTIME: &str = "fixture-runtime";
const WORKSPACE: &str = "fixture-workspace";
const NEW_URL: &str = "http://127.0.0.1:34567";
#[derive(Serialize)]
#[serde(untagged)]
enum ReceiptFixture<T> {
Valid(T),
Malformed(u8),
}
#[derive(Default, Serialize)]
struct CheckpointFixture {
next_activation_sequence: u64,
activating: Option<u8>,
activating_notification: Vec<u8>,
pending: Vec<u8>,
pending_notifications: Vec<u8>,
receipts: Vec<ReceiptFixture<SubmissionReceipt>>,
notification_receipts: Vec<ReceiptFixture<NotificationReceipt>>,
invocation_receipts: Vec<u8>,
invocation_recovery_blocked: bool,
}
fn settled_checkpoint() -> CheckpointFixture {
CheckpointFixture {
next_activation_sequence: 2,
receipts: vec![ReceiptFixture::Valid(SubmissionReceipt {
submission_request_id: "completed-submission-request".into(),
source_namespace: "fixture-source".into(),
submission_id: "completed-submission".into(),
payload_digest: "fixture-payload-digest".into(),
disposition: protocol::SubmissionDisposition::Started,
})],
notification_receipts: vec![ReceiptFixture::Valid(NotificationReceipt {
notification_request_id: "completed-notification-request".into(),
source_namespace: "fixture-source".into(),
payload_digest: "fixture-notification-digest".into(),
})],
..Default::default()
}
}
fn checkpoint_extension(checkpoint: &CheckpointFixture) -> session_store::SessionExtension {
session_store::SessionExtension::new(
"worker.pending_activations.v1",
serde_json::to_value(checkpoint).unwrap(),
)
}
fn worker_id() -> WorkerId {
"01a1657d-b762-7791-87ae-a49f28cadbce".parse().unwrap()
}
fn resolved() -> manifest::WorkerManifest {
let mut config = manifest::WorkerManifestConfig::resolution_defaults();
config.worker.name = Some(runtime_worker_name(worker_id()));
manifest::WorkerManifest::try_from(config).unwrap()
}
fn request() -> CreateWorkerRequest {
CreateWorkerRequest {
worker_id: worker_id(),
create_fingerprint: "new-create-fingerprint".into(),
profile: ProfileSelector::Builtin("default".into()),
display_name: Some("fixture".into()),
profile_source: ProfileSourceArchiveSource::WorkspaceConfig {
archive: ProfileSourceArchiveRef {
id: "new-profile".into(),
digest: "new-profile-digest".into(),
size_bytes: 1,
source_graph: ProfileSourceGraphSummary {
source_count: 1,
total_source_bytes: 1,
entrypoints: BTreeMap::from([("builtin:default".into(), "default.dcl".into())]),
import_count: 0,
},
},
},
config_bundle: Some(ConfigBundleRef {
id: "new-config".into(),
digest: "new-config-digest".into(),
}),
workspace_api: Some(WorkspaceApiRef {
workspace_id: WORKSPACE.into(),
base_url: NEW_URL.into(),
}),
initial_input: None,
workdir_attachment_requests: Vec::new(),
workdir_attachments: Vec::new(),
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: WORKSPACE.into(),
language: "en".into(),
}),
subjektiv_attached: false,
backend_job: None,
}
}
fn initialize(root: &Path, runtime_id: &str, workspace_id: &str, url: &str) {
#[derive(Serialize)]
struct Auth {
identity: RuntimeIdentityMaterial,
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
}
let issuer = RuntimeIdentityMaterial::generate("fixture-issuer").unwrap();
let digest = Sha256::digest(crate::auth::decode_public_key(&issuer.public_key).unwrap());
let fingerprint = format!(
"sha256:{}",
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
let trust = WorkspaceIssuerTrustRecord::from_bundle(
server_api::WorkspacePublicIdentityBundle {
workspace_id: workspace_id.into(),
backend_url: url.into(),
key_id: "fixture-issuer".into(),
algorithm: "ed25519".into(),
public_key: issuer.public_key,
public_key_fingerprint: fingerprint,
},
"fixture-trust".into(),
100,
)
.unwrap();
fs::create_dir_all(root).unwrap();
fs::write(
root.join("auth.toml"),
toml::to_string(&Auth {
identity: RuntimeIdentityMaterial::generate(runtime_id).unwrap(),
workspace_issuers: vec![trust],
})
.unwrap(),
)
.unwrap();
}
fn record(request: CreateWorkerRequest) -> PersistedWorkerRecord {
PersistedWorkerRecord {
restore_guard: crate::runtime::RestoreGuard::default(),
worker_ref: WorkerRef::new(request.worker_id),
worker_id: request.worker_id,
profile: request.profile.clone(),
display_name: request.display_name.clone(),
profile_source: request.profile_source.reference(),
config_bundle: request.config_bundle.clone(),
created_at_ms: Some(123456),
status: WorkerStatus::Stopped,
last_finished_submission_request_id: Some("old-submission-operation".into()),
workspace_id: Some(WORKSPACE.into()),
workdir_attachments: vec![],
logical_workdir_attachments: vec![],
execution_state: PersistedWorkerExecutionState::Available(PersistedWorkerExecution {
request,
binding: Some(PersistedWorkerExecutionBinding {}),
restore_intent: WorkerRestoreIntent::Explicit,
}),
}
}
fn source() -> (tempfile::TempDir, PathBuf, StoppedWorkerTemplate) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
initialize(&root, RUNTIME, WORKSPACE, "http://127.0.0.1:23456");
drop(
crate::Runtime::with_fs_store(FsRuntimeStoreOptions::new(&root).with_runtime_id(RUNTIME))
.unwrap(),
);
let opened = FsRuntimeStore::open_or_create(root.clone(), RUNTIME).unwrap();
let mut old = request();
old.workspace_api.as_mut().unwrap().base_url = "http://127.0.0.1:23456".into();
old.config_bundle.as_mut().unwrap().id = "old-config".into();
opened.store.write_worker_record(&record(old)).unwrap();
let worker = opened.store.worker_dir(&worker_id());
let session_id = "01a1657d-b800-7777-8888-111111111111".parse().unwrap();
let segment_id = "01a1657d-b818-77c1-bd73-176172a4bd82".parse().unwrap();
let mut metadata = WorkerMetadata::new(
runtime_worker_name(worker_id()),
Some(WorkerActiveSegmentRef {
session_id,
segment_id: Some(segment_id),
}),
)
.with_workspace_id(WORKSPACE)
.with_workspace_root(PathBuf::from("/old/host/path"));
metadata.resolved_manifest_snapshot =
Some(manifest::write_persisted_worker_manifest_snapshot(&resolved()).unwrap());
atomic_write_json(
&worker.join(WORKER_METADATA_FILE),
&metadata,
"test metadata",
)
.unwrap();
let sessions = WorkerSessionStore::new(worker.join("session")).unwrap();
let history = session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::Message {
role: session_store::LoggedRole::Assistant,
content: vec![session_store::LoggedContentPart::Text {
text: "artificial fixture reply".into(),
}],
},
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: session_store::LoggedSessionHistoryEntryId("fixed-entry-id".into()),
origin: session_store::LoggedSessionHistoryOrigin::ModelOutput {
worker: session_store::LoggedWorkerSubject {
workspace_id: Some(WORKSPACE.into()),
runtime_id: Some(RUNTIME.into()),
worker_id: worker_id().to_string(),
},
},
derivation: None,
input_metadata: None,
},
};
sessions
.create_segment(
session_id,
segment_id,
&[
LogEntry::AnnotatedSegmentStart {
ts: 123,
session_id,
system_prompt: Some("Harmless artificial fixture instructions.".into()),
config: Default::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
LogEntry::Invoke {
ts: 124,
trigger: protocol::InvokeKind::UserSend,
},
LogEntry::AnnotatedUserInput {
ts: 124,
segments: vec![protocol::Segment::text(
"Harmless artificial fixture input.",
)],
extensions: vec![checkpoint_extension(&settled_checkpoint())],
history: vec![session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::Message {
role: session_store::LoggedRole::User,
content: vec![session_store::LoggedContentPart::Text {
text: "Harmless artificial fixture input.".into(),
}],
},
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: session_store::LoggedSessionHistoryEntryId(
"fixed-user-id".into(),
),
origin: session_store::LoggedSessionHistoryOrigin::BackendInstruction {
operation_id: None,
},
derivation: None,
input_metadata: None,
},
}],
},
LogEntry::AnnotatedAssistantItem {
ts: 125,
entry: history,
},
LogEntry::LlmUsage {
ts: 125,
history_len: 2,
input_total_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
output_tokens: 1,
},
LogEntry::TurnEnd {
ts: 126,
turn_count: 1,
},
LogEntry::RunCompleted {
entry_id: Some(session_store::LoggedSessionHistoryEntryId(
"fixed-terminal-id".into(),
)),
ts: 126,
interrupted: false,
result: agen::EngineResult::Finished,
active_run_turn_count: None,
},
],
)
.unwrap();
fs::create_dir_all(worker.join("runs/old-run")).unwrap();
fs::write(worker.join("runs/old-run/private.log"), "not exported").unwrap();
drop(opened);
let template = StoppedWorkerTemplate::extract(&worker).unwrap();
(temp, worker, template)
}
fn destination(template: &StoppedWorkerTemplate) -> (tempfile::TempDir, FsRuntimeStoreOptions) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
initialize(
&root,
template.runtime_id(),
template.workspace_id(),
NEW_URL,
);
(
temp,
FsRuntimeStoreOptions::new(root).with_runtime_id(template.runtime_id()),
)
}
fn assert_fresh(options: &FsRuntimeStoreOptions) {
assert_eq!(fs::read_dir(&options.root).unwrap().count(), 1);
assert!(options.root.join("auth.toml").is_file());
}
#[test]
fn extraction_install_roundtrip_preserves_bytes_and_is_inactive() {
let (_source, worker, template) = source();
let original_worker = fs::read(worker.join(WORKER_FILE)).unwrap();
let original_metadata = fs::read(worker.join(WORKER_METADATA_FILE)).unwrap();
let exported = serde_json::to_vec(&template).unwrap();
let exported_text = String::from_utf8(exported.clone()).unwrap();
for forbidden in [
"old-config",
"old-submission-operation",
"restore_guard",
"private_key",
"http://",
"/old/host",
"private.log",
"resolved_manifest_snapshot",
] {
assert!(!exported_text.contains(forbidden));
}
let template: StoppedWorkerTemplate = serde_json::from_slice(&exported).unwrap();
let (_target, options) = destination(&template);
let auth_before = fs::read(options.root.join("auth.toml")).unwrap();
template.install(&options, request(), &resolved()).unwrap();
assert_eq!(
fs::read(options.root.join("auth.toml")).unwrap(),
auth_before
);
assert_eq!(fs::read(worker.join(WORKER_FILE)).unwrap(), original_worker);
assert_eq!(
fs::read(worker.join(WORKER_METADATA_FILE)).unwrap(),
original_metadata
);
let installed = options
.root
.join(WORKERS_DIR)
.join(template.worker_id().to_string());
assert_eq!(
fs::read(installed.join("session/session.json")).unwrap(),
template.session_manifest
);
assert_eq!(
fs::read(installed.join(format!("session/segments/{}.jsonl", template.segment_id())))
.unwrap(),
template.segment_jsonl
);
assert!(!installed.join("runs").exists());
let metadata: WorkerMetadata =
decode(&fs::read(installed.join(WORKER_METADATA_FILE)).unwrap()).unwrap();
assert_eq!(metadata.active.unwrap().session_id, template.session_id());
assert!(metadata.workspace_root.is_none() && metadata.resolved_manifest_snapshot.is_some());
let opened = FsRuntimeStore::open_or_create(options.root.clone(), RUNTIME).unwrap();
let state = opened.state.unwrap();
assert!(state.diagnostics.is_empty());
let saved = state.workers.get(&worker_id()).unwrap();
assert_eq!(saved.status, WorkerStatus::Stopped);
assert!(saved.last_finished_submission_request_id.is_none());
let PersistedWorkerExecutionState::Available(execution) = &saved.execution_state else {
panic!("not available")
};
assert_eq!(execution.restore_intent, WorkerRestoreIntent::Explicit);
assert!(execution.binding.is_none());
assert_eq!(execution.request, request());
// Fresh restore guard, no operation owner/receipt history.
assert_ne!(
serde_json::to_vec(&saved.restore_guard).unwrap(),
serde_json::to_vec(
&decode::<WorkerAggregateRecord>(&original_worker)
.unwrap()
.restore_guard
)
.unwrap()
);
let guard: GuardState = decode(&serde_json::to_vec(&saved.restore_guard).unwrap()).unwrap();
assert!(guard.active_request_id.is_none() && guard.owners.is_empty());
drop(opened.store);
struct NoExecution;
impl crate::execution::WorkerExecutionBackend for NoExecution {
fn backend_id(&self) -> &str {
"fixture-no-execution"
}
fn spawn_worker(
&self,
_: crate::execution::WorkerExecutionSpawnRequest,
) -> crate::execution::WorkerExecutionSpawnResult {
panic!("fixture executed")
}
fn dispatch_input(
&self,
_: &WorkerRef,
_: crate::interaction::WorkerInput,
) -> crate::execution::WorkerExecutionResult {
panic!("fixture submitted")
}
fn restore_worker(
&self,
_: crate::execution::WorkerExecutionRestoreRequest,
) -> crate::execution::WorkerExecutionSpawnResult {
panic!("fixture auto-restored")
}
}
let runtime =
crate::Runtime::with_fs_store_and_execution_backend(options.clone(), Arc::new(NoExecution))
.unwrap();
assert_eq!(runtime.list_workers().unwrap().len(), 1);
assert_eq!(runtime.summary().unwrap().active_worker_count, 0);
assert_eq!(
runtime
.worker_detail(&WorkerRef::new(worker_id()))
.unwrap()
.status,
WorkerStatus::Stopped
);
drop(runtime);
let runtime = crate::Runtime::with_fs_store(options).unwrap();
assert_eq!(
runtime.list_workers().unwrap()[0].status,
WorkerStatus::Stopped
);
}
#[test]
fn install_rejects_schema_identity_input_and_overwrite() {
let (_source, _, template) = source();
for change in [
"template",
"runtime-schema",
"session-schema",
"session-id",
"segment-id",
"runtime-id",
"workspace-id",
"worker-id",
"origin-id",
"duplicate-id",
"truncated",
] {
let (_target, options) = destination(&template);
let mut invalid = template.clone();
match change {
"template" => invalid.template_version += 1,
"runtime-schema" => invalid.runtime_schema_version += 1,
"session-schema" => {
invalid.session_manifest = format!(
"{{\"schema_version\":999,\"session_id\":\"{}\"}}",
invalid.session_id
)
.into_bytes()
}
"session-id" => invalid.session_id = uuid::Uuid::now_v7(),
"segment-id" => invalid.segment_id = uuid::Uuid::nil(), // no external attestation: valid new Segment ID is otherwise portable
"runtime-id" => invalid.runtime_id = "other-runtime".into(),
"workspace-id" => invalid.workspace_id = "other-workspace".into(),
"worker-id" => invalid.worker_id = WorkerId::now_v7(),
"origin-id" => {
invalid.segment_jsonl = String::from_utf8(invalid.segment_jsonl)
.unwrap()
.replace(RUNTIME, "other-runtime")
.into_bytes()
}
"duplicate-id" => {
let last = invalid
.segment_jsonl
.split_inclusive(|b| *b == b'\n')
.last()
.unwrap()
.to_vec();
invalid.segment_jsonl.extend(last);
}
"truncated" => {
invalid.segment_jsonl.pop();
}
_ => unreachable!(),
}
assert!(
invalid.install(&options, request(), &resolved()).is_err(),
"accepted {change}"
);
assert_fresh(&options);
}
let (_target, options) = destination(&template);
let mut launch = request();
launch.initial_input = Some(crate::interaction::WorkerInput::user("do not execute"));
assert!(template.install(&options, launch, &resolved()).is_err());
assert_fresh(&options);
template.install(&options, request(), &resolved()).unwrap();
let worker_path = options
.root
.join(WORKERS_DIR)
.join(worker_id().to_string())
.join(WORKER_FILE);
let before = fs::read(&worker_path).unwrap();
assert!(template.install(&options, request(), &resolved()).is_err());
assert_eq!(fs::read(worker_path).unwrap(), before);
}
#[test]
fn rejects_uninitialized_untrusted_live_and_partial_destination() {
let (_source, _, template) = source();
let (_target, options) = destination(&template);
let held = acquire_runtime_store_owner_lock(&options.root).unwrap();
assert!(template.install(&options, request(), &resolved()).is_err());
assert_fresh(&options);
drop(held);
initialize(&options.root, "wrong-runtime", WORKSPACE, NEW_URL);
assert!(template.install(&options, request(), &resolved()).is_err());
initialize(&options.root, RUNTIME, WORKSPACE, "http://127.0.0.1:23456");
assert!(template.install(&options, request(), &resolved()).is_err());
fs::remove_file(options.root.join("auth.toml")).unwrap();
assert!(template.install(&options, request(), &resolved()).is_err());
initialize(&options.root, RUNTIME, WORKSPACE, NEW_URL);
fs::create_dir(options.root.join(WORKERS_DIR)).unwrap();
assert!(template.install(&options, request(), &resolved()).is_err());
}
#[test]
fn extraction_rejects_nonstopped_pending_schema_and_metadata_mismatch() {
for change in [
"running",
"pending",
"guard-pending",
"attachment",
"schema",
"worker-id",
"metadata-workspace",
"metadata-session",
"artifact",
"secret",
"url",
"path",
] {
let (_source, worker, _) = source();
let mut aggregate: WorkerAggregateRecord =
decode(&fs::read(worker.join(WORKER_FILE)).unwrap()).unwrap();
match change {
"running" => aggregate.status = WorkerStatus::Running,
"pending" => {
aggregate.execution_state = WorkerExecutionStateRecord::ReconciliationRequired(
PersistedWorkerLifecycleOperation::Restore(PersistedWorkerRestoreOperation {
operation_id: WorkerLifecycleOperationId::new(),
mode: PersistedWorkerRestoreMode::Explicit,
request: request(),
binding: None,
last_settled_status: WorkerStatus::Stopped,
}),
)
}
"guard-pending" => aggregate.restore_guard = serde_json::from_str(r#"{"observation_token":"old-token","active_request_id":"pending-restore","owners":{}}"#).unwrap(),
"attachment" => aggregate.logical_workdir_attachments.push(LogicalWorkdirAttachment {
alias: workdir::WorkdirAttachmentAlias::new("source").unwrap(),
working_directory_id: "old-workdir".into(),
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
}),
"schema" => aggregate.schema_version += 1,
"worker-id" => aggregate.worker_id = WorkerId::now_v7(),
"metadata-workspace" | "metadata-session" => {
let mut metadata: WorkerMetadata =
decode(&fs::read(worker.join(WORKER_METADATA_FILE)).unwrap()).unwrap();
if change == "metadata-workspace" {
metadata.workspace_id = Some("wrong-workspace".into());
} else {
metadata.active.as_mut().unwrap().session_id = uuid::Uuid::now_v7();
}
atomic_write_json(
&worker.join(WORKER_METADATA_FILE),
&metadata,
"test mismatch",
)
.unwrap();
}
"artifact" => {
fs::create_dir_all(worker.join("session/artifacts/paste")).unwrap();
fs::write(
worker.join("session/artifacts/paste/payload"),
"unsupported",
)
.unwrap();
}
"secret" | "url" | "path" => {
let segment = fs::read_dir(worker.join("session/segments"))
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let replacement = match change {
"secret" => "Bearer secret",
"url" => "https://old-host.invalid",
_ => "/old/host/path",
};
let text = fs::read_to_string(&segment)
.unwrap()
.replace("artificial fixture reply", replacement);
fs::write(segment, text).unwrap();
}
_ => unreachable!(),
}
atomic_write_json(&worker.join(WORKER_FILE), &aggregate, "test aggregate").unwrap();
assert!(
StoppedWorkerTemplate::extract(&worker).is_err(),
"accepted {change}"
);
}
let (_source, worker, _) = source();
let held =
acquire_runtime_store_owner_lock(worker.parent().unwrap().parent().unwrap()).unwrap();
assert!(StoppedWorkerTemplate::extract(&worker).is_err());
drop(held);
}
#[test]
fn fresh_manifest_and_memory_settings_are_required() {
let (_source, _, template) = source();
let (_target, options) = destination(&template);
let mut manifest = resolved();
manifest.worker.name = "wrong-worker".into();
assert!(template.install(&options, request(), &manifest).is_err());
let mut binding = request();
binding.memory_settings = None;
assert!(template.install(&options, binding, &resolved()).is_err());
let mut binding = request();
binding.memory_settings.as_mut().unwrap().workspace_id = "other-workspace".into();
assert!(template.install(&options, binding, &resolved()).is_err());
assert_fresh(&options);
}
#[test]
fn settled_checkpoints_preserve_receipts_but_refuse_pending_work() {
let mut checkpoint = settled_checkpoint();
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_ok());
checkpoint.pending.push(1);
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
checkpoint.pending.clear();
checkpoint.activating = Some(1);
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
checkpoint.activating = None;
checkpoint.invocation_receipts.push(1);
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
checkpoint.invocation_receipts.clear();
checkpoint.invocation_recovery_blocked = true;
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
}
// Deliberately corrupt only test-owned synthetic source files, never the lab.
fn assert_rejected_session_bytes(worker: &Path, template: &StoppedWorkerTemplate, bytes: Vec<u8>) {
let path = worker.join(format!("session/segments/{}.jsonl", template.segment_id()));
fs::write(path, &bytes).unwrap();
let extraction_error = StoppedWorkerTemplate::extract(worker)
.err()
.expect("invalid source accepted");
assert!(
!extraction_error
.to_string()
.contains("synthetic-discarded-material")
);
let (_target, options) = destination(template);
let mut invalid = template.clone();
invalid.segment_jsonl = bytes;
let installation_error = invalid
.install(&options, request(), &resolved())
.unwrap_err();
assert!(
!installation_error
.to_string()
.contains("synthetic-discarded-material")
);
assert_fresh(&options);
}
fn synthetic_logs(template: &StoppedWorkerTemplate) -> Vec<LogEntry> {
template
.segment_jsonl
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(|line| serde_json::from_slice(line).unwrap())
.collect()
}
fn encode_synthetic_logs(logs: &[LogEntry]) -> Vec<u8> {
let mut bytes = Vec::new();
for log in logs {
serde_json::to_writer(&mut bytes, log).unwrap();
bytes.push(b'\n');
}
bytes
}
#[tokio::test]
async fn missing_system_prompt_matches_worker_restore_rejection() {
let (_source, worker_dir, template) = source();
let mut logs = synthetic_logs(&template);
assert!(session_store::collect_state(&logs).system_prompt.is_some());
let LogEntry::AnnotatedSegmentStart { system_prompt, .. } = &mut logs[0] else {
panic!("missing seed")
};
*system_prompt = None;
assert_rejected_session_bytes(&worker_dir, &template, encode_synthetic_logs(&logs));
// The owning Worker constructor rejects this before model-client creation,
// allocation, or execution. Do not start a controller or submit any input.
let store = session_store::CombinedStore::new(
WorkerSessionStore::open_read_only(worker_dir.join("session")).unwrap(),
session_store::WorkerAggregateStore::open_read_only(&worker_dir, template.worker_name())
.unwrap(),
);
let restored = worker::Worker::restore_from_manifest(
template.session_id(),
template.segment_id(),
resolved(),
store,
worker::PromptCatalogSource::builtins_only(),
)
.await;
assert!(matches!(
restored,
Err(worker::WorkerError::SegmentSystemPromptMissing { .. })
));
}
#[test]
fn malformed_submission_and_notification_receipts_fail_extraction_and_install() {
let (_source, worker, template) = source();
for notification in [false, true] {
let mut checkpoint = settled_checkpoint();
if notification {
checkpoint.notification_receipts =
vec![ReceiptFixture::Malformed(1), ReceiptFixture::Malformed(2)];
} else {
checkpoint.receipts = vec![ReceiptFixture::Malformed(1), ReceiptFixture::Malformed(2)];
}
let mut logs = synthetic_logs(&template);
for log in &mut logs {
if let LogEntry::AnnotatedUserInput { extensions, .. } = log {
*extensions = vec![checkpoint_extension(&checkpoint)];
}
}
assert_rejected_session_bytes(&worker, &template, encode_synthetic_logs(&logs));
}
}
#[test]
fn unknown_fields_at_every_retained_record_depth_are_rejected_without_secret_diagnostics() {
let (_source, worker, template) = source();
let original = String::from_utf8(template.segment_jsonl.clone()).unwrap();
// These values are harmless synthetic markers, not lab/private material.
// The compatibility decoder normally discards many of these fields.
let locations = [
r#""kind":"annotated_segment_start""#,
r#""kind":"invoke""#,
r#""kind":"annotated_user_input""#,
r#""kind":"annotated_assistant_item""#,
r#""kind":"llm_usage""#,
r#""kind":"turn_end""#,
r#""kind":"run_completed""#,
r#""max_tokens":null"#,
r#""kind":"message","role":"assistant""#,
r#""kind":"text","text":"artificial fixture reply""#,
r#""kind":"text","content":"Harmless artificial fixture input.""#,
r#""entry_id":"fixed-entry-id""#,
r#""kind":"model_output""#,
r#""worker_id":"01a1657d-b762-7791-87ae-a49f28cadbce""#,
r#""domain":"worker.pending_activations.v1""#,
r#""next_activation_sequence":2"#,
r#""submission_request_id":"completed-submission-request""#,
r#""notification_request_id":"completed-notification-request""#,
];
for location in locations {
assert!(original.contains(location));
for extra in [
r#", "reasoning":{"text":"synthetic-discarded-material"}"#,
r#", "encrypted_content":"synthetic-discarded-material""#,
] {
let altered = original.replacen(location, &format!("{location}{extra}"), 1);
assert_rejected_session_bytes(&worker, &template, altered.into_bytes());
}
}
let duplicate = original.replacen(
r#""role":"assistant""#,
r#""role":"assistant","role":"assistant""#,
1,
);
assert_rejected_session_bytes(&worker, &template, duplicate.into_bytes());
}
#[test]
fn opaque_feature_payloads_cannot_bypass_strict_session_validation() {
let (_source, worker, template) = source();
let mut logs = synthetic_logs(&template);
for log in &mut logs {
if let LogEntry::AnnotatedUserInput { history, .. } = log {
if let session_store::LoggedItem::Message { role, .. } = &mut history[0].item {
*role = session_store::LoggedRole::System;
}
history[0].metadata.input_metadata = Some(
session_store::HistoryInputMetadata::new(
session_store::HistoryInputUsePolicy {
recording_material: session_store::InputEvidenceUse::Exclude,
memory_evidence: session_store::InputEvidenceUse::Exclude,
observation: session_store::InputObservationUse::Hidden,
compaction: session_store::InputCompactionUse::Retain,
},
Some(
session_store::FeatureInputPayload::encode(
"fixture.test",
1,
&BTreeMap::from([("reasoning", "harmless synthetic payload")]),
)
.unwrap(),
),
None,
)
.unwrap(),
);
}
}
assert_rejected_session_bytes(&worker, &template, encode_synthetic_logs(&logs));
}
#[cfg(unix)]
#[test]
fn refuses_source_and_destination_symlinks() {
let (_source, worker, template) = source();
let (_target, options) = destination(&template);
std::os::unix::fs::symlink(worker.join(WORKER_FILE), options.root.join("linked")).unwrap();
assert!(template.install(&options, request(), &resolved()).is_err());
fs::remove_file(options.root.join("linked")).unwrap();
std::os::unix::fs::symlink(worker.join(WORKER_FILE), worker.join("runs/link")).unwrap();
assert!(StoppedWorkerTemplate::extract(&worker).is_err());
}
/// Opt-in only: source path supplied explicitly; assertions never display content.
#[test]
#[ignore = "requires the approved artificial lab aggregate via YOI_STOPPED_FIXTURE_SOURCE"]
fn approved_lab_extract_install() {
let path = PathBuf::from(
std::env::var_os("YOI_STOPPED_FIXTURE_SOURCE").expect("explicit source required"),
);
let template = StoppedWorkerTemplate::extract(&path).unwrap();
let (_target, options) = destination(&template);
let mut bind = request();
bind.worker_id = template.worker_id();
bind.workspace_api.as_mut().unwrap().workspace_id = template.workspace_id().into();
bind.memory_settings.as_mut().unwrap().workspace_id = template.workspace_id().into();
let mut manifest = resolved();
manifest.worker.name = template.worker_name();
template.install(&options, bind, &manifest).unwrap();
let installed = options
.root
.join(WORKERS_DIR)
.join(template.worker_id().to_string());
for relative in [
"session/session.json".to_string(),
format!("session/segments/{}.jsonl", template.segment_id()),
] {
assert!(
fs::read(path.join(&relative)).unwrap() == fs::read(installed.join(relative)).unwrap()
);
}
let runtime = crate::Runtime::with_fs_store(options).unwrap();
assert_eq!(runtime.list_workers().unwrap().len(), 1);
assert_eq!(runtime.summary().unwrap().active_worker_count, 0);
}
@@ -1389,6 +1389,15 @@ fn bind_workspace_memory_settings(
Ok(()) Ok(())
} }
/// Read-only validation shared with the offline, feature-gated fixture installer.
#[cfg(feature = "test-support")]
pub(crate) fn validate_fixture_restore_metadata(
metadata: &session_store::WorkerMetadata,
request: &CreateWorkerRequest,
) -> Result<(), String> {
ProfileRuntimeWorkerFactory::manifest_for_restore(metadata, request).map(|_| ())
}
fn validate_worker_memory_settings( fn validate_worker_memory_settings(
manifest: &manifest::WorkerManifest, manifest: &manifest::WorkerManifest,
request: &CreateWorkerRequest, request: &CreateWorkerRequest,
+4 -3
View File
@@ -71,9 +71,10 @@ pub use session_history::{
}; };
pub use shared_state::WorkerSharedState; pub use shared_state::WorkerSharedState;
pub use worker::{ pub use worker::{
LocalWorkingDirectory, SubjektivSessionAttributionLifecycle, Worker, WorkerError, LocalWorkingDirectory, ReviewerChildWorkspaceClient, ReviewerContext,
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest, SubjektivSessionAttributionLifecycle, Worker, WorkerError, WorkerFilesystemAuthority,
WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest, WorkspaceBinaryResponse,
WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest,
marker_workspace_client, unavailable_workspace_client, marker_workspace_client, unavailable_workspace_client,
+4 -1
View File
@@ -834,7 +834,10 @@ mod tests {
assert!(common.contains("target-only movement preserves approval")); assert!(common.contains("target-only movement preserves approval"));
assert!(reviewer.contains("complete linked MR/result snapshot")); assert!(reviewer.contains("complete linked MR/result snapshot"));
assert!(reviewer.contains("Submit `ReviewMergeRequest` once")); assert!(reviewer.contains("Submit `ReviewMergeRequest` once"));
assert!(reviewer.contains("without modifying implementation files")); assert!(reviewer.contains("Bash"));
assert!(reviewer.contains("Git"));
assert!(!reviewer.contains("without modifying"));
assert!(!reviewer.contains("read-only"));
} }
#[test] #[test]
+13 -1
View File
@@ -70,7 +70,8 @@ struct SubWorkerSpawnInput {
/// Explicitly grant command execution through the parent-owned Workdir tool broker. /// Explicitly grant command execution through the parent-owned Workdir tool broker.
#[serde(default)] #[serde(default)]
command: bool, command: bool,
/// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate. /// Binds an actual builtin Reviewer child to the current Merge Request candidate.
/// Bash may be enabled for Git and other commands.
/// Review capability material is generated by the trusted spawn layer. /// Review capability material is generated by the trusted spawn layer.
#[serde(default)] #[serde(default)]
review: Option<ReviewerHandoffInput>, review: Option<ReviewerHandoffInput>,
@@ -1174,6 +1175,17 @@ mod tests {
} }
} }
#[test]
fn reviewer_handoff_schema_allows_bash_for_git_commands() {
let schema = serde_json::to_value(schemars::schema_for!(SubWorkerSpawnInput)).unwrap();
let description = schema["properties"]["review"]["description"]
.as_str()
.expect("review handoff description");
assert!(description.contains("Bash"));
assert!(description.contains("Git"));
assert!(!description.contains("read-only"));
}
#[test] #[test]
fn reviewer_handoff_requires_explicit_builtin_profile_and_writable_scope() { fn reviewer_handoff_requires_explicit_builtin_profile_and_writable_scope() {
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
+65 -9
View File
@@ -1332,15 +1332,28 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
mut request: WorkspaceRequest, mut request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
let workspace_id = self.workspace_id().unwrap_or_default(); let workspace_id = self.workspace_id().unwrap_or_default();
let ticket_id = &self.context.ticket_id;
let ticket_query_path = format!("/api/w/{workspace_id}/tickets/query"); let ticket_query_path = format!("/api/w/{workspace_id}/tickets/query");
let ticket_show_path = format!("/api/w/{workspace_id}/tickets/{ticket_id}/show"); let ticket_show_prefix = format!("/api/w/{workspace_id}/tickets/");
// ShowTicket uses POST for bounded read parameters. Like Ticket query
// and GET reads, it must allow related Tickets through the bound Host's
// read authority. Only review submission is restricted to the attested MR.
// Match one literal reference segment, not arbitrary POST routes.
let is_ticket_show = request
.path
.strip_prefix(&ticket_show_prefix)
.and_then(|path| path.strip_suffix("/show"))
.is_some_and(|reference| {
!reference.is_empty()
&& reference
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
});
let merge_request_id = &self.context.merge_request_id; let merge_request_id = &self.context.merge_request_id;
let review_path = let review_path =
format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}/reviews"); format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}/reviews");
let read_allowed = request.method == WorkspaceRequestMethod::Get let read_allowed = request.method == WorkspaceRequestMethod::Get
|| (request.method == WorkspaceRequestMethod::Post || (request.method == WorkspaceRequestMethod::Post
&& (request.path == ticket_query_path || request.path == ticket_show_path)); && (request.path == ticket_query_path || is_ticket_show));
if request.method == WorkspaceRequestMethod::Post && request.path == review_path { if request.method == WorkspaceRequestMethod::Post && request.path == review_path {
let body = request.body.take().ok_or_else(|| { let body = request.body.take().ok_or_else(|| {
WorkspaceClientError::Request("review submission requires a JSON body".to_string()) WorkspaceClientError::Request("review submission requires a JSON body".to_string())
@@ -1868,16 +1881,11 @@ mod reviewer_client_tests {
} }
#[test] #[test]
fn reviewer_child_client_rejects_other_ticket_and_mutation_posts() { fn reviewer_child_client_rejects_mutation_posts() {
let inner: Arc<dyn WorkspaceClient> = Arc::new(RecordingWorkspaceClient::default()); let inner: Arc<dyn WorkspaceClient> = Arc::new(RecordingWorkspaceClient::default());
let client = reviewer_client(inner); let client = reviewer_client(inner);
for request in [ for request in [
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
"/api/w/ws/tickets/T2/show",
"{}".to_string(),
),
WorkspaceRequest::json( WorkspaceRequest::json(
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
"/api/w/ws/tickets/T1/comments", "/api/w/ws/tickets/T1/comments",
@@ -1894,6 +1902,54 @@ mod reviewer_client_tests {
} }
} }
#[test]
fn reviewer_ticket_reads_reject_route_injection_and_mutations_without_transport() {
let inner = Arc::new(RecordingWorkspaceClient::default());
let client = reviewer_client(inner.clone());
for path in [
"/api/w/other/tickets/T-1/show",
"/api/w/ws/tickets//show",
"/api/w/ws/tickets/T-1/comments",
"/api/w/ws/tickets/T-1/posts",
"/api/w/ws/tickets/T-1/show/",
"/api/w/ws/tickets/T-1/show?query=1",
"/api/w/ws/tickets/T-1?query=1/show",
"/api/w/ws/tickets/T-1#fragment/show",
"/api/w/ws/tickets/../show",
"/api/w/ws/tickets/T-1/../T-2/show",
"/api/w/ws/tickets/T%2D1/show",
"/api/w/ws/tickets/T-1%2fcomments/show",
"/api/w/ws/merge-requests/MR2/reviews",
] {
assert!(
client
.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
path,
"{}",
))
.is_err(),
"{path}"
);
}
for method in [
WorkspaceRequestMethod::Put,
WorkspaceRequestMethod::Patch,
WorkspaceRequestMethod::Delete,
] {
assert!(
client
.execute(WorkspaceRequest::json(
method,
"/api/w/ws/tickets/T-1/show",
"{}"
))
.is_err()
);
}
assert!(inner.requests.lock().unwrap().is_empty());
}
#[test] #[test]
fn reviewer_child_client_injects_capability_only_for_attested_review() { fn reviewer_child_client_injects_capability_only_for_attested_review() {
let inner = Arc::new(RecordingWorkspaceClient::default()); let inner = Arc::new(RecordingWorkspaceClient::default());
+4
View File
@@ -10,6 +10,10 @@ autobins = false
name = "yoi-server" name = "yoi-server"
path = "src/main.rs" path = "src/main.rs"
[features]
# Offline fixture preparation only; build product binaries without this feature.
test-support = []
[dependencies] [dependencies]
api-macros = { workspace = true, features = ["axum"] } api-macros = { workspace = true, features = ["axum"] }
async-trait.workspace = true async-trait.workspace = true
@@ -476,7 +476,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(99, "Explicit hosting administrators and self-registration policy".into()), (99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into()), (100, "Provisional users and scoped passkey activation".into()),
(101, "Durable explicit Subject body release".into()), (101, "Durable explicit Subject body release".into()),
(102, "Durable failed Subject creation recovery".into()) (102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into())
] ]
); );
for (table, rows) in retained.iter().zip(original.iter()) { for (table, rows) in retained.iter().zip(original.iter()) {
+17 -1
View File
@@ -228,13 +228,21 @@ fn resolve_whoami_sync(
use server_api::{BrowserSessionState, WhoamiResponse}; use server_api::{BrowserSessionState, WhoamiResponse};
let anonymous = || WhoamiResponse { let anonymous = || WhoamiResponse {
actor: None, actor: None,
authentication_reference: None,
session: BrowserSessionState::Anonymous {}, session: BrowserSessionState::Anonymous {},
}; };
// Preserve the existing API-token precedence and fallback rules, including expired tokens. // Preserve the existing API-token precedence and fallback rules, including expired tokens.
if let Some((actor, expires_at)) = if let Some((actor, expires_at)) =
resolve_credentials_sync(store, bearer, session_token.clone())? resolve_credentials_sync(store, bearer.clone(), session_token.clone())?
{ {
let credential = match actor.auth_method {
ActorAuthMethod::ApiToken => bearer.as_deref(),
ActorAuthMethod::BrowserSession => session_token.as_deref(),
}
.ok_or_else(|| auth_error("auth_required", "authentication binding missing"))?;
let authentication_reference = Some(authentication_reference(&token_hash(credential)));
return Ok(WhoamiResponse { return Ok(WhoamiResponse {
authentication_reference,
actor: Some(actor), actor: Some(actor),
session: BrowserSessionState::Authenticated { expires_at }, session: BrowserSessionState::Authenticated { expires_at },
}); });
@@ -255,6 +263,7 @@ fn resolve_whoami_sync(
if within_session_expiry_grace(&session.expires_at, Utc::now()) { if within_session_expiry_grace(&session.expires_at, Utc::now()) {
return Ok(WhoamiResponse { return Ok(WhoamiResponse {
actor: None, actor: None,
authentication_reference: None,
session: BrowserSessionState::Expired {}, session: BrowserSessionState::Expired {},
}); });
} }
@@ -262,6 +271,13 @@ fn resolve_whoami_sync(
Ok(anonymous()) Ok(anonymous())
} }
/// Domain-separated public comparison value; never accepted as a credential.
pub(crate) fn authentication_reference(credential_hash: &str) -> String {
token_hash(&format!(
"passkey-authentication-reference:{credential_hash}"
))
}
fn within_session_expiry_grace(expires_at: &str, now: DateTime<Utc>) -> bool { fn within_session_expiry_grace(expires_at: &str, now: DateTime<Utc>) -> bool {
DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| { DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| {
expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS) expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS)
+8 -1
View File
@@ -8866,7 +8866,14 @@ mod tests {
}) })
.await .await
.expect_err("closed session must reject local operation without another request"); .expect_err("closed session must reject local operation without another request");
assert!(matches!(error, WorkdirError::Unavailable(_))); assert!(matches!(
error.classification_source(),
WorkdirError::Unavailable(_)
));
assert_eq!(
error.failure_reason(),
Some(workdir::WorkdirFailureReason::SessionClosed)
);
server.join().expect("mock remote server finished"); server.join().expect("mock remote server finished");
} }
@@ -381,6 +381,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_results;
DROP TABLE ticket_implementation_delegations; DROP TABLE ticket_implementation_delegations;
DROP TABLE merge_request_source_observations; DROP TABLE merge_request_source_observations;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
DROP TABLE subject_create_recoveries; DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102; UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases; DROP TABLE subject_worker_releases;
@@ -517,6 +521,10 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
// Reconstruct the prior composition before its new business capture existed. // Reconstruct the prior composition before its new business capture existed.
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture; conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
DROP TABLE subject_create_recoveries; DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102; UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases; DROP TABLE subject_worker_releases;
@@ -679,6 +687,10 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
if historical { if historical {
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture; conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
DROP TABLE subject_create_recoveries; DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102; UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases; DROP TABLE subject_worker_releases;
+2
View File
@@ -25,6 +25,8 @@ pub mod runtime_subscription;
pub mod server; pub mod server;
pub mod skills; pub mod skills;
pub mod store; pub mod store;
#[cfg(feature = "test-support")]
pub use store::fixture_support as test_support;
pub mod subjektiv; pub mod subjektiv;
#[cfg(test)] #[cfg(test)]
mod ticket_browse_performance_tests; mod ticket_browse_performance_tests;
@@ -0,0 +1,18 @@
CREATE TABLE passkey_metadata (
credential_id TEXT PRIMARY KEY REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
key_reference TEXT NOT NULL UNIQUE,
normal_login_verified_at TEXT
);
INSERT INTO passkey_metadata(credential_id, key_reference)
SELECT credential_id, lower(hex(randomblob(16))) FROM passkey_credentials;
CREATE TRIGGER passkey_metadata_insert AFTER INSERT ON passkey_credentials
BEGIN
INSERT INTO passkey_metadata(credential_id, key_reference)
VALUES (NEW.credential_id, lower(hex(randomblob(16))));
END;
CREATE TABLE passkey_addition_receipts (
challenge_id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL UNIQUE REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
authentication_reference TEXT NOT NULL
);
+200 -7
View File
@@ -6970,6 +6970,12 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
.merge(server_api::server_api_axum::hosting_settings_update( .merge(server_api::server_api_axum::hosting_settings_update(
service.clone(), service.clone(),
)) ))
.merge(server_api::server_api_axum::auth_passkey_list(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_addition_result(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_addition_options( .merge(server_api::server_api_axum::auth_passkey_addition_options(
service.clone(), service.clone(),
)) ))
@@ -8179,6 +8185,65 @@ impl server_api::ServerApi for ServerApiContractService {
) )
} }
async fn auth_passkey_list(
&self,
context: server_api::ServerRequestContext,
) -> std::result::Result<server_api::PasskeyListResponse, server_api::RepositoryApiError> {
let headers = contract_request_headers(&context)?;
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let (user, _) = passkey_owner_authority(&api, &headers, false)?;
Ok::<_, ApiError>(server_api::PasskeyListResponse {
keys: api.store.list_passkey_summaries(&user.user_id)?,
user: user_response(user),
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn auth_passkey_addition_result(
&self,
context: server_api::ServerRequestContext,
request: server_api::PasskeyAdditionResultRequest,
) -> std::result::Result<
server_api::PasskeyAdditionResultResponse,
server_api::RepositoryApiError,
> {
let headers = contract_request_headers(&context)?;
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let (user, _) = passkey_addition_authority(&api, &headers)?;
if user.user_id != request.expected_user_id
|| user.account_id != request.expected_account_id
{
return Err(auth_error(
"passkey_authentication_changed",
"current authenticated owner does not match the operation owner",
)
.into());
}
let key = api.store.read_passkey_addition_result(
&user.user_id,
&request.expected_authentication_reference,
&request.challenge_id,
)?;
Ok::<_, ApiError>(match key {
Some(key) => server_api::PasskeyAdditionResultResponse::Saved {
user: user_response(user),
key,
},
None => server_api::PasskeyAdditionResultResponse::Unknown {},
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn auth_passkey_addition_options( async fn auth_passkey_addition_options(
&self, &self,
context: server_api::ServerRequestContext, context: server_api::ServerRequestContext,
@@ -8191,6 +8256,13 @@ impl server_api::ServerApi for ServerApiContractService {
.db_executor() .db_executor()
.run(move || { .run(move || {
let (user, authentication) = passkey_addition_authority(&api, &headers)?; let (user, authentication) = passkey_addition_authority(&api, &headers)?;
require_expected_passkey_owner(
&user,
&authentication,
&request.expected_user_id,
&request.expected_account_id,
&request.expected_authentication_reference,
)?;
start_passkey_registration( start_passkey_registration(
&api, &api,
&headers, &headers,
@@ -30161,6 +30233,14 @@ async fn post_passkey_registration_options(
fn passkey_addition_authority( fn passkey_addition_authority(
api: &ServerAuthApi, api: &ServerAuthApi,
headers: &HeaderMap, headers: &HeaderMap,
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
passkey_owner_authority(api, headers, true)
}
fn passkey_owner_authority(
api: &ServerAuthApi,
headers: &HeaderMap,
require_origin: bool,
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> { ) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
let auth = auth_public_config(&api.config); let auth = auth_public_config(&api.config);
let actor = crate::auth::resolve_request_actor_sync( let actor = crate::auth::resolve_request_actor_sync(
@@ -30176,7 +30256,7 @@ fn passkey_addition_authority(
})?; })?;
let binding = match actor.auth_method { let binding = match actor.auth_method {
ActorAuthMethod::BrowserSession => { ActorAuthMethod::BrowserSession => {
if request_origin(headers).as_deref() != Some(auth.origin.as_str()) { if require_origin && request_origin(headers).as_deref() != Some(auth.origin.as_str()) {
return Err(Error::WorkspacePermissionDenied( return Err(Error::WorkspacePermissionDenied(
"cookie-authenticated passkey addition requires the configured Browser origin" "cookie-authenticated passkey addition requires the configured Browser origin"
.into(), .into(),
@@ -30208,6 +30288,26 @@ fn passkey_addition_authority(
Ok((user, binding)) Ok((user, binding))
} }
fn require_expected_passkey_owner(
user: &UserRecord,
binding: &PasskeyAuthenticationBinding,
expected_user_id: &str,
expected_account_id: &str,
expected_authentication_reference: &str,
) -> ApiResult<()> {
if user.user_id != expected_user_id
|| user.account_id != expected_account_id
|| binding.reference() != expected_authentication_reference
{
return Err(auth_error(
"passkey_authentication_changed",
"authentication changed; refresh account before starting another operation",
)
.into());
}
Ok(())
}
fn start_passkey_registration( fn start_passkey_registration(
api: &ServerAuthApi, api: &ServerAuthApi,
headers: &HeaderMap, headers: &HeaderMap,
@@ -30289,7 +30389,11 @@ fn start_passkey_registration(
/// Credential commit precedes session issuance. If issuing the session or /// Credential commit precedes session issuance. If issuing the session or
/// delivering the response fails, the credential can already exist: log in with /// delivering the response fails, the credential can already exist: log in with
/// that key rather than overwriting it or replaying the consumed completion. /// that key rather than overwriting it or replaying the consumed completion.
/// After an unknown commit outcome, try login first. Restart new-account /// Own additions save a receipt atomically with the credential: a currently
/// authenticated same owner can inspect it using the original operation binding
/// reference even if the response body was lost after Set-Cookie took effect.
/// This read does not authorize a completion or prove current session health.
/// For unknown new-account outcomes, try login first. Restart new-account
/// enrollment with fresh options only for an unused handle with no persisted /// enrollment with fresh options only for an unused handle with no persisted
/// identity; legacy pre-created identities follow the migration policy on /// identity; legacy pre-created identities follow the migration policy on
/// [`PasskeyRegistrationEnvelope`]. Neither recovery path requires DB edits. /// [`PasskeyRegistrationEnvelope`]. Neither recovery path requires DB edits.
@@ -30313,6 +30417,7 @@ fn complete_passkey_enrollment(
request: PasskeyRegistrationCompleteRequest, request: PasskeyRegistrationCompleteRequest,
completion: EnrollmentCompletion<'_>, completion: EnrollmentCompletion<'_>,
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { ) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
let is_addition = matches!(&completion, EnrollmentCompletion::Addition(_));
let authority = match completion { let authority = match completion {
EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?), EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?),
_ => None, _ => None,
@@ -30398,7 +30503,7 @@ fn complete_passkey_enrollment(
&error.to_string(), &error.to_string(),
) )
})?; })?;
api.store.register_verified_passkey( api.store.register_verified_passkey_with_result(
&envelope.target, &envelope.target,
&PasskeyCredentialRecord { &PasskeyCredentialRecord {
credential_id: passkey_credential_id(&passkey)?, credential_id: passkey_credential_id(&passkey)?,
@@ -30411,6 +30516,7 @@ fn complete_passkey_enrollment(
created_at: crate::auth::now_rfc3339(), created_at: crate::auth::now_rfc3339(),
last_used_at: None, last_used_at: None,
}, },
is_addition.then_some(request.challenge_id.as_str()),
)?; )?;
issue_browser_session(api, user) issue_browser_session(api, user)
} }
@@ -30580,6 +30686,7 @@ fn complete_passkey_login(
.into()); .into());
} }
let webauthn = webauthn_for_challenge(&api.config, &challenge)?; let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
let normal_login = matches!(state, PasskeyLoginState::Discoverable(_));
let auth_result = match state { let auth_result = match state {
PasskeyLoginState::Discoverable(state) => { PasskeyLoginState::Discoverable(state) => {
// userHandle is unsigned routing data, never identity authority. The // userHandle is unsigned routing data, never identity authority. The
@@ -30635,7 +30742,7 @@ fn complete_passkey_login(
.map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?; .map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?;
api.store api.store
.upsert_passkey_credential(&PasskeyCredentialRecord { .upsert_passkey_credential(&PasskeyCredentialRecord {
credential_id, credential_id: credential_id.clone(),
user_id: user.user_id.clone(), user_id: user.user_id.clone(),
public_key_cose, public_key_cose,
transports_json: stored.transports_json, transports_json: stored.transports_json,
@@ -30643,6 +30750,10 @@ fn complete_passkey_login(
created_at: stored.created_at, created_at: stored.created_at,
last_used_at: Some(crate::auth::now_rfc3339()), last_used_at: Some(crate::auth::now_rfc3339()),
})?; })?;
if normal_login {
api.store
.mark_passkey_normal_login_verified(&credential_id, &crate::auth::now_rfc3339())?;
}
issue_browser_session(api, user) issue_browser_session(api, user)
} }
@@ -30668,6 +30779,9 @@ fn issue_browser_session(
Ok(( Ok((
AuthUserResponse { AuthUserResponse {
user: user_response(user), user: user_response(user),
authentication_reference: crate::auth::authentication_reference(&token_hash(
&session_token,
)),
}, },
server_api::SetCookieHeader::new(cookie), server_api::SetCookieHeader::new(cookie),
)) ))
@@ -37791,9 +37905,13 @@ mod tests {
))) )))
.unwrap(); .unwrap();
} }
let error = write
.await
.unwrap()
.expect_err("mismatched or lost mutation response must remain outcome-unknown");
assert!(matches!( assert!(matches!(
write.await.unwrap(), error.classification_source(),
Err(workdir::WorkdirError::OutcomeUnknown(_)) workdir::WorkdirError::OutcomeUnknown(_)
)); ));
} }
} }
@@ -52397,6 +52515,7 @@ mod tests {
include!("server_activation_tests.rs"); include!("server_activation_tests.rs");
include!("server_discoverable_tests.rs"); include!("server_discoverable_tests.rs");
include!("server_passkey_results_tests.rs");
const ORIGIN_URL: &str = "https://workspace.test"; const ORIGIN_URL: &str = "https://workspace.test";
type Authenticator = ResidentAuthenticator; type Authenticator = ResidentAuthenticator;
@@ -52450,9 +52569,61 @@ mod tests {
origin: Option<&str>, origin: Option<&str>,
body: Value, body: Value,
) -> (StatusCode, HeaderMap, Value) { ) -> (StatusCode, HeaderMap, Value) {
let mut body = body;
if path == "/api/auth/passkeys/addition/options"
&& body.get("expected_user_id").is_none()
{
let expected = self.expected_owner(cookie, bearer).await;
for (field, value) in expected.as_object().unwrap() {
body[field] = value.clone();
}
}
Self::post_to(self.app.clone(), path, cookie, bearer, origin, body).await Self::post_to(self.app.clone(), path, cookie, bearer, origin, body).await
} }
async fn get_authenticated(
&self,
path: &str,
cookie: Option<&str>,
bearer: Option<&str>,
) -> (StatusCode, Value) {
let mut request = Request::builder().uri(path);
if let Some(cookie) = cookie {
request = request.header(axum::http::header::COOKIE, cookie);
}
if let Some(bearer) = bearer {
request = request.header(
axum::http::header::AUTHORIZATION,
format!("Bearer {bearer}"),
);
}
let response = self
.app
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
let status = response.status();
let body = serde_json::from_slice(
&to_bytes(response.into_body(), usize::MAX).await.unwrap(),
)
.unwrap();
(status, body)
}
async fn expected_owner(&self, cookie: Option<&str>, bearer: Option<&str>) -> Value {
let (status, whoami) = self
.get_authenticated("/api/auth/whoami", cookie, bearer)
.await;
assert_eq!(status, StatusCode::OK);
serde_json::json!({
"expected_user_id": whoami["actor"]["user_id"].as_str().unwrap_or("absent"),
"expected_account_id": whoami["actor"]["account_id"].as_str().unwrap_or("absent"),
"expected_authentication_reference": whoami["authentication_reference"].as_str().unwrap_or("absent"),
})
}
async fn post_to( async fn post_to(
app: Router, app: Router,
path: &str, path: &str,
@@ -52608,6 +52779,14 @@ mod tests {
.next() .next()
.unwrap() .unwrap()
.to_string(); .to_string();
let (status, whoami) = self
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
body["authentication_reference"] == whoami["authentication_reference"],
"completion must identify its exact issued session"
);
( (
cookie, cookie,
serde_json::from_value::<AuthUserResponse>(body) serde_json::from_value::<AuthUserResponse>(body)
@@ -52695,6 +52874,14 @@ mod tests {
.next() .next()
.unwrap() .unwrap()
.to_string(); .to_string();
let (status, whoami) = self
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
body["authentication_reference"] == whoami["authentication_reference"],
"login completion must identify its exact issued session"
);
( (
cookie, cookie,
serde_json::from_value::<AuthUserResponse>(body) serde_json::from_value::<AuthUserResponse>(body)
@@ -53218,6 +53405,9 @@ mod tests {
.auth_passkey_addition_options( .auth_passkey_addition_options(
context, context,
server_api::PasskeyAdditionOptionsRequest { server_api::PasskeyAdditionOptionsRequest {
expected_user_id: user.user_id.clone(),
expected_account_id: user.account_id.clone(),
expected_authentication_reference: "not-authority".into(),
browser_origin: Some(ORIGIN_URL.into()), browser_origin: Some(ORIGIN_URL.into()),
}, },
) )
@@ -53232,6 +53422,9 @@ mod tests {
.auth_passkey_addition_options( .auth_passkey_addition_options(
context, context,
server_api::PasskeyAdditionOptionsRequest { server_api::PasskeyAdditionOptionsRequest {
expected_user_id: user.user_id.clone(),
expected_account_id: user.account_id.clone(),
expected_authentication_reference: "not-authority".into(),
browser_origin: Some(ORIGIN_URL.into()), browser_origin: Some(ORIGIN_URL.into()),
}, },
) )
@@ -54237,7 +54430,7 @@ mod tests {
.header(axum::http::header::COOKIE, &marker) .header(axum::http::header::COOKIE, &marker)
.header(ORIGIN, &auth.origin) .header(ORIGIN, &auth.origin)
.header(CONTENT_TYPE, "application/json") .header(CONTENT_TYPE, "application/json")
.body(Body::from("{}")) .body(Body::from(r#"{"expected_user_id":"absent","expected_account_id":"absent","expected_authentication_reference":"absent"}"#))
.unwrap(), .unwrap(),
) )
.await .await
@@ -18,6 +18,57 @@ const EVIDENCE_MR: &str = "ticket-evidence-mr";
const EVIDENCE_SOURCE: &str = "immutable-approved-source"; const EVIDENCE_SOURCE: &str = "immutable-approved-source";
const EVIDENCE_TARGET: &str = "immutable-target-result"; const EVIDENCE_TARGET: &str = "immutable-target-result";
#[tokio::test]
async fn integration_preserves_legacy_empty_post_ref_and_exposes_new_ref_with_server_schema() {
let fixture = EvidenceApiFixture::new().await;
let approval = fixture.approve("integration-post-ref");
let connection = Connection::open(&fixture.api.config.database_path).unwrap();
let legacy_index: i64 = connection.query_row(
"SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",
params![TEST_WORKSPACE_ID, fixture.ticket_id], |row| row.get(0),
).unwrap();
connection.execute(
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
VALUES(?1,?2,?3,'','comment','Legacy integration')",
params![TEST_WORKSPACE_ID, fixture.ticket_id, legacy_index],
).unwrap();
let merged = fixture.integrate(&approval);
assert_eq!(fixture.integrate(&approval), merged);
let detail = fixture
.backend
.show(fixture.ticket_id.clone().into())
.unwrap();
let legacy = detail
.events
.iter()
.find(|event| event.body.as_str() == "Legacy integration")
.unwrap();
assert_eq!(legacy.event_ref, "");
let integrations = detail
.events
.iter()
.filter(|event| {
event.attributes.get("merge_request_id").map(String::as_str) == Some(EVIDENCE_MR)
})
.collect::<Vec<_>>();
assert_eq!(
integrations.len(),
1,
"completion replay must not duplicate the Ticket post"
);
assert_eq!(
integrations[0].event_ref,
format!("{}:{}", fixture.ticket_id, legacy_index + 1)
);
assert_eq!(
integrations[0].attributes["operation_id"],
"evidence-integration"
);
let shown = fixture.show().await;
assert_eq!(shown.merge_requests[0].state, "merged");
}
#[tokio::test] #[tokio::test]
async fn ticket_browse_does_not_observe_sources_but_authoritative_query_and_show_still_do() { async fn ticket_browse_does_not_observe_sources_but_authoritative_query_and_show_still_do() {
struct CountingObserver(Arc<std::sync::atomic::AtomicUsize>); struct CountingObserver(Arc<std::sync::atomic::AtomicUsize>);
@@ -0,0 +1,399 @@
// Integration boundary: generated HTTP admission -> real WebAuthn verifier ->
// SQLite credential/receipt transaction -> real session issuance. Only the
// authenticator is synthetic; failures are SQLite triggers, not success fakes.
impl Fixture {
async fn addition_result(&self, cookie: &str, challenge_id: &str) -> (StatusCode, Value) {
let mut request = self.expected_owner(Some(cookie), None).await;
request["challenge_id"] = challenge_id.into();
let (status, _, body) = self
.post(
"/api/auth/passkeys/addition/result",
Some(cookie),
Some(ORIGIN_URL),
request,
)
.await;
(status, body)
}
}
#[tokio::test]
async fn addition_receipt_is_atomic_and_recovers_credential_commit_not_session_success() {
for failure in [
"before_credential",
"receipt_insert",
"session_insert",
"lost_response",
"lost_body_accepted_cookie",
] {
let f = Fixture::new().await;
let mut original = authenticator();
let (cookie, owner) = f.enroll("registration", None, "owner", &mut original).await;
let original_expectation = f.expected_owner(Some(&cookie), None).await;
let options = f.options("addition", Some(&cookie), "").await;
let challenge_id = options.challenge_id.clone();
let mut added = authenticator();
let request = Fixture::credential(options, &mut added);
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], "unknown");
let table = match failure {
"before_credential" => Some("passkey_credentials"),
"receipt_insert" => Some("passkey_addition_receipts"),
"session_insert" => Some("browser_sessions"),
_ => None,
};
if let Some(table) = table {
f.api.config_store.with_conn(|conn| {
conn.execute_batch(&format!("CREATE TRIGGER inject_passkey_failure BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END;"))?;
Ok(())
}).unwrap();
}
// Discard the completion body; one variant still accepts Set-Cookie.
let (status, headers, _) = f
.post(
"/api/auth/passkeys/addition/complete",
Some(&cookie),
Some(ORIGIN_URL),
request.clone(),
)
.await;
assert_eq!(
status,
if matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
StatusCode::OK
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
);
if !matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
}
let saved = matches!(
failure,
"session_insert" | "lost_response" | "lost_body_accepted_cookie"
);
if failure == "lost_body_accepted_cookie" {
let accepted_cookie = headers[axum::http::header::SET_COOKIE]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap();
let current = f.expected_owner(Some(accepted_cookie), None).await;
assert!(
current["expected_authentication_reference"]
!= original_expectation["expected_authentication_reference"],
"addition issued a distinct session"
);
let mut recovery = original_expectation.clone();
recovery["challenge_id"] = challenge_id.clone().into();
let (status, _, result) = f
.post(
"/api/auth/passkeys/addition/result",
Some(accepted_cookie),
Some(ORIGIN_URL),
recovery,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], "saved");
assert_eq!(result["user"]["user_id"], owner.user_id);
}
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], if saved { "saved" } else { "unknown" });
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
let receipt_count = f
.api
.config_store
.with_conn(|conn| {
Ok(conn.query_row(
"SELECT COUNT(*) FROM passkey_addition_receipts",
[],
|row| row.get::<_, u64>(0),
)?)
})
.unwrap();
assert_eq!(receipt_count, u64::from(saved));
if saved {
assert_eq!(result["user"]["user_id"], owner.user_id);
let (_, listed) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
let reference = result["key"]["key_reference"].as_str().unwrap();
assert!(
listed["keys"]
.as_array()
.unwrap()
.iter()
.any(|key| key["key_reference"] == reference)
);
assert!(result["key"]["normal_login_verified_at"].is_null());
}
if table.is_some() {
f.api
.config_store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER inject_passkey_failure")?;
Ok(())
})
.unwrap();
}
// Consumed completion is not retry authority, even after a transaction failure.
let (status, headers, _) = f
.post(
"/api/auth/passkeys/addition/complete",
Some(&cookie),
Some(ORIGIN_URL),
request,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
if saved {
assert_eq!(f.discoverable_login(&mut added).await, owner);
}
}
}
#[tokio::test]
async fn stale_displayed_owner_or_session_cannot_start_addition_or_read_another_binding_result() {
let f = Fixture::new().await;
let mut alice_key = authenticator();
let (alice_cookie, alice) = f
.enroll("registration", None, "alice", &mut alice_key)
.await;
let (bob_cookie, _) = f
.enroll("registration", None, "bob", &mut authenticator())
.await;
let expected = f.expected_owner(Some(&alice_cookie), None).await;
let (replacement_cookie, _) = f.login_session("alice", &mut alice_key).await;
let count_challenges = || {
f.api
.config_store
.with_conn(|conn| {
Ok(
conn.query_row("SELECT COUNT(*) FROM auth_challenges", [], |row| {
row.get::<_, u64>(0)
})?,
)
})
.unwrap()
};
let before = count_challenges();
for cookie in [&bob_cookie, &replacement_cookie] {
let (status, _, _) = f
.post(
"/api/auth/passkeys/addition/options",
Some(cookie),
Some(ORIGIN_URL),
expected.clone(),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
count_challenges(),
before,
"stale options must not persist a ceremony"
);
}
let options = f.options("addition", Some(&alice_cookie), "").await;
let challenge_id = options.challenge_id.clone();
let request = Fixture::credential(options, &mut authenticator());
assert_eq!(
f.post(
"/api/auth/passkeys/addition/complete",
Some(&alice_cookie),
Some(ORIGIN_URL),
request
)
.await
.0,
StatusCode::OK
);
assert_eq!(
f.addition_result(&alice_cookie, &challenge_id).await.1["status"],
"saved"
);
// Current owner authorization is separate from original operation binding.
let mut recovery = expected.clone();
recovery["challenge_id"] = challenge_id.clone().into();
let (status, _, result) = f
.post(
"/api/auth/passkeys/addition/result",
Some(&replacement_cookie),
Some(ORIGIN_URL),
recovery.clone(),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], "saved");
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
Some(&bob_cookie),
Some(ORIGIN_URL),
recovery.clone()
)
.await
.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
None,
Some(ORIGIN_URL),
recovery.clone()
)
.await
.0,
StatusCode::FORBIDDEN
);
recovery["expected_authentication_reference"] = "changed-original-reference".into();
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
Some(&replacement_cookie),
Some(ORIGIN_URL),
recovery
)
.await
.0,
StatusCode::BAD_REQUEST
);
// Bob's own expected fields cannot discover Alice's operation either.
let (status, result) = f.addition_result(&bob_cookie, &challenge_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result, serde_json::json!({"status":"unknown"}));
assert_eq!(f.credentials(&alice).len(), 2);
assert_eq!(
f.post(
"/api/auth/logout",
Some(&alice_cookie),
Some(ORIGIN_URL),
serde_json::json!({})
)
.await
.0,
StatusCode::OK
);
let mut request = expected;
request["challenge_id"] = challenge_id.into();
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
Some(&alice_cookie),
Some(ORIGIN_URL),
request
)
.await
.0,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn own_key_list_is_safe_and_only_verified_normal_login_marks_confirmation() {
let f = Fixture::new().await;
let mut key = authenticator();
let (cookie, owner) = f.enroll("registration", None, "owner", &mut key).await;
let (other_cookie, _) = f
.enroll("registration", None, "other", &mut authenticator())
.await;
assert_eq!(
f.get_authenticated("/api/auth/passkeys", None, None)
.await
.0,
StatusCode::FORBIDDEN
);
let (status, list) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list["user"]["user_id"], owner.user_id);
assert_eq!(list["keys"].as_array().unwrap().len(), 1);
let summary = &list["keys"][0];
assert!(summary["normal_login_verified_at"].is_null());
assert!(summary["last_used_at"].is_null());
let reference = summary["key_reference"].as_str().unwrap();
assert_eq!(reference.len(), 32);
assert_ne!(reference, f.credentials(&owner)[0].credential_id);
assert_eq!(
summary
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect::<std::collections::BTreeSet<_>>(),
[
"key_reference",
"created_at",
"last_used_at",
"normal_login_verified_at"
]
.into_iter()
.collect()
);
let (_, other) = f
.get_authenticated("/api/auth/passkeys", Some(&other_cookie), None)
.await;
assert_ne!(other["keys"][0]["key_reference"], reference);
assert_eq!(f.login("owner", &mut key).await, owner);
let (_, legacy) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
assert!(legacy["keys"][0]["last_used_at"].is_string());
assert!(legacy["keys"][0]["normal_login_verified_at"].is_null());
// Rejected normal assertion does not produce confirmation metadata.
let mut invalid = f.discoverable_request(&mut key).await;
invalid.credential.response.signature = vec![0u8; 64].into();
assert_eq!(
f.post(
"/api/auth/passkeys/login/complete",
None,
Some(ORIGIN_URL),
serde_json::to_value(invalid).unwrap()
)
.await
.0,
StatusCode::BAD_REQUEST
);
assert!(
f.api.store.list_passkey_summaries(&owner.user_id).unwrap()[0]
.normal_login_verified_at
.is_none()
);
assert_eq!(f.discoverable_login(&mut key).await, owner);
let (_, confirmed) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
assert_eq!(confirmed["keys"][0]["key_reference"], reference);
assert!(confirmed["keys"][0]["normal_login_verified_at"].is_string());
}
#[tokio::test]
async fn unknown_transport_metadata_does_not_discard_verified_registration() {
let f = Fixture::new().await;
let options = f.options("registration", None, "future-transport").await;
let mut key = authenticator();
let mut request = Fixture::credential(options, &mut key);
request["credential"]["response"]["transports"] =
serde_json::json!(["internal", "future-transport", "test"]);
let (status, _, body) = f
.post(
"/api/auth/passkeys/registration/complete",
None,
Some(ORIGIN_URL),
request,
)
.await;
assert_eq!(status, StatusCode::OK);
let user = serde_json::from_value::<AuthUserResponse>(body)
.unwrap()
.user;
assert_eq!(f.discoverable_login(&mut key).await, user);
}
@@ -120,6 +120,14 @@ struct TicketSurface {
} }
impl TicketSurface { impl TicketSurface {
async fn new(root: &Path, client: Arc<TicketRouterClient>, wip: bool) -> Self { async fn new(root: &Path, client: Arc<TicketRouterClient>, wip: bool) -> Self {
Self::with_access(root, client, wip, TicketFeatureAccess::workflow()).await
}
async fn with_access(
root: &Path,
client: Arc<dyn WorkspaceClient>,
wip: bool,
access: TicketFeatureAccess,
) -> Self {
let manifest = worker::WorkerManifest::from_toml("[worker]\nname=\"ticket-tools\"\n[model]\nscheme=\"anthropic\"\nmodel_id=\"unused\"\n[engine]\n[scope]\nallow=[]\n").unwrap(); let manifest = worker::WorkerManifest::from_toml("[worker]\nname=\"ticket-tools\"\n[model]\nscheme=\"anthropic\"\nmodel_id=\"unused\"\n[engine]\n[scope]\nallow=[]\n").unwrap();
let engine = let engine =
agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated( agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
@@ -138,19 +146,15 @@ impl TicketSurface {
) )
.await .await
.unwrap(); .unwrap();
let report = let report = host.install_features(
host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module( worker::feature::FeatureRegistryBuilder::new()
TicketFeature::new(client.clone(), TicketFeatureAccess::workflow()), .with_module(TicketFeature::new(client.clone(), access)),
)); );
assert!(!report.has_errors()); assert!(!report.has_errors());
let handle = host.engine().tool_server_handle(); let handle = host.engine().tool_server_handle();
handle.flush_pending(); handle.flush_pending();
let tools = [ let tools = access
"TicketAssignmentTrackingQuery", .tool_names()
"TicketAssignmentAnswer",
"TicketImplementationResult",
"TicketImplementationResultQuery",
]
.into_iter() .into_iter()
.map(|name| (name.into(), handle.get_tool(name).unwrap().1)) .map(|name| (name.into(), handle.get_tool(name).unwrap().1))
.collect(); .collect();
@@ -162,7 +166,7 @@ impl TicketSurface {
worker::feature::builtin::ticket::mount_workspace_http_ticket_wip( worker::feature::builtin::ticket::mount_workspace_http_ticket_wip(
&mut mounts, &mut mounts,
client, client,
TicketFeatureAccess::workflow(), access,
None, None,
&route, &route,
) )
@@ -189,8 +193,9 @@ impl TicketSurface {
if !self.wip { if !self.wip {
return execute_tool(self.tools[name].clone(), input, context).await; return execute_tool(self.tools[name].clone(), input, context).await;
} }
let path = format!("/tickets/{}", input["ticket"].as_str().unwrap()); let reference_field = if name == "ShowTicket" { "id" } else { "ticket" };
input.as_object_mut().unwrap().remove("ticket"); let path = format!("/tickets/{}", input[reference_field].as_str().unwrap());
input.as_object_mut().unwrap().remove(reference_field);
// Observe the real item representation before invoking a mutation. Tracking // Observe the real item representation before invoking a mutation. Tracking
// reads alone do not establish the Ticket WIP object validator. Explicit // reads alone do not establish the Ticket WIP object validator. Explicit
// refresh also reconciles stale observations after an unknown result; // refresh also reconciles stale observations after an unknown result;
@@ -201,6 +206,9 @@ impl TicketSurface {
ToolExecutionContext::direct(), ToolExecutionContext::direct(),
) )
.await?; .await?;
if name == "ShowTicket" {
return execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":"read","arguments":input}), context).await;
}
execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":"read","arguments":{}}), ToolExecutionContext::direct()).await?; execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":"read","arguments":{}}), ToolExecutionContext::direct()).await?;
let inspected = execute_tool( let inspected = execute_tool(
self.tools["Inspect"].clone(), self.tools["Inspect"].clone(),
@@ -364,6 +372,210 @@ async fn ticket_public_roundtrip_observed_answer_replays_without_changing_waitin
} }
} }
// Reviewer policy, tool projection, reference resolution, authentication and
// persistence remain production code. Only the synchronous HTTP transport is
// replaced by signed in-process router dispatch. Child spawning and MR attestation
// have their own suites; this fixture does not claim to exercise those boundaries.
fn reviewer_client(inner: Arc<TicketRouterClient>, bound_ticket: &str) -> Arc<dyn WorkspaceClient> {
Arc::new(worker::ReviewerChildWorkspaceClient::new(
inner,
worker::ReviewerContext {
ticket_id: bound_ticket.into(),
merge_request_id: "unsubmitted-review-candidate".into(),
},
"unused-review-capability".into(),
))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reviewer_reads_saved_related_tickets_by_key_and_id_and_pages_their_threads() {
for wip in [false, true] {
let dir = tempfile::tempdir().unwrap();
let (api, _runtime, identity, source, bound_id) = tracking_fixture(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let mut related = ticket::NewTicket::new("Related requirements");
related.body = ticket::MarkdownText::new("Requirements from a different Ticket");
let related = backend.create(related).unwrap();
backend
.add_ticket_relation(
bound_id.clone().into(),
ticket::NewTicketRelation {
kind: ticket::TicketRelationKind::DependsOn,
target: related.id.clone(),
note: None,
author: None,
},
)
.unwrap();
for body in ["Earlier decision", "Latest decision"] {
backend
.add_event(
related.id.clone().into(),
ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, body),
)
.unwrap();
}
let bound = backend.show(bound_id.clone().into()).unwrap();
let related_before = backend.show(related.id.clone().into()).unwrap();
let bound_key = bound.meta.resource_key.as_deref().unwrap();
let related_key = related.resource_key.as_deref().unwrap();
let transport = ticket_client(&api, identity, &source);
// One client must retain its review binding while following related Tickets.
let client = reviewer_client(transport.clone(), &bound_id);
let surface =
TicketSurface::with_access(dir.path(), client, wip, TicketFeatureAccess::review())
.await;
for (reference, expected_key, expected_title, expected_body) in [
(
bound_key,
bound_key,
"Tracking authority",
bound.document.body.as_str(),
),
(
related_key,
related_key,
"Related requirements",
related_before.document.body.as_str(),
),
(
related.id.as_str(),
related_key,
"Related requirements",
related_before.document.body.as_str(),
),
(
bound_id.as_str(),
bound_key,
"Tracking authority",
bound.document.body.as_str(),
),
] {
let shown = surface
.call(
"ShowTicket",
serde_json::json!({"id":reference}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(shown["ticket"], expected_key);
assert_eq!(shown["title"], expected_title);
assert_eq!(shown["body"], expected_body);
if expected_key == bound_key {
assert!(
shown["relations"]["outgoing"]
.as_array()
.unwrap()
.iter()
.any(|relation| relation["ticket"] == related_key
&& relation["kind"] == "depends_on")
);
}
}
let first = surface
.call(
"ShowTicket",
serde_json::json!({"id":related_key,"event_limit":1}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(first["thread"].as_array().unwrap().len(), 1);
assert_eq!(first["thread"][0]["body"], "Latest decision");
assert_eq!(first["event_page"]["has_more"], true);
let second = surface.call("ShowTicket", serde_json::json!({"id":related_key,"event_limit":1,"event_cursor":first["event_page"]["next_cursor"]}), ToolExecutionContext::direct()).await.unwrap();
assert_eq!(second["thread"].as_array().unwrap().len(), 1);
assert_eq!(second["thread"][0]["body"], "Earlier decision");
assert_eq!(backend.show(bound_id.into()).unwrap(), bound);
assert_eq!(backend.show(related.id.into()).unwrap(), related_before);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reviewer_read_uses_live_backend_membership_and_cannot_mutate_saved_ticket() {
for wip in [false, true] {
let dir = tempfile::tempdir().unwrap();
let (api, _runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let before = backend.show(ticket.clone().into()).unwrap();
let transport = ticket_client(&api, identity, &source);
let client = reviewer_client(transport.clone(), &ticket);
let surface = TicketSurface::with_access(
dir.path(),
client.clone(),
wip,
TicketFeatureAccess::review(),
)
.await;
surface
.call(
"ShowTicket",
serde_json::json!({"id":ticket}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
let request_count = transport.requests.lock().unwrap().len();
for (method, path, body) in [
(
WorkspaceRequestMethod::Post,
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/comments"),
serde_json::json!({"body":"unauthorized comment"}),
),
(
WorkspaceRequestMethod::Patch,
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}"),
serde_json::json!({"title":"unauthorized title"}),
),
(
WorkspaceRequestMethod::Post,
format!("/api/w/another-workspace/tickets/{ticket}/show"),
serde_json::json!({}),
),
(
WorkspaceRequestMethod::Post,
format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests/other-candidate/reviews"),
serde_json::json!({"verdict":"approved"}),
),
] {
let client = client.clone();
let error = tokio::task::spawn_blocking(move || {
client.execute(WorkspaceRequest::json(method, path, body.to_string()))
})
.await
.unwrap()
.unwrap_err();
assert!(error.to_string().contains("read-only"), "{error}");
}
assert_eq!(transport.requests.lock().unwrap().len(), request_count);
assert_eq!(backend.show(ticket.clone().into()).unwrap(), before);
// Keep a valid Runtime signature but select an unregistered Worker.
// This must fail at the real Backend, not the Reviewer route filter.
*transport.source.lock().unwrap() =
RuntimeWorkerRef::new(source.runtime_id, uuid::Uuid::now_v7().to_string());
let error = surface
.call(
"ShowTicket",
serde_json::json!({"id":ticket}),
ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(error.to_string().contains("401"), "{error}");
assert!(
transport
.requests
.lock()
.unwrap()
.iter()
.skip(request_count)
.any(|(_, status, _)| *status == 401)
);
assert_eq!(backend.show(ticket.into()).unwrap(), before);
}
}
fn report_counts(api: &WorkspaceApi) -> (i64, i64) { fn report_counts(api: &WorkspaceApi) -> (i64, i64) {
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
( (
+244 -8
View File
@@ -119,7 +119,7 @@ pub use ticket::{
}; };
const OLDEST_SCHEMA_VERSION: i64 = 50; const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 102; const LATEST_SCHEMA_VERSION: i64 = 103;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -437,6 +437,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Durable failed Subject creation recovery", name: "Durable failed Subject creation recovery",
apply: migrate_subject_create_recovery_v101_to_v102, apply: migrate_subject_create_recovery_v101_to_v102,
}, },
Migration {
version: 103,
name: "Passkey metadata and atomic addition results",
apply: migrate_passkey_results_v102_to_v103,
},
]; ];
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -749,6 +754,15 @@ pub enum PasskeyAuthenticationBinding {
ApiToken(String), ApiToken(String),
} }
impl PasskeyAuthenticationBinding {
pub(crate) fn reference(&self) -> String {
let hash = match self {
Self::BrowserSession(hash) | Self::ApiToken(hash) => hash,
};
crate::auth::authentication_reference(hash)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PasskeyCredentialRecord { pub struct PasskeyCredentialRecord {
pub credential_id: String, pub credential_id: String,
@@ -1713,6 +1727,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
&self, &self,
target: &PasskeyRegistrationTarget, target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord, credential: &PasskeyCredentialRecord,
) -> Result<()> {
self.register_verified_passkey_with_result(target, credential, None)
}
/// Receipt and credential commit together; no receipt claims session issuance.
fn register_verified_passkey_with_result(
&self,
target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord,
addition_challenge_id: Option<&str>,
) -> Result<()>;
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>>;
fn read_passkey_addition_result(
&self,
user_id: &str,
original_authentication_reference: &str,
challenge_id: &str,
) -> Result<Option<server_api::PasskeySummary>>;
fn mark_passkey_normal_login_verified(
&self,
credential_id: &str,
verified_at: &str,
) -> Result<()>; ) -> Result<()>;
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>; fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
fn get_passkey_credential( fn get_passkey_credential(
@@ -2589,6 +2624,10 @@ pub struct SqliteWorkspaceStore {
db_executor: crate::db_executor::DbExecutor, db_executor: crate::db_executor::DbExecutor,
} }
#[cfg(feature = "test-support")]
#[path = "test_support.rs"]
pub mod fixture_support;
impl SqliteWorkspaceStore { impl SqliteWorkspaceStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self> { pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let conn = Connection::open(path)?; let conn = Connection::open(path)?;
@@ -2900,6 +2939,33 @@ impl SqliteWorkspaceStore {
request_fingerprint: &str, request_fingerprint: &str,
singleton_key: Option<&str>, singleton_key: Option<&str>,
current_memory_settings: &WorkspaceMemorySettingsRecord, current_memory_settings: &WorkspaceMemorySettingsRecord,
) -> Result<WorkerCreateReservation> {
self.reserve_worker_create_with(
workspace_id,
runtime_id,
allocation_key,
request_fingerprint,
singleton_key,
current_memory_settings,
|_| Ok(None),
|_, _| Ok(()),
)
}
// Keep reservation validation, Memory binding and writes in one transaction. The
// nondefault test-support module supplies fixed identity / offline catalog hooks;
// ordinary creation always supplies a newly generated ID and no completion hook.
#[allow(clippy::too_many_arguments)]
fn reserve_worker_create_with(
&self,
workspace_id: &str,
runtime_id: &str,
allocation_key: &str,
request_fingerprint: &str,
singleton_key: Option<&str>,
current_memory_settings: &WorkspaceMemorySettingsRecord,
prepare: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<Option<WorkerId>>,
complete: impl FnOnce(&rusqlite::Transaction<'_>, &WorkerCreateReservation) -> Result<()>,
) -> Result<WorkerCreateReservation> { ) -> Result<WorkerCreateReservation> {
if allocation_key.trim().is_empty() || request_fingerprint.trim().is_empty() { if allocation_key.trim().is_empty() || request_fingerprint.trim().is_empty() {
return Err(Error::InvalidInput( return Err(Error::InvalidInput(
@@ -2912,6 +2978,7 @@ impl SqliteWorkspaceStore {
validate_workspace_memory_settings_record(current_memory_settings, workspace_id)?; validate_workspace_memory_settings_record(current_memory_settings, workspace_id)?;
self.with_conn_mut(|conn| { self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let fixed_worker_id = prepare(&tx)?;
let existing = tx let existing = tx
.query_row( .query_row(
"SELECT worker_id, runtime_id, request_fingerprint, create_fingerprint, \ "SELECT worker_id, runtime_id, request_fingerprint, create_fingerprint, \
@@ -3094,7 +3161,7 @@ impl SqliteWorkspaceStore {
validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?; validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?;
let create_fingerprint = let create_fingerprint =
bound_worker_create_fingerprint(request_fingerprint, &snapshot); bound_worker_create_fingerprint(request_fingerprint, &snapshot);
let worker_id = WorkerId::now_v7(); let worker_id = fixed_worker_id.unwrap_or_else(WorkerId::now_v7);
let worker_id_text = worker_id.to_string(); let worker_id_text = worker_id.to_string();
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
let singleton_generation = match singleton_owner.as_ref() { let singleton_generation = match singleton_owner.as_ref() {
@@ -3183,13 +3250,15 @@ impl SqliteWorkspaceStore {
)); ));
} }
} }
tx.commit()?; let reservation = WorkerCreateReservation {
Ok(WorkerCreateReservation {
worker_id, worker_id,
create_fingerprint, create_fingerprint,
memory_settings: snapshot, memory_settings: snapshot,
singleton, singleton,
}) };
complete(&tx, &reservation)?;
tx.commit()?;
Ok(reservation)
}) })
} }
@@ -6938,10 +7007,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn register_verified_passkey( fn register_verified_passkey_with_result(
&self, &self,
target: &PasskeyRegistrationTarget, target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord, credential: &PasskeyCredentialRecord,
addition_challenge_id: Option<&str>,
) -> Result<()> { ) -> Result<()> {
self.with_immediate_transaction(|tx| { self.with_immediate_transaction(|tx| {
let user = match target { let user = match target {
@@ -7008,6 +7078,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", "INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at], params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at],
)?; )?;
if let Some(challenge_id) = addition_challenge_id {
let PasskeyRegistrationTarget::Addition { user, authentication } = target else {
return Err(crate::auth::auth_error("passkey_target_mismatch", "receipt requires an addition"));
};
tx.execute(
"INSERT INTO passkey_addition_receipts(challenge_id, credential_id, user_id, authentication_reference) VALUES (?1, ?2, ?3, ?4)",
params![challenge_id, credential.credential_id, user.user_id, authentication.reference()],
)?;
}
if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target { if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target {
if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 { if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 {
return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists")); return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists"));
@@ -7017,6 +7096,57 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at
FROM passkey_credentials c JOIN passkey_metadata m USING(credential_id)
WHERE c.user_id = ?1 ORDER BY c.created_at, m.key_reference",
)?;
let rows = stmt.query_map([user_id], read_passkey_summary)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn read_passkey_addition_result(
&self,
user_id: &str,
original_authentication_reference: &str,
challenge_id: &str,
) -> Result<Option<server_api::PasskeySummary>> {
self.with_conn(|conn| {
// Current authenticated ownership authorizes reading. The original
// binding reference identifies the saved operation, not the current
// session: Set-Cookie may have arrived even if its body was lost.
let result = conn.query_row(
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at, r.authentication_reference
FROM passkey_addition_receipts r
JOIN passkey_credentials c USING(credential_id)
JOIN passkey_metadata m USING(credential_id)
WHERE r.challenge_id = ?1 AND r.user_id = ?2 AND c.user_id = ?2",
params![challenge_id, user_id],
|row| Ok((read_passkey_summary(row)?, row.get::<_, String>(4)?)),
).optional()?;
match result {
Some((key, reference)) if reference == original_authentication_reference => Ok(Some(key)),
Some(_) => Err(crate::auth::auth_error("passkey_result_binding_mismatch", "original operation authentication reference does not match")),
None => Ok(None),
}
})
}
fn mark_passkey_normal_login_verified(
&self,
credential_id: &str,
verified_at: &str,
) -> Result<()> {
self.with_conn(|conn| {
conn.execute("UPDATE passkey_metadata SET normal_login_verified_at = ?2 WHERE credential_id = ?1", params![credential_id, verified_at])?;
Ok(())
})
}
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> { fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
self.with_immediate_transaction(|conn| { self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?; activation::require_active(conn, &record.user_id)?;
@@ -11877,6 +12007,15 @@ fn passkey_select_sql(where_clause: &str) -> String {
) )
} }
fn read_passkey_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result<server_api::PasskeySummary> {
Ok(server_api::PasskeySummary {
key_reference: row.get(0)?,
created_at: row.get(1)?,
last_used_at: row.get(2)?,
normal_login_verified_at: row.get(3)?,
})
}
fn read_passkey_credential_record( fn read_passkey_credential_record(
row: &rusqlite::Row<'_>, row: &rusqlite::Row<'_>,
) -> rusqlite::Result<PasskeyCredentialRecord> { ) -> rusqlite::Result<PasskeyCredentialRecord> {
@@ -15130,6 +15269,22 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
fn migrate_passkey_results_v102_to_v103(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 102 {
return Err(Error::Store(
"Passkey results migration requires schema 102".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(include_str!("passkey_results_schema.sql"))?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(103,?1)",
["Passkey metadata and atomic addition results"],
)?;
tx.commit()?;
Ok(())
}
fn migrate_subject_create_recovery_v101_to_v102(conn: &Connection) -> Result<()> { fn migrate_subject_create_recovery_v101_to_v102(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 101 { if current_schema_version(conn)? != 101 {
return Err(Error::Store( return Err(Error::Store(
@@ -15221,6 +15376,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?; create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
conn.execute_batch(include_str!("activation_schema.sql"))?; conn.execute_batch(include_str!("activation_schema.sql"))?;
conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
Ok(()) Ok(())
} }
@@ -18914,6 +19070,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()}, WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()},
WorkspaceSchemaMigrationStep {version:101, name:"Durable explicit Subject body release".into()}, WorkspaceSchemaMigrationStep {version:101, name:"Durable explicit Subject body release".into()},
WorkspaceSchemaMigrationStep {version:102, name:"Durable failed Subject creation recovery".into()}, WorkspaceSchemaMigrationStep {version:102, name:"Durable failed Subject creation recovery".into()},
WorkspaceSchemaMigrationStep {version:103, name:"Passkey metadata and atomic addition results".into()},
] ]
); );
@@ -19026,6 +19183,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(100, "Provisional users and scoped passkey activation".to_string()), (100, "Provisional users and scoped passkey activation".to_string()),
(101, "Durable explicit Subject body release".to_string()), (101, "Durable explicit Subject body release".to_string()),
(102, "Durable failed Subject creation recovery".to_string()), (102, "Durable failed Subject creation recovery".to_string()),
(103, "Passkey metadata and atomic addition results".to_string()),
] ]
); );
assert!(!table_exists(conn, "trusted_runtime_records")?); assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -21254,6 +21412,83 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
); );
} }
#[test]
fn passkey_results_migration_preserves_old_keys_unknown_confirmation_and_rolls_back() {
for baseline in [50, 102] {
let dir = tempfile::tempdir().unwrap();
let database = dir.path().join("server.db");
if baseline == 50 {
prepare_schema_v50(&database, None);
} else {
prepare_retained_schema(&database, baseline);
}
let conn = Connection::open(&database).unwrap();
configure_sqlite(&conn).unwrap();
conn.execute_batch(r#"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
VALUES('account-old', 'user', 'old', 'Old', 'created', 'updated');
INSERT INTO users(user_id, account_id, handle, display_name, created_at, updated_at)
VALUES('user-old', 'account-old', 'old', 'Old', 'created', 'updated');
INSERT INTO passkey_credentials(credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at)
VALUES('old-key', 'user-old', 'unchanged-key-material', '["internal"]', 7, 'created-key', 'used-key');"#)
.unwrap();
if baseline == 102 {
conn.execute_batch("CREATE TRIGGER interrupt_passkey_migration BEFORE INSERT ON __yoi_schema_migrations
WHEN NEW.version=103 BEGIN SELECT RAISE(ABORT, 'synthetic migration interruption'); END;").unwrap();
assert!(migrate_passkey_results_v102_to_v103(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 102);
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
assert!(!table_exists(&conn, "passkey_addition_receipts").unwrap());
conn.execute_batch("DROP TRIGGER interrupt_passkey_migration")
.unwrap();
}
drop(conn);
let preflight = SqliteWorkspaceStore::migration_plan(&database).unwrap();
assert!(preflight.migration_required());
// Preflight must not install metadata in the source database.
let conn = Connection::open(&database).unwrap();
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
drop(conn);
let migrated = SqliteWorkspaceStore::open_for_test(&database).unwrap();
let record = migrated.get_passkey_credential("old-key").unwrap().unwrap();
assert_eq!(record.public_key_cose, "unchanged-key-material");
assert_eq!(record.sign_count, 7);
assert_eq!(record.last_used_at.as_deref(), Some("used-key"));
let summary = migrated
.list_passkey_summaries("user-old")
.unwrap()
.remove(0);
assert_eq!(summary.created_at, "created-key");
assert_eq!(summary.last_used_at.as_deref(), Some("used-key"));
assert!(summary.normal_login_verified_at.is_none());
assert_eq!(summary.key_reference.len(), 32);
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
let schema = |conn: &Connection| -> Result<Vec<(String, String)>> {
let mut stmt = conn.prepare("SELECT name, sql FROM sqlite_schema WHERE name IN
('passkey_metadata', 'passkey_metadata_insert', 'passkey_addition_receipts') ORDER BY name")?;
Ok(stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<std::result::Result<Vec<_>, _>>()?)
};
assert_eq!(
migrated.with_conn(schema).unwrap(),
fresh.with_conn(schema).unwrap()
);
migrated.with_conn(|conn| {
assert!(conn.execute("INSERT INTO passkey_metadata(credential_id,key_reference) VALUES('missing-key','ref')", []).is_err());
assert!(conn.execute("INSERT INTO passkey_addition_receipts(challenge_id,credential_id,user_id,authentication_reference)
VALUES('operation','missing-key','user-old','{}')", []).is_err());
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, u64>(0))?, 0);
Ok(())
}).unwrap();
drop(migrated);
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
assert_eq!(
reopened.list_passkey_summaries("user-old").unwrap(),
vec![summary]
);
}
}
#[test] #[test]
fn subject_create_recovery_migration_is_atomic_and_matches_fresh_schema() { fn subject_create_recovery_migration_is_atomic_and_matches_fresh_schema() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -21293,7 +21528,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
drop(conn); drop(conn);
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap(); let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
reopened reopened
.with_conn(|c| verify_schema_history(c, 102)) .with_conn(|c| verify_schema_history(c, LATEST_SCHEMA_VERSION))
.unwrap(); .unwrap();
} }
@@ -22232,7 +22467,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(99, "Explicit hosting administrators and self-registration policy".into()), (99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into()), (100, "Provisional users and scoped passkey activation".into()),
(101, "Durable explicit Subject body release".into()), (101, "Durable explicit Subject body release".into()),
(102, "Durable failed Subject creation recovery".into())]); (102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) { for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
} }
+688
View File
@@ -0,0 +1,688 @@
//! Offline Backend fixture preparation, available only with `test-support`.
//!
//! Use a new isolated database and the existing account, Workspace signing-identity /
//! `ControlPlaneStore::create_workspace_bootstrap`, and Runtime binding APIs first.
//! Generate fresh account credentials and signing keys for each environment; no fixture
//! credentials are accepted here. Install Profile configuration through the existing
//! config-source mechanisms before starting the product processes.
//!
//! This module seeds Backend ownership only. It neither writes Runtime storage nor
//! launches a Worker, fabricates Runtime observations, changes authorization, or sends
//! initial input. The caller must separately install the matching stopped Runtime
//! fixture. Build product binaries in a separate Cargo invocation **without** this
//! feature (Cargo features unify within an invocation).
use super::*;
use crate::hosts::{WorkerSpawnIntent, WorkerSpawnRequest, worker_spawn_create_fingerprint};
use worker_runtime::catalog::ProfileSelector;
/// One ordinary, attachment-free Worker from an externally validated fixture.
///
/// IDs must match the Runtime fixture. `request` is the unresolved canonical create
/// intent, not a Runtime launch request containing credentials or execution grants.
/// The Profile selector must already be installed by the harness. `initial_submit`
/// must be empty: saved Session contents belong to the Runtime fixture, not new input.
/// Singleton/service, Ticket-assigned and attachment-bearing Workers are deliberately
/// unsupported.
pub struct FixedWorkerSeed<'a> {
pub workspace_id: &'a str,
pub runtime_id: &'a str,
pub worker_id: WorkerId,
pub allocation_key: &'a str,
pub request: &'a WorkerSpawnRequest,
}
/// Insert a fixed Worker catalog entry and completed creation reservation atomically.
///
/// Uses the normal reservation validators, authoritative Memory snapshot, fingerprint
/// binding, resource-key allocator and immediate transaction. Even an exact repeat is
/// rejected: this is not an upsert/import/recovery API. Existing allocations, Worker
/// identities and residual lifecycle/attachment records are never adopted or repaired.
/// Returns the creation binding needed to check the corresponding Runtime fixture.
pub fn seed_fixed_worker(
store: &SqliteWorkspaceStore,
seed: FixedWorkerSeed<'_>,
) -> Result<WorkerCreateReservation> {
validate_identifier("workspace_id", seed.workspace_id)?;
validate_identifier("runtime_id", seed.runtime_id)?;
let request = seed.request;
if !request.initial_submit.is_empty() {
return Err(Error::InvalidInput(
"fixture Worker cannot contain initial_submit; offline seeding accepts no new input"
.into(),
));
}
if !matches!(request.intent, WorkerSpawnIntent::WorkspaceCoding)
|| request.singleton_key.is_some()
|| request.ticket_assignment.is_some()
|| request.resolved_subjektiv_attached
|| !request.workdir_attachment_requests.is_empty()
|| !request.resolved_workdir_attachment_requests.is_empty()
|| !request.resolved_workdir_attachments.is_empty()
|| request.resolved_config_bundle.is_some()
|| request.resolved_workspace_api.is_some()
|| request.resolved_memory_settings.is_some()
|| request.resolved_worker_observation_enabled
|| !request.resolved_worker_observation_grants.is_empty()
|| request.resolved_control_operation.is_some()
|| request.resolved_execution_grant.is_some()
{
return Err(Error::InvalidInput(
"fixture Worker must be ordinary unresolved create intent without singleton, assignments, attachments or grants".into(),
));
}
// Builtin selectors already carry their canonical `builtin:` prefix.
let profile = match &request.profile {
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => name,
};
validate_non_empty("fixture Profile selector", profile)?;
let worker_id = seed.worker_id.to_string();
let display_name = request
.requested_worker_name
.as_deref()
.unwrap_or(&worker_id);
validate_non_empty("fixture Worker display name", display_name)?;
let fingerprint = worker_spawn_create_fingerprint(request).map_err(Error::InvalidInput)?;
let memory = store.get_workspace_memory_settings(seed.workspace_id)?;
store.reserve_worker_create_with(
seed.workspace_id,
seed.runtime_id,
seed.allocation_key,
&fingerprint,
None,
&memory,
|tx| {
require_seed_target(tx, &seed)?;
Ok(Some(seed.worker_id))
},
|tx, reservation| {
let now = chrono::Utc::now().to_rfc3339();
tx.execute(
"INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, profile,
retention_state, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, 'normal', ?6, ?6)",
params![seed.workspace_id, seed.runtime_id, worker_id, display_name, profile, now],
)?;
let changed = tx.execute(
"UPDATE worker_create_reservations SET state = 'created', updated_at = ?4
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND state = 'reserved'",
params![seed.workspace_id, seed.runtime_id, reservation.worker_id.to_string(), now],
)?;
if changed != 1 {
return Err(Error::Store("fixture creation reservation disappeared".into()));
}
Ok(())
},
)
}
fn require_seed_target(tx: &rusqlite::Transaction<'_>, seed: &FixedWorkerSeed<'_>) -> Result<()> {
let active: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces w
JOIN workspace_runtime_bindings r ON r.workspace_id = w.workspace_id
JOIN workspace_signing_identities s ON s.workspace_id = w.workspace_id
WHERE w.workspace_id = ?1 AND w.state = 'active'
AND r.runtime_id = ?2 AND r.revoked_at IS NULL
AND r.authentication_mode = 'workspace_identity' AND s.state = 'active')",
params![seed.workspace_id, seed.runtime_id],
|row| row.get(0),
)?;
if !active {
return Err(Error::InvalidInput(
"fixture requires an active bootstrapped Workspace and authenticated Runtime binding"
.into(),
));
}
let worker_id = seed.worker_id.to_string();
let allocation_exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM worker_create_reservations
WHERE workspace_id = ?1 AND allocation_key = ?2)",
params![seed.workspace_id, seed.allocation_key],
|row| row.get(0),
)?;
if allocation_exists {
return Err(Error::InvalidInput(
"fixture Worker allocation already exists".into(),
));
}
// Check by WorkerId, not just the proposed Runtime tuple: orphaned and terminal
// ownership is still a collision. These tables are owned by this store schema.
for table in [
"worker_create_reservations",
"worker_registry",
"worker_registry_observations",
"worker_registry_projection_removals",
"worker_removal_operations",
"worker_singleton_owners",
"worker_workdir_links",
"worker_restore_intents",
"worker_tombstones",
"worker_orphan_diagnostics",
"worker_registry_projection_diagnostics",
"worker_session_archives",
"worker_diagnostics_archives",
"ticket_assignment_worker_tombstones",
] {
let exists: bool = tx.query_row(
&format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE worker_id = ?1)"),
[&worker_id],
|row| row.get(0),
)?;
if exists {
return Err(Error::InvalidInput(format!(
"fixture Worker identity already exists in {table}"
)));
}
}
// A resource key without a catalog/reservation must not be silently reused.
let mut statement = tx.prepare(
"SELECT resource_id FROM workspace_resource_keys WHERE resource_kind = 'worker'",
)?;
let resources = statement.query_map([], |row| row.get::<_, String>(0))?;
for resource in resources {
let resource = resource?;
let existing = resource
.is_ascii()
.then(|| parse_worker_resource_id(&resource))
.flatten()
.ok_or_else(|| {
Error::InvalidInput("unknown fixture Worker resource identity collision".into())
})?;
if existing.worker_id == worker_id {
return Err(Error::InvalidInput(
"fixture Worker resource identity already exists".into(),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hosts::{
WorkerSpawnAcceptanceRequirement, WorkerSpawnWorkingDirectoryAttachmentRequest,
WorkerSpawnWorkingDirectoryRequest,
};
use crate::workspace_signing_identity::{
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
};
const WORKSPACE: &str = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c501";
const RUNTIME: &str = "fixture-runtime";
const WORKER: &str = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c502";
// Exercises the same public bootstrap APIs the offline harness uses. All account
// and key identities are newly generated, even across stores with fixed fixture IDs.
fn bootstrap() -> SqliteWorkspaceStore {
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
let account = AccountRecord {
account_id: format!("fixture-account-{}", Uuid::now_v7()),
kind: "user".into(),
handle: format!("fixture-{}", Uuid::now_v7()),
display_name: "Fixture owner".into(),
created_at: "2026-10-24T00:00:00Z".into(),
updated_at: "2026-10-24T00:00:00Z".into(),
};
store.upsert_account(&account).unwrap();
let identities = WorkspaceSigningIdentityService::new(
Arc::new(store.clone()),
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
);
let (identity, operation) = identities
.prepare_workspace_creation(
"fixture-bootstrap",
"fixture-bootstrap",
WORKSPACE,
&account.account_id,
)
.unwrap();
store
.create_workspace_bootstrap(
&WorkspaceBootstrapRecord {
operation_key: "fixture-bootstrap".into(),
request_fingerprint: "fixture-bootstrap".into(),
workspace: WorkspaceRecord {
workspace_id: WORKSPACE.into(),
owner_account_id: account.account_id,
display_name: "Fixture".into(),
state: "active".into(),
created_at: account.created_at,
updated_at: account.updated_at,
},
repository: None,
},
&identity,
&operation,
)
.unwrap();
let runtime_key = worker_runtime::auth::RuntimeIdentityMaterial::generate(RUNTIME).unwrap();
store
.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: WORKSPACE.into(),
runtime_id: RUNTIME.into(),
display_name: "Fixture Runtime".into(),
base_url: "http://127.0.0.1:12345".into(),
public_key: runtime_key.public_key,
public_key_fingerprint: String::new(),
binding_id: String::new(),
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
created_at: "2026-10-24T00:00:00Z".into(),
updated_at: "2026-10-24T00:00:00Z".into(),
revoked_at: None,
},
false,
)
.unwrap();
store
}
fn request() -> WorkerSpawnRequest {
WorkerSpawnRequest {
intent: WorkerSpawnIntent::WorkspaceCoding,
requested_worker_name: Some("Fixture Worker".into()),
singleton_key: None,
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
profile: ProfileSelector::Builtin("builtin:coder".into()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachment_requests: Vec::new(),
resolved_workdir_attachment_requests: Vec::new(),
resolved_workdir_attachments: Vec::new(),
resolved_config_bundle: None,
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_control_operation: None,
resolved_execution_grant: None,
}
}
fn seed(request: &WorkerSpawnRequest) -> FixedWorkerSeed<'_> {
FixedWorkerSeed {
workspace_id: WORKSPACE,
runtime_id: RUNTIME,
worker_id: WORKER.parse().unwrap(),
allocation_key: "fixture-allocation",
request,
}
}
fn worker() -> RuntimeWorkerRef {
RuntimeWorkerRef::new(RUNTIME, WORKER)
}
#[test]
fn fixed_ids_are_isolated_and_seed_has_no_runtime_observation() {
let a = bootstrap();
let b = bootstrap();
let request = request();
let first = seed_fixed_worker(&a, seed(&request)).unwrap();
let second = seed_fixed_worker(&b, seed(&request)).unwrap();
assert_eq!(first, second);
assert_eq!(first.worker_id.to_string(), WORKER);
assert_eq!(first.memory_settings.workspace_id, WORKSPACE);
assert_eq!(first.memory_settings.language, "English");
assert!(first.singleton.is_none());
assert_ne!(
a.get_workspace_signing_identity(WORKSPACE)
.unwrap()
.unwrap()
.public_key,
b.get_workspace_signing_identity(WORKSPACE)
.unwrap()
.unwrap()
.public_key
);
assert_ne!(
a.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
.unwrap()
.unwrap()
.public_key,
b.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
.unwrap()
.unwrap()
.public_key
);
for store in [&a, &b] {
assert_eq!(
store
.worker_resource_key(WORKSPACE, &worker())
.unwrap()
.as_deref(),
Some("W-1")
);
assert_eq!(
store
.resolve_worker_resource_reference(WORKSPACE, "W-1")
.unwrap(),
Some(worker())
);
assert!(
!store
.has_active_worker_create_reservation(WORKSPACE, &worker())
.unwrap()
);
let registry = store
.get_worker_registry(WORKSPACE, &worker())
.unwrap()
.unwrap();
assert_eq!(registry.profile.as_deref(), Some("builtin:coder"));
assert_eq!(registry.display_name, "Fixture Worker");
assert_eq!(registry.retention_state, "normal");
let projection = store
.with_conn(|conn| read_worker_registry_projection(conn, WORKSPACE, &worker()))
.unwrap()
.unwrap();
assert!(projection.observation.is_none());
assert!(projection.job.is_none());
}
}
#[test]
fn initial_submit_is_refused_without_registration_or_allocation() {
let store = bootstrap();
let mut request = request();
// Even an empty text segment is new input; the vector itself must be empty.
for text in ["new input must not be seeded", ""] {
request.initial_submit = vec![protocol::Segment::text(text)];
let error = seed_fixed_worker(&store, seed(&request)).unwrap_err();
assert!(
matches!(error, Error::InvalidInput(ref message) if message.contains("initial_submit"))
);
assert!(
store
.get_worker_registry(WORKSPACE, &worker())
.unwrap()
.is_none()
);
assert!(
store
.worker_resource_key(WORKSPACE, &worker())
.unwrap()
.is_none()
);
assert!(
!store
.has_active_worker_create_reservation(WORKSPACE, &worker())
.unwrap()
);
}
// The rejected attempts must not consume the allocation or resource counter.
request.initial_submit.clear();
seed_fixed_worker(&store, seed(&request)).unwrap();
assert_eq!(
store
.worker_resource_key(WORKSPACE, &worker())
.unwrap()
.as_deref(),
Some("W-1")
);
}
#[test]
fn exact_replay_changed_input_and_cross_runtime_identity_are_refused() {
let store = bootstrap();
let mut request = request();
seed_fixed_worker(&store, seed(&request)).unwrap();
let before = store.get_worker_registry(WORKSPACE, &worker()).unwrap();
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
request.requested_worker_name = Some("Must not overwrite".into());
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
let mut new_allocation = seed(&request);
new_allocation.allocation_key = "another-allocation";
assert!(seed_fixed_worker(&store, new_allocation).is_err());
let mut same_allocation = seed(&request);
same_allocation.worker_id = WorkerId::now_v7();
assert!(seed_fixed_worker(&store, same_allocation).is_err());
assert_eq!(
store.get_worker_registry(WORKSPACE, &worker()).unwrap(),
before
);
let other = bootstrap();
let mut record = before.unwrap();
record.worker.runtime_id = "different-runtime".into();
other.upsert_worker_registry(&record).unwrap();
assert!(seed_fixed_worker(&other, seed(&request)).is_err());
assert!(
other
.get_worker_registry(WORKSPACE, &worker())
.unwrap()
.is_none()
);
}
#[test]
fn reserved_and_orphaned_resource_identities_are_not_adopted() {
let store = bootstrap();
let request = request();
let memory = store.get_workspace_memory_settings(WORKSPACE).unwrap();
let ordinary = store
.reserve_worker_create(WORKSPACE, RUNTIME, "ordinary", "fingerprint", None, &memory)
.unwrap();
let mut collision = seed(&request);
collision.worker_id = ordinary.worker_id;
assert!(seed_fixed_worker(&store, collision).is_err());
// Store-level fault setup only: the external harness never writes SQL.
store
.with_conn(|conn| {
allocate_resource_key(
conn,
WORKSPACE,
WorkspaceResourceKind::Worker,
&worker_resource_id(&worker()),
"now",
)?;
Ok(())
})
.unwrap();
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
assert!(
store
.get_worker_registry(WORKSPACE, &worker())
.unwrap()
.is_none()
);
}
#[test]
fn unknown_resource_identity_and_retained_removal_fence_are_refused() {
let request = request();
let malformed = bootstrap();
malformed
.with_conn(|conn| {
allocate_resource_key(
conn,
WORKSPACE,
WorkspaceResourceKind::Worker,
"unknown-identity",
"now",
)?;
Ok(())
})
.unwrap();
assert!(matches!(
seed_fixed_worker(&malformed, seed(&request)),
Err(Error::InvalidInput(_))
));
assert!(
malformed
.get_worker_registry(WORKSPACE, &worker())
.unwrap()
.is_none()
);
let removed = bootstrap();
removed.with_conn(|conn| {
conn.execute(
"INSERT INTO worker_registry_projection_removals(workspace_id, runtime_id, worker_id) VALUES (?1, ?2, ?3)",
params![WORKSPACE, RUNTIME, WORKER],
)?;
Ok(())
}).unwrap();
assert!(matches!(
seed_fixed_worker(&removed, seed(&request)),
Err(Error::InvalidInput(_))
));
assert!(
removed
.worker_resource_key(WORKSPACE, &worker())
.unwrap()
.is_none()
);
}
#[test]
fn unknown_workspace_runtime_and_revoked_binding_are_refused() {
let store = bootstrap();
let request = request();
let mut unknown = seed(&request);
unknown.workspace_id = "unknown";
assert!(seed_fixed_worker(&store, unknown).is_err());
let mut unknown = seed(&request);
unknown.runtime_id = "unknown";
assert!(seed_fixed_worker(&store, unknown).is_err());
store
.revoke_workspace_runtime_binding(WORKSPACE, RUNTIME, "2026-10-24T01:00:00Z")
.unwrap();
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
assert!(
store
.worker_resource_key(WORKSPACE, &worker())
.unwrap()
.is_none()
);
}
#[test]
fn unsupported_intent_is_refused_without_partial_registration() {
let store = bootstrap();
let base = request();
let mut requests = Vec::new();
let mut request = base.clone();
request.singleton_key = Some("fixture-singleton".into());
requests.push(request);
let mut request = base.clone();
request.intent = WorkerSpawnIntent::WorkspaceOrchestrator;
requests.push(request);
let mut request = base.clone();
request.resolved_subjektiv_attached = true;
requests.push(request);
let mut request = base.clone();
request
.workdir_attachment_requests
.push(WorkerSpawnWorkingDirectoryAttachmentRequest {
alias: workdir::WorkdirAttachmentAlias::new("repo").unwrap(),
working_directory: WorkerSpawnWorkingDirectoryRequest {
repository_key: "repo".into(),
selector: None,
},
});
requests.push(request);
for request in requests {
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
}
assert!(
store
.get_worker_registry(WORKSPACE, &worker())
.unwrap()
.is_none()
);
assert!(
store
.worker_resource_key(WORKSPACE, &worker())
.unwrap()
.is_none()
);
assert_eq!(
seed_fixed_worker(&store, seed(&base))
.unwrap()
.worker_id
.to_string(),
WORKER
);
}
#[test]
fn late_catalog_failure_rolls_back_reservation_and_resource_allocation() {
let store = bootstrap();
let request = request();
store
.with_conn(|conn| {
conn.execute_batch(
"CREATE TEMP TRIGGER fail_fixture_catalog BEFORE INSERT ON worker_registry
BEGIN SELECT RAISE(ABORT, 'injected catalog failure'); END",
)?;
Ok(())
})
.unwrap();
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
assert!(
!store
.has_active_worker_create_reservation(WORKSPACE, &worker())
.unwrap()
);
assert!(
store
.worker_resource_key(WORKSPACE, &worker())
.unwrap()
.is_none()
);
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_fixture_catalog")?;
Ok(())
})
.unwrap();
seed_fixed_worker(&store, seed(&request)).unwrap();
assert_eq!(
store
.worker_resource_key(WORKSPACE, &worker())
.unwrap()
.as_deref(),
Some("W-1")
);
}
#[test]
fn ordinary_creation_still_allocates_and_replays_without_catalog_insertion() {
let store = bootstrap();
let memory = store.get_workspace_memory_settings(WORKSPACE).unwrap();
let reserve = |key| {
store
.reserve_worker_create(WORKSPACE, RUNTIME, key, "fingerprint", None, &memory)
.unwrap()
};
let first = reserve("ordinary-one");
assert_eq!(first, reserve("ordinary-one"));
let second = reserve("ordinary-two");
assert_ne!(first.worker_id, second.worker_id);
assert_ne!(first.worker_id.to_string(), WORKER);
assert_eq!(
first.worker_id.as_uuid().get_version(),
Some(uuid::Version::SortRand)
);
let worker = RuntimeWorkerRef::new(RUNTIME, first.worker_id.to_string());
assert!(
store
.has_active_worker_create_reservation(WORKSPACE, &worker)
.unwrap()
);
assert!(
store
.get_worker_registry(WORKSPACE, &worker)
.unwrap()
.is_none()
);
assert!(
store
.reserve_worker_create(WORKSPACE, RUNTIME, "ordinary-one", "changed", None, &memory)
.is_err()
);
}
}
+261 -3
View File
@@ -298,12 +298,17 @@
"AuthUserResponse": { "AuthUserResponse": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"authentication_reference": {
"description": "Comparison evidence for the exact newly issued session, not a credential.",
"type": "string"
},
"user": { "user": {
"$ref": "#/components/schemas/AuthenticatedUser" "$ref": "#/components/schemas/AuthenticatedUser"
} }
}, },
"required": [ "required": [
"user" "user",
"authentication_reference"
], ],
"type": "object" "type": "object"
}, },
@@ -6709,15 +6714,112 @@
}, },
"PasskeyAdditionOptionsRequest": { "PasskeyAdditionOptionsRequest": {
"additionalProperties": false, "additionalProperties": false,
"description": "Adds a credential to the current authenticated actor; target identity is never public input.", "description": "Adds to the current actor. Expected identity/binding fields only fence stale UI;\nthey never select an owner or authorize an addition.",
"properties": { "properties": {
"browser_origin": { "browser_origin": {
"type": [ "type": [
"string", "string",
"null" "null"
] ]
},
"expected_account_id": {
"type": "string"
},
"expected_authentication_reference": {
"type": "string"
},
"expected_user_id": {
"type": "string"
} }
}, },
"required": [
"expected_user_id",
"expected_account_id",
"expected_authentication_reference"
],
"type": "object"
},
"PasskeyAdditionResultRequest": {
"additionalProperties": false,
"properties": {
"challenge_id": {
"type": "string"
},
"expected_account_id": {
"type": "string"
},
"expected_authentication_reference": {
"description": "Original options authentication reference; the current same-owner session may differ.",
"type": "string"
},
"expected_user_id": {
"type": "string"
}
},
"required": [
"challenge_id",
"expected_user_id",
"expected_account_id",
"expected_authentication_reference"
],
"type": "object"
},
"PasskeyAdditionResultResponse": {
"description": "Unknown is not proof that registration failed: completion may still be in flight.\nSaved proves the credential commit, not session issuance or current session health.",
"oneOf": [
{
"additionalProperties": false,
"properties": {
"status": {
"const": "unknown",
"type": "string"
}
},
"required": [
"status"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"key": {
"$ref": "#/components/schemas/PasskeySummary"
},
"status": {
"const": "saved",
"type": "string"
},
"user": {
"$ref": "#/components/schemas/AuthenticatedUser"
}
},
"required": [
"status",
"user",
"key"
],
"type": "object"
}
]
},
"PasskeyListResponse": {
"additionalProperties": false,
"properties": {
"keys": {
"items": {
"$ref": "#/components/schemas/PasskeySummary"
},
"type": "array"
},
"user": {
"$ref": "#/components/schemas/AuthenticatedUser"
}
},
"required": [
"user",
"keys"
],
"type": "object" "type": "object"
}, },
"PasskeyLoginCompleteRequest": { "PasskeyLoginCompleteRequest": {
@@ -6820,6 +6922,35 @@
], ],
"type": "object" "type": "object"
}, },
"PasskeySummary": {
"additionalProperties": false,
"description": "Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.\nNormal-login verification is observed verifier success, not inferred discoverability.",
"properties": {
"created_at": {
"type": "string"
},
"key_reference": {
"type": "string"
},
"last_used_at": {
"type": [
"string",
"null"
]
},
"normal_login_verified_at": {
"type": [
"string",
"null"
]
}
},
"required": [
"key_reference",
"created_at"
],
"type": "object"
},
"PasteArtifactAvailability": { "PasteArtifactAvailability": {
"enum": [ "enum": [
"available", "available",
@@ -15143,6 +15274,13 @@
} }
] ]
}, },
"authentication_reference": {
"description": "Non-secret comparison evidence, never an authentication credential.",
"type": [
"string",
"null"
]
},
"session": { "session": {
"$ref": "#/components/schemas/BrowserSessionState" "$ref": "#/components/schemas/BrowserSessionState"
} }
@@ -18155,7 +18293,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7" "x-yoi-source-digest": "sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -18589,6 +18727,61 @@
} }
} }
}, },
"/api/auth/passkeys": {
"get": {
"operationId": "auth_passkey_list",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyListResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/auth/passkeys/addition/complete": { "/api/auth/passkeys/addition/complete": {
"post": { "post": {
"operationId": "auth_passkey_addition_complete", "operationId": "auth_passkey_addition_complete",
@@ -18736,6 +18929,71 @@
] ]
} }
}, },
"/api/auth/passkeys/addition/result": {
"post": {
"operationId": "auth_passkey_addition_result",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyAdditionResultRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyAdditionResultResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/auth/passkeys/login/complete": { "/api/auth/passkeys/login/complete": {
"post": { "post": {
"operationId": "auth_passkey_login_complete", "operationId": "auth_passkey_login_complete",
+1 -1
View File
@@ -1,5 +1,5 @@
You are the independent MR Reviewer, a direct SubWorker of the review requester. You are the independent MR Reviewer, a direct SubWorker of the review requester.
Evaluate the captured MR subject, Ticket `content_digest`, and complete linked MR/result snapshot against the Ticket requirements. Approval attests that snapshot, not just the selected diff. Inspect and test without modifying implementation files, repository refs, or Ticket state. Evaluate the captured MR subject, Ticket `content_digest`, and complete linked MR/result snapshot against the Ticket requirements. Approval attests that snapshot, not just the selected diff. You may use Bash for Git and other commands.
Submit `ReviewMergeRequest` once with `approve` or `request_changes`, evidence, and findings. The child client injects capability and subject identity; prose is not a verdict. Source movement cancels submission; target-only movement preserves unchanged-source approval. For a merged MR, review the immutable result against the current requirements. Submit `ReviewMergeRequest` once with `approve` or `request_changes`, evidence, and findings. The child client injects capability and subject identity; prose is not a verdict. Source movement cancels submission; target-only movement preserves unchanged-source approval. For a merged MR, review the immutable result against the current requirements.
+12
View File
@@ -9,6 +9,7 @@ autotests = false
[features] [features]
default = [] default = []
e2e = [] e2e = []
fixture-support = ["yoi-workspace-server/test-support", "worker-runtime/test-support"]
[dependencies] [dependencies]
libc.workspace = true libc.workspace = true
@@ -17,11 +18,17 @@ serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
protocol.workspace = true
tungstenite = { version = "0.29", default-features = false, features = ["handshake"] }
[dev-dependencies] [dev-dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
yoi-workspace-server.workspace = true yoi-workspace-server.workspace = true
server-api.workspace = true server-api.workspace = true
worker-runtime.workspace = true
session-store.workspace = true
config-source.workspace = true
manifest.workspace = true
webauthn-rs-proto = "0.5.5" webauthn-rs-proto = "0.5.5"
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
@@ -44,3 +51,8 @@ required-features = ["e2e"]
name = "worker_environment" name = "worker_environment"
path = "tests/worker_environment.rs" path = "tests/worker_environment.rs"
required-features = ["e2e"] required-features = ["e2e"]
[[test]]
name = "worker_restore"
path = "tests/worker_restore.rs"
required-features = ["e2e", "fixture-support"]
+39
View File
@@ -7,3 +7,42 @@
- [`PanelHarness`]: PTY経由の入力、観測イベントによる同期、失敗時のartifact保存。 - [`PanelHarness`]: PTY経由の入力、観測イベントによる同期、失敗時のartifact保存。
- [`FixtureWorkspace`]: TUI用WorkspaceとHOME/XDG領域の隔離。 - [`FixtureWorkspace`]: TUI用WorkspaceとHOME/XDG領域の隔離。
- [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。 - [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。
- [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。
- [`WorkerSessionSubscription`]: 公開Worker WebSocketの初期full Snapshot照合と停止確認。モデル呼出しは行わない。
- `tests/auth_passkeys/native_migration.rs`・`browser/native_passkey_migration.ts`: 実Backend/本番shell/Chromium native CTAP2の旧鍵移行。fixture境界・未確認範囲・明示選択コマンドはmoduleに置く。通常suiteやsoftware authenticatorの成功で代替しない。
## 固定Workerの段階起動
1. 提供fixtureを所有crateの型で検証し、`FixtureSource`で原本hashを記録する。各環境の一時領域へcopyする。fixtureが未提供・不適合なら失敗とし、代替Workerを生成しない。
2. `initialize_runtime_identity`で固定Runtime IDに新しい鍵を発行する。Backendの型付きstore APIで新しいAccount認証・Workspace署名identity・Runtime binding・固定Worker登録を準備する。
3. `start_seeded_backend`へ固定Workspace/Runtime IDとその環境の新しいbearerを渡す。Server起動後の公開APIからWorkspaceの公開identityを取得する。
4. `start_runtime_with_seed`はtrust設定後、Runtime processの起動前にseed closureを呼ぶ。ここで実Server URLを使いRuntime所有crateの型付きinstallを呼ぶ。Runtime store rootは`runtime_data_dir.join("runtime")`。
5. Worker一覧とdetailから停止状態・観測tokenを確認し、一つの固定request IDで`restore_worker`を一度だけ呼ぶ。結果不明・拒否は成功扱いせず、tokenを自動更新しない。
6. `WorkerSessionSubscription::connect`で期待`protocol::SessionSnapshot`全体(entry ID、timestamp、順序、本文を含む)を照合する。二環境を同時に保持し、一方のstop/cleanup後も他方の購読・一覧・内容を再確認する。
7. `stop_worker`、購読の`expect_stopped`、環境cleanup、原本`assert_unchanged`を確認する。
所有crateの`test-support`依存はテスト側の非default `fixture-support` featureだけで有効にする。製品binary providerは別Cargo invocationと専用`target/e2e-products`へ`yoi`・`yoi-server`・`yoi-runtime`を追加featureなしでビルドする。support test自身をこのtargetへビルドしてはならない。`YOI_E2E_BIN`等の明示binary overrideを使用する場合も、呼出し側が同じ分離を保証する。既存TUI用`e2e-test` featureは別の`target/e2e-tui-products`へビルドし、restore製品binaryへ混入しない。
固定資産の正常restore・並行隔離E2E:
```sh
cargo test -p yoi-e2e --features e2e,fixture-support --test worker_restore
# 親から提供された候補の検証(読取専用、原本の自動採用や生成はしない)
YOI_E2E_RESTORE_FIXTURE=target/fixture-candidate-v1 \
cargo test -p yoi-e2e --features e2e,fixture-support --test worker_restore
```
標準資産は`tests/e2e/fixtures/worker-codex-v1`の`manifest.json`・`worker-template.json`・`expected.json`・`workspace-config.dcdl`。manifestの各asset hash/size、schemaと固定ID、source revision、modelを検証する。Workspace configのimport参照を黙って最新へ置換せず、Gitでsource revisionに対する`resources`と`crates/builtin-source`の一致を確認する(Git履歴がない場合も失敗)。移植したSessionの正規公開projectionを供給された会話行と照合し、その全snapshotを実executionのWebSocketと比較する。欠落fixture・hash不一致・未実行は成功扱いしない。
通常のharness回帰試験(製品processは起動しない):
```sh
cargo test -p yoi-e2e --lib --no-default-features
```
既存の製品process E2Eは別結果として実行・報告する:
```sh
cargo test -p yoi-e2e --features e2e --test worker_environment
cargo test -p yoi-e2e --features e2e --test panel --test rewind
```
+28 -57
View File
@@ -4,7 +4,7 @@
// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。 // 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。
// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。 // navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。
import { assert, assertEquals } from "@std/assert"; import { assert, assertEquals } from "@std/assert";
import { extname, join, normalize } from "@std/path"; import { serveProductionShell } from "./static_shell.ts";
import { type Browser, type BrowserContext, chromium } from "playwright"; import { type Browser, type BrowserContext, chromium } from "playwright";
const [backend, origin, build, handle] = Deno.args; const [backend, origin, build, handle] = Deno.args;
@@ -13,24 +13,15 @@ if (!backend || !origin || !build || !handle) {
"Expected isolated Backend URL, browser origin, production build, handle", "Expected isolated Backend URL, browser origin, production build, handle",
); );
} }
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。 const server = await serveProductionShell(
const mime: Record<string, string> = { backend,
".html": "text/html; charset=utf-8", origin,
".js": "text/javascript", build,
".css": "text/css", async (request) => {
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
".ico": "image/x-icon",
};
const server = Deno.serve({
hostname: "127.0.0.1",
port: Number(new URL(origin).port),
onListen() {},
}, async (request) => {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname === "/__e2e/expired-session" && request.method === "POST") { if (
url.pathname === "/__e2e/expired-session" && request.method === "POST"
) {
if (request.headers.get("origin") !== origin) { if (request.headers.get("origin") !== origin) {
return new Response(null, { status: 403 }); return new Response(null, { status: 403 });
} }
@@ -45,50 +36,20 @@ const server = Deno.serve({
return new Response(null, { status: 204, headers }); return new Response(null, { status: 204, headers });
} }
if ( if (
url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET" url.pathname === "/__e2e/primary-cookie-present" &&
request.method === "GET"
) { ) {
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。 // HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
const present = request.headers.get("cookie")?.split(";").some((pair) => const present = request.headers.get("cookie")?.split(";").some((pair) =>
pair.trim().split("=", 1)[0] === "yoi_workspace_session" pair.trim().split("=", 1)[0] === "yoi_workspace_session"
) ?? false; ) ?? false;
return Response.json(present, { headers: { "cache-control": "no-store" } }); return Response.json(present, {
} headers: { "cache-control": "no-store" },
if (url.pathname.startsWith("/api/")) {
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("connection");
// OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。
return await fetch(new URL(url.pathname + url.search, backend), {
method: request.method,
headers,
body: ["GET", "HEAD"].includes(request.method)
? undefined
: await request.arrayBuffer(),
redirect: "manual",
}); });
} }
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html"); return undefined;
if (relative.startsWith("..")) {
return new Response("not found", { status: 404 });
}
let file = join(build, relative);
try {
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
return new Response(await Deno.readFile(file), {
headers: {
"content-type": mime[extname(file)] ?? "application/octet-stream",
}, },
}); );
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
if (url.pathname.startsWith("/_app/")) {
return new Response("not found", { status: 404 });
}
return new Response(await Deno.readFile(join(build, "index.html")), {
headers: { "content-type": mime[".html"] },
});
}
});
// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。 // 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。
const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream()) const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream())
@@ -313,7 +274,8 @@ try {
// 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、 // 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、
// 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。 // 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。
const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25"; const expiredTarget =
"/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
const catalogResponse = () => const catalogResponse = () =>
page.waitForResponse((response) => page.waitForResponse((response) =>
new URL(response.url()).pathname === "/api/workspaces" && new URL(response.url()).pathname === "/api/workspaces" &&
@@ -360,6 +322,7 @@ try {
assert(expiredCookieNames.includes(marker.name)); assert(expiredCookieNames.includes(marker.name));
assertEquals(await whoami(), { assertEquals(await whoami(), {
actor: null, actor: null,
authentication_reference: null,
session: { kind: "expired" }, session: { kind: "expired" },
}); });
privateRequests.length = 0; privateRequests.length = 0;
@@ -383,7 +346,11 @@ try {
[], [],
"expired reload must gate the real private catalog loader", "expired reload must gate the real private catalog loader",
); );
assertEquals(await whoami(), { actor: null, session: { kind: "expired" } }); assertEquals(await whoami(), {
actor: null,
authentication_reference: null,
session: { kind: "expired" },
});
await page.reload(); await page.reload();
await at(expiredLogin); await at(expiredLogin);
await page.getByRole("button", { name: "Log in with passkey", exact: true }) await page.getByRole("button", { name: "Log in with passkey", exact: true })
@@ -416,7 +383,11 @@ try {
// authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。 // authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。
await context.clearCookies({ name: session.name }); await context.clearCookies({ name: session.name });
const partial = await whoami(); const partial = await whoami();
assertEquals(partial, { actor: null, session: { kind: "anonymous" } }); assertEquals(partial, {
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
await page.reload(); await page.reload();
await at("/"); await at("/");
await context.clearCookies(); await context.clearCookies();
@@ -0,0 +1,611 @@
/// <reference lib="dom" />
// 実Backend + 本番static shell + Chromium native WebAuthn/CTAP2の旧鍵移行matrix。
// navigator.credentials、HTTP応答、暗号検証を置換しない。CDPは合成認証器の
// 能力/本人確認/タッチだけを制御し、credentialのexport/import/deleteはしない。
// 旧鍵fixtureだけは実optionsのuser.idとresident指定を変更してnative createする。
// 登録attestationはuser handleを署名しないため、本番verifier/storeで保存できる。
// 現行製品の登録動作の再現ではなく、旧random handle保存状態の明示的な合成である。
// 実スマホ/OSのprovider選択/物理鍵は対象外。mobileはChromiumのviewportのみ。
import { join } from "@std/path";
import { type Browser, type CDPSession, chromium, type Page } from "playwright";
import { serveProductionShell } from "./static_shell.ts";
const [backend, origin, build, handlePrefix, artifacts] = Deno.args;
if (!backend || !origin || !build || !handlePrefix || !artifacts) {
throw new Error(
"Expected owned Backend, origin, production build, synthetic handle prefix, artifacts",
);
}
const started = performance.now();
const server = await serveProductionShell(backend, origin, build);
let browser: Browser | undefined;
let phase = "launch";
const rows: {
resident: boolean;
authenticator: string;
outcome: string;
elapsed_ms: number;
}[] = [];
class NativeCheckError extends Error {}
function check(condition: unknown, diagnostic: string): asserts condition {
// 値をdumpするassertEqualsは使わない。例外/HTTP本文/credential/cookieをログに残さない。
if (!condition) throw new NativeCheckError(diagnostic);
}
type User = { user_id: string; account_id: string; handle: string };
type Key = {
key_reference: string;
normal_login_verified_at: string | null;
last_used_at: string | null;
};
async function whoami(page: Page) {
const response = await page.request.get(`${origin}/api/auth/whoami`);
check(response.status() === 200, "whoami status");
return await response.json() as {
actor: User | null;
authentication_reference: string | null;
session: { kind: string };
};
}
async function owner(page: Page, user: User, reference?: string) {
const current = await whoami(page);
check(
current.session.kind === "authenticated" &&
current.actor?.user_id === user.user_id &&
current.actor.account_id === user.account_id &&
current.actor.handle === user.handle,
"authenticated owner mismatch",
);
check(
typeof current.authentication_reference === "string",
"missing authentication reference",
);
if (reference) {
check(
current.authentication_reference === reference,
"issued authentication mismatch",
);
}
return current;
}
async function keys(page: Page, user: User): Promise<Key[]> {
const response = await page.request.get(`${origin}/api/auth/passkeys`);
check(response.status() === 200, "key list status");
const result = await response.json();
check(
result.user.user_id === user.user_id &&
result.user.account_id === user.account_id,
"key list owner",
);
return result.keys;
}
async function presence(
cdp: CDPSession,
authenticatorId: string,
enabled: boolean,
) {
await cdp.send("WebAuthn.setAutomaticPresenceSimulation", {
authenticatorId,
enabled,
});
}
async function authenticator(
cdp: CDPSession,
resident: boolean,
automaticPresenceSimulation = true,
) {
return (await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "usb",
hasResidentKey: resident,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation,
},
})).authenticatorId;
}
async function account(page: Page) {
await page.goto(origin + "/account");
await page.getByRole("heading", { name: "Account", exact: true }).waitFor();
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
}
async function logout(page: Page) {
await account(page);
await page.getByRole("button", { name: "Log out", exact: true }).click();
await page.waitForURL((url) => url.origin === origin && url.pathname === "/");
check(
(await whoami(page)).session.kind === "anonymous",
"logout must clear authentication",
);
}
async function login(page: Page, user: User, legacy: boolean) {
await page.goto(origin + "/login?returnTo=%2Faccount");
const button = page.getByRole("button", {
name: legacy ? "Log in with older key" : "Log in with passkey",
exact: true,
});
if (legacy) {
await page.getByText("Use an older key with account ID", { exact: true })
.click();
await page.getByLabel("Account ID (handle)", { exact: true }).fill(
user.handle,
);
} else {
// 互換入口は閉じたまま。通常loginのrequestにhandle/user/accountが入らないことも検査する。
check(
!await page.getByLabel("Account ID (handle)", { exact: true })
.isVisible(),
"normal login asks for ID",
);
}
const options = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/login/options"
);
const complete = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/login/complete"
);
await button.click();
const optionResponse = await options;
check(optionResponse.status() === 200, "login options status");
const input = optionResponse.request().postDataJSON();
if (!legacy) {
check(
Object.keys(input).every((key) => key === "browser_origin"),
"normal login selected an account",
);
const wire = await optionResponse.json();
check(
!wire.public_key.publicKey.allowCredentials?.length,
"normal login sends an allow-list",
);
}
const response = await complete;
check(response.status() === 200, "native login complete status");
const issued = await response.json();
check(
issued.user.user_id === user.user_id &&
issued.user.account_id === user.account_id,
"login returned wrong owner",
);
await page.waitForURL((url) => url.pathname === "/account");
await owner(page, user, issued.authentication_reference);
}
const terminate = () => {
void (async () => {
await browser?.close();
await server.shutdown();
Deno.exit(143);
})();
};
Deno.addSignalListener("SIGTERM", terminate);
try {
browser = await chromium.launch({ headless: true, timeout: 15_000 });
for (const resident of [false, true]) {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
serviceWorkers: "block",
});
context.setDefaultTimeout(12_000);
// 製品もfixtureも外部通信しない。loopback proxy以外へのbrowser requestは拒否する。
await context.route(
"**/*",
(route) =>
new URL(route.request().url()).origin === origin
? route.continue()
: route.abort(),
);
let page = await context.newPage();
let pageErrors = 0;
page.on("pageerror", () => ++pageErrors);
let cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable", { enableUI: false });
// 両行とも認証器はresident key対応にする。residentは旧credentialの作成指定だけを変え、
// resident必須の追加が能力不足で拒否されるケースとduplicate exclusionを混同しない。
const old = await authenticator(cdp, true);
let completionPosts = 0;
const completions = () => completionPosts;
page.on("request", (r) => {
if (
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
) ++completionPosts;
});
try {
phase = `legacy-fixture-resident-${resident}`;
await page.goto(origin + "/register");
// 生options/credentialはpage内にとどめる。user.id/residency以外はServerの要求を維持する。
const fixture = await page.evaluate(async ({ handle, resident }) => {
const post = async (path: string, body: unknown) => {
const response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error("synthetic legacy fixture HTTP rejected");
}
return await response.json();
};
const wire = await post("/api/auth/passkeys/registration/options", {
handle,
display_name: "Synthetic native migration",
browser_origin: location.origin,
});
const bytes = (value: string) =>
Uint8Array.from(
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
(c) => c.charCodeAt(0),
);
const options = wire.public_key.publicKey;
const random = crypto.getRandomValues(new Uint8Array(32));
const stable = bytes(options.user.id);
const differs = stable.length !== random.length ||
stable.some((byte: number, index: number) => byte !== random[index]);
options.challenge = bytes(options.challenge);
options.user.id = random;
options.excludeCredentials = options.excludeCredentials?.map((
item: { id: string },
) => ({ ...item, id: bytes(item.id) }));
options.authenticatorSelection = {
...options.authenticatorSelection,
residentKey: resident ? "required" : "discouraged",
requireResidentKey: resident,
};
const credential = await navigator.credentials.create({
publicKey: options,
}) as PublicKeyCredential | null;
if (
!credential ||
!(credential.response instanceof AuthenticatorAttestationResponse)
) throw new Error("native attestation missing");
const encode = (value: ArrayBuffer) =>
btoa(String.fromCharCode(...new Uint8Array(value))).replaceAll(
"+",
"-",
).replaceAll("/", "_").replaceAll("=", "");
const issued = await post("/api/auth/passkeys/registration/complete", {
challenge_id: wire.challenge_id,
credential: {
id: credential.id,
rawId: encode(credential.rawId),
type: credential.type,
response: {
attestationObject: encode(credential.response.attestationObject),
clientDataJSON: encode(credential.response.clientDataJSON),
transports: credential.response.getTransports(),
},
extensions: credential.getClientExtensionResults(),
},
});
return {
user: issued.user,
authentication_reference: issued.authentication_reference,
randomDiffersFromStable: differs,
};
}, {
handle: `${handlePrefix}-${resident ? "resident" : "nonresident"}`,
resident,
});
check(
fixture.randomDiffersFromStable,
"fixture must use a legacy random handle",
);
const user: User = fixture.user;
await owner(page, user, fixture.authentication_reference);
const initial = await keys(page, user);
check(
initial.length === 1 && initial[0].normal_login_verified_at === null,
"legacy key initially unverified",
);
const oldReference = initial[0].key_reference;
await logout(page);
phase = `older-key-login-before-resident-${resident}`;
await login(page, user, true);
phase = `same-authenticator-excluded-resident-${resident}`;
const sameStarted = performance.now();
const add = () =>
page.getByRole("button", {
name: `Add passkey to ${user.handle}`,
exact: true,
});
const optionsResponse = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
);
await add().click();
check(
(await optionsResponse).status() === 200,
"addition options status",
);
const excluded = page.getByRole("alert").filter({
hasText: "[authenticator/excluded]",
});
await excluded.waitFor({ state: "visible" });
check(
(await excluded.innerText()).includes(
"different device, security key, or passkey provider",
),
"exclusion needs a concrete alternative",
);
check(
(await excluded.innerText()).includes("do not delete"),
"exclusion must preserve working access",
);
check(
completions() === 0,
"native exclusion must not submit completion",
);
const afterExcluded = await keys(page, user);
check(
afterExcluded.length === 1 &&
afterExcluded[0].key_reference === oldReference,
"exclusion changed old key",
);
await owner(page, user);
rows.push({
resident,
authenticator: "same",
outcome: "native-exclusion-no-completion-old-key-preserved",
elapsed_ms: Math.round(performance.now() - sameStarted),
});
phase = `other-authenticator-pending-resident-${resident}`;
const otherStarted = performance.now();
// CDPの仮想認証器選択は物理providerの選択UIを再現しない。別targetに別認証器を置き、
// 旧targetと旧鍵は生存させる。共有contextの既存sessionを使い、cookie/鍵の移送はしない。
const legacyPage = page;
const legacyCdp = cdp;
page = await context.newPage();
page.on("pageerror", () => ++pageErrors);
page.on("request", (r) => {
if (
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
) ++completionPosts;
});
cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable", { enableUI: false });
const newer = await authenticator(cdp, true, false);
await account(page);
// options応答後、native操作はCTAPのpresence=falseで止める。timeout/sleepでpromptを推測しない。
const beforeAddition = await owner(page, user);
const additionOptions = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
);
const additionComplete = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
);
await add().click();
const optionResponse = await additionOptions;
check(
optionResponse.status() === 200,
"other authenticator options status",
);
let optionsInput = optionResponse.request().postDataJSON();
check(
optionsInput.expected_user_id === user.user_id &&
optionsInput.expected_account_id === user.account_id &&
optionsInput.expected_authentication_reference ===
beforeAddition.authentication_reference,
"addition must fence displayed owner and session",
);
let wire = await optionResponse.json();
check(
wire.public_key.publicKey.excludeCredentials.length === 1,
"old credential must remain excluded",
);
check(
wire.public_key.publicKey.authenticatorSelection.userVerification ===
"required",
"addition must require UV",
);
check(
wire.public_key.publicKey.authenticatorSelection.residentKey ===
"required",
"product addition must require residency",
);
const pending = page.getByRole("status").filter({
hasText: "Waiting for passkey addition",
});
await pending.waitFor({ state: "visible" });
check(
await page.getByRole("heading", { name: "Account", exact: true })
.isVisible(),
"account hidden while native pending",
);
check(
await add().isDisabled() && completions() === 0,
"pending must prevent duplicate/early completion",
);
for (
const viewport of [{ width: 1280, height: 900 }, {
width: 390,
height: 844,
}]
) {
await page.setViewportSize(viewport);
await pending.scrollIntoViewIfNeeded();
check(
await pending.isVisible(),
"pending must remain visible at viewport",
);
await page.screenshot({
path: join(
artifacts,
`native-addition-pending-${resident}-${viewport.width}.png`,
),
fullPage: false,
});
}
await page.setViewportSize({ width: 1280, height: 900 });
// presence=falseで既に待機中のCTAP要求を後からtrueにして解放できるとは仮定しない。
// pending/取消と成功を分ける。送信前の停止を確認し、別ceremonyを明示開始する。
await page.getByRole("button", { name: "Stop waiting", exact: true })
.click();
await page.getByRole("alert").filter({
hasText: "Completion was not sent.",
}).waitFor();
check(
completions() === 0 && (await keys(page, user)).length === 1,
"cancel before touch must not save or complete",
);
await presence(cdp, newer, true);
const freshOptions = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
);
await add().click();
const freshResponse = await freshOptions;
check(freshResponse.status() === 200, "fresh addition options status");
optionsInput = freshResponse.request().postDataJSON();
wire = await freshResponse.json();
phase = `other-authenticator-completion-resident-${resident}`;
const complete = await additionComplete;
check(complete.status() === 200, "native addition complete status");
const issued = await complete.json();
check(
issued.user.user_id === user.user_id &&
issued.user.account_id === user.account_id &&
typeof issued.authentication_reference === "string",
"addition complete owner/reference",
);
await page.getByRole("status").filter({
hasText: "Passkey added to your account",
}).waitFor({ state: "visible" });
await owner(page, user, issued.authentication_reference);
check(completions() === 1, "addition must submit exactly once");
// 元のbinding referenceはreceipt照合用。Cookie発行後も同じ現在ownerから確認する。
const receipt = await page.request.post(
`${origin}/api/auth/passkeys/addition/result`,
{
headers: { Origin: origin },
data: {
challenge_id: wire.challenge_id,
expected_user_id: user.user_id,
expected_account_id: user.account_id,
expected_authentication_reference:
optionsInput.expected_authentication_reference,
},
},
);
check(
receipt.status() === 200,
"saved addition receipt after Cookie issuance",
);
const saved = await receipt.json();
check(
saved.status === "saved" && saved.user.user_id === user.user_id &&
saved.user.account_id === user.account_id,
"receipt must identify saved owner",
);
const added = await keys(page, user);
check(
added.length === 2 &&
added.some((key) => key.key_reference === oldReference),
"addition must retain older key",
);
const newKey = added.find((key) => key.key_reference !== oldReference);
check(
newKey && saved.key.key_reference === newKey.key_reference &&
newKey.normal_login_verified_at === null,
"storage must not assert normal login",
);
await logout(page);
phase = `handle-free-login-resident-${resident}`;
await login(page, user, false);
const verified = await keys(page, user);
check(
verified.length === 2 &&
verified.find((key) => key.key_reference === newKey.key_reference)
?.normal_login_verified_at != null,
"actual handle-free verifier success must mark the new key",
);
check(
verified.find((key) => key.key_reference === oldReference)
?.normal_login_verified_at === null,
"normal login must not mark the unused legacy key",
);
await logout(page);
phase = `older-key-login-after-resident-${resident}`;
await presence(cdp, newer, false);
page = legacyPage;
cdp = legacyCdp;
await presence(cdp, old, true);
await login(page, user, true);
const retained = await keys(page, user);
check(
retained.length === 2 &&
retained.find((key) => key.key_reference === oldReference)
?.last_used_at != null,
"older key must remain usable after migration",
);
check(
retained.find((key) => key.key_reference === newKey.key_reference)
?.normal_login_verified_at != null,
"older login must preserve new key verification",
);
check(pageErrors === 0, "product page emitted an uncaught error");
rows.push({
resident,
authenticator: "other",
outcome:
"saved-correct-owner-handle-free-verified-older-login-preserved",
elapsed_ms: Math.round(performance.now() - otherStarted),
});
} finally {
await context.close();
}
}
await Deno.writeTextFile(
join(artifacts, "native-migration-result.json"),
JSON.stringify(
{
status: "passed",
boundary:
"real Backend/static shell/native Chromium CTAP2; synthetic legacy creation override only",
chromium: browser.version(),
rows,
elapsed_ms: Math.round(performance.now() - started),
viewports: ["1280x900", "390x844"],
physical_smartphone: "unconfirmed",
physical_authenticator: "unconfirmed",
},
null,
2,
),
);
console.error(
`native-migration: PASS ${rows.length} matrix rows; ${
Math.round(performance.now() - started)
}ms`,
);
} catch (error) {
const diagnostic = error instanceof NativeCheckError
? error.message
: "browser-or-tool-operation-failed";
await Deno.writeTextFile(
join(artifacts, "native-migration-result.json"),
JSON.stringify(
{
status: "failed",
phase,
diagnostic,
rows,
elapsed_ms: Math.round(performance.now() - started),
physical_smartphone: "unconfirmed",
physical_authenticator: "unconfirmed",
},
null,
2,
),
);
// Playwrightの例外にはrequest/引数を含み得るため本文やstackを保存しない。
console.error(
`native-migration: FAIL phase=${phase}; completed_rows=${rows.length}; elapsed_ms=${
Math.round(performance.now() - started)
}`,
);
Deno.exitCode = 1;
} finally {
Deno.removeSignalListener("SIGTERM", terminate);
await browser?.close();
await server.shutdown();
}
+76
View File
@@ -0,0 +1,76 @@
// 実Backendへの透過proxyと本番SPA配信をbrowser suite間で共有する。
// Cookie/Origin/署名検証/認可は置換しない。fixture入口は呼出側が明示的に渡す。
import { extname, join, normalize } from "@std/path";
export async function serveProductionShell(
backend: string,
origin: string,
build: string,
fixture?: (request: Request) => Promise<Response | undefined>,
) {
for (const address of [backend, origin]) {
const url = new URL(address);
if (
url.protocol !== "http:" ||
!["localhost", "127.0.0.1"].includes(url.hostname) || !url.port
) {
throw new Error("Browser E2E requires explicit owned loopback endpoints");
}
}
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
".ico": "image/x-icon",
};
return Deno.serve({
hostname: "127.0.0.1",
port: Number(new URL(origin).port),
onListen() {},
}, async (request) => {
const url = new URL(request.url);
const special = await fixture?.(request);
if (special) return special;
if (url.pathname.startsWith("/api/")) {
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("connection");
return await fetch(new URL(url.pathname + url.search, backend), {
method: request.method,
headers,
body: ["GET", "HEAD"].includes(request.method)
? undefined
: await request.arrayBuffer(),
redirect: "manual",
});
}
const relative = normalize(
url.pathname.replace(/^\/+/, "") || "index.html",
);
if (relative.startsWith("..")) {
return new Response("not found", { status: 404 });
}
let file = join(build, relative);
try {
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
return new Response(await Deno.readFile(file), {
headers: {
"content-type": mime[extname(file)] ?? "application/octet-stream",
},
});
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
if (url.pathname.startsWith("/_app/")) {
return new Response("not found", { status: 404 });
}
return new Response(await Deno.readFile(join(build, "index.html")), {
headers: { "content-type": mime[".html"] },
});
}
});
}
@@ -0,0 +1,52 @@
{
"source_revision": "1639be98411921308238b3a6a6e75c784c045156",
"model": "codex-oauth/gpt-6.1-sol",
"worker_id": "01a1657d-b762-7791-87ae-a49f28cadbce",
"session_id": "01a1657d-b818-77c1-bd73-175a27562f63",
"rows": [
{
"entry_id": "01a1657f-18bf-7a03-b8b8-953d27041fe0",
"timestamp": 1792704190655,
"role": "user",
"parts": [
{
"kind": "text",
"content": "これは保存と復元を確認するための人工的な会話です。合言葉は「青い灯台」です。覚えたことを一文で返してください。Toolは使わないでください。"
}
]
},
{
"entry_id": "01a1657f-1f55-71d3-b418-8199b863f59a",
"timestamp": 1792704192341,
"role": "assistant",
"parts": [
{
"kind": "text",
"text": "合言葉は「青い灯台」と覚えました。"
}
]
},
{
"entry_id": "01a1657f-3053-71f3-bf4f-91290d82488b",
"timestamp": 1792704196691,
"role": "user",
"parts": [
{
"kind": "text",
"content": "先ほどの合言葉は何でしたか?合言葉だけを答えてください。"
}
]
},
{
"entry_id": "01a1657f-36ef-78b1-b0c8-b141f13b0c7b",
"timestamp": 1792704198383,
"role": "assistant",
"parts": [
{
"kind": "text",
"text": "青い灯台"
}
]
}
]
}
@@ -0,0 +1,34 @@
{
"format_version": 1,
"source_revision": "1639be98411921308238b3a6a6e75c784c045156",
"runtime_schema_version": 12,
"session_schema_version": 6,
"workspace_id": "01a16553-b901-7191-8ceb-8e2306fe3da9",
"runtime_id": "fixture-951707ee-93ca-4205-9c12-45f08a104a74",
"worker_id": "01a1657d-b762-7791-87ae-a49f28cadbce",
"session_id": "01a1657d-b818-77c1-bd73-175a27562f63",
"segment_id": "01a1657d-b818-77c1-bd73-176172a4bd82",
"model": "codex-oauth/gpt-6.1-sol",
"state": "stopped",
"acquisition": "Real isolated Codex OAuth two-turn artificial conversation; typed offline StoppedWorkerTemplate extraction. Session bytes unchanged; environment credentials and execution receipts excluded.",
"source_file_sha256": {
"worker.json": "912e620013a17f05c1aaf900da48c9ee0ecb1f54121efc3721e03f8755eb5eca",
"metadata.json": "657928a89ec621bd092b86924201ed7b562e9683261789ce18bdb1d248bdc450",
"session/session.json": "22b01daab68a28b9777a1f7abb9ef9f7f6cbae113089446602f5d15421e7c7f3",
"session/segments/01a1657d-b818-77c1-bd73-176172a4bd82.jsonl": "7c5663bb06d9406d5bd0a5086159b31c0d7799458fcb1c6f81e18b63c49f4df5"
},
"files": {
"worker-template.json": {
"bytes": 48343,
"sha256": "8bf6ce3c71c4f05c82314de93c1e8e51b7502b00915b6adb8b33046944e463b2"
},
"expected.json": {
"bytes": 1474,
"sha256": "b9846db47ecb8f3f53b2abd9d5f0c766122f86015a7a7df7addf8beab3e9c55a"
},
"workspace-config.dcdl": {
"bytes": 1141,
"sha256": "9b07507b71733081f077e2e9968a7f1f676c916a243bb355d1813f067a55c415"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
{
profile = {
default_profile = "project:fixture-chat";
entries = [{
selector = "project:fixture-chat";
label = "Isolated Codex fixture conversation";
description = "Artificial conversation only; no Workdir, Tools, Subject or background work.";
profile = (import "$builtin/profiles/job.dcdl") // {
slug = "fixture-chat";
worker = { mode = "tools"; };
model = { ref = "codex-oauth/gpt-6.1-sol"; };
scope = "workspace_read";
delegation_scope = "workspace_read";
permissions = { default_action = "deny"; rule = []; };
engine = { instruction = "default"; reasoning = "low"; max_tokens = 2048; max_turns = 4; language = "Japanese"; };
session = { record_event_trace = false; };
feature = { workdir_catalog = { enabled = false; }; };
};
}];
};
prompts = { default_prompt = "これは隔離環境の人工的な会話テストです。日本語で簡潔に応答してください。Toolや外部操作は行わず、ユーザーが会話中に提示した情報だけを使ってください。"; };
} as WorkspaceConfigSchema
+173
View File
@@ -0,0 +1,173 @@
//! Byte-preserving fixture transport, independent of the owning store's schema validation.
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::{HarnessError, Result};
#[derive(Debug, Clone)]
pub struct FixtureSource {
root: PathBuf,
hashes: BTreeMap<PathBuf, String>,
}
impl FixtureSource {
/// Record every supplied file, including the manifest. No fixture is generated
/// or repaired here; the typed store importer must additionally validate it.
pub fn capture(root: &Path) -> Result<Self> {
let hashes = inventory(root)?;
if hashes.is_empty() {
return Err(HarnessError::Protocol("provided fixture is empty".into()));
}
Ok(Self {
root: root.to_owned(),
hashes,
})
}
pub fn hashes(&self) -> &BTreeMap<PathBuf, String> {
&self.hashes
}
pub fn assert_unchanged(&self) -> Result<()> {
if inventory(&self.root)? != self.hashes {
return Err(HarnessError::Protocol(
"provided fixture changed during execution".into(),
));
}
Ok(())
}
/// The destination must not exist. A copy is never a link to the source.
pub fn copy_to(&self, destination: &Path) -> Result<()> {
self.assert_unchanged()?;
let parent = destination
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
if parent
.canonicalize()?
.starts_with(self.root.canonicalize()?)
{
return Err(HarnessError::Protocol(
"fixture copy destination is inside the original".into(),
));
}
fs::create_dir(destination)?;
for (relative, expected) in &self.hashes {
let bytes = fs::read(self.root.join(relative))?;
if hash(&bytes) != *expected {
return Err(HarnessError::Protocol(
"fixture changed while copying".into(),
));
}
let target = destination.join(relative);
fs::create_dir_all(target.parent().unwrap())?;
use std::io::Write;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(target)?;
file.write_all(&bytes)?;
}
if inventory(destination)? != self.hashes {
return Err(HarnessError::Protocol(
"fixture copy failed hash verification".into(),
));
}
self.assert_unchanged()
}
}
fn hash(bytes: &[u8]) -> String {
format!(
"sha256:{}",
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
)
}
fn inventory(root: &Path) -> Result<BTreeMap<PathBuf, String>> {
fn visit(root: &Path, path: &Path, files: &mut BTreeMap<PathBuf, String>) -> Result<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.is_dir() {
for entry in fs::read_dir(path)? {
visit(root, &entry?.path(), files)?;
}
} else if metadata.is_file() {
files.insert(
path.strip_prefix(root).unwrap().to_owned(),
hash(&fs::read(path)?),
);
} else {
return Err(HarnessError::Protocol(
"fixture contains a symlink or special file".into(),
));
}
Ok(())
}
if !fs::symlink_metadata(root)?.is_dir() {
return Err(HarnessError::Protocol(
"fixture source must be a real directory".into(),
));
}
let mut files = BTreeMap::new();
visit(root, root, &mut files)?;
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
// These are transport-only byte samples, never saved Worker substitutes.
#[test]
fn copies_are_independent_and_source_mutation_is_detected() -> Result<()> {
let temp = tempfile::tempdir()?;
let source = temp.path().join("source");
fs::create_dir(&source)?;
fs::write(source.join("bytes"), b"original")?;
let fixture = FixtureSource::capture(&source)?;
assert!(fixture.copy_to(&source.join("nested-copy")).is_err());
assert!(!source.join("nested-copy").exists());
fixture.assert_unchanged()?;
let first = temp.path().join("first");
let second = temp.path().join("second");
fixture.copy_to(&first)?;
fixture.copy_to(&second)?;
fs::write(first.join("bytes"), b"changed")?;
fs::remove_dir_all(first)?;
assert_eq!(fs::read(second.join("bytes"))?, b"original");
fixture.assert_unchanged()?;
fs::write(source.join("bytes"), b"changed")?;
assert!(fixture.assert_unchanged().is_err());
Ok(())
}
#[test]
fn rejects_missing_empty_existing_destination_and_extra_file() -> Result<()> {
let temp = tempfile::tempdir()?;
assert!(FixtureSource::capture(&temp.path().join("missing")).is_err());
assert!(FixtureSource::capture(temp.path()).is_err());
fs::write(temp.path().join("bytes"), b"original")?;
let source = FixtureSource::capture(temp.path())?;
assert!(source.copy_to(temp.path()).is_err());
fs::write(temp.path().join("extra"), b"new")?;
assert!(source.assert_unchanged().is_err());
Ok(())
}
#[cfg(unix)]
#[test]
fn rejects_symbolic_links() -> Result<()> {
let temp = tempfile::tempdir()?;
fs::write(temp.path().join("bytes"), b"original")?;
std::os::unix::fs::symlink("bytes", temp.path().join("link"))?;
assert!(FixtureSource::capture(temp.path()).is_err());
Ok(())
}
}
+29 -1
View File
@@ -1268,7 +1268,11 @@ impl Drop for PanelHarness {
} }
} }
mod fixture_source;
pub use fixture_source::FixtureSource;
mod worker_environment; mod worker_environment;
mod worker_session;
pub use worker_session::{WorkerSessionSubscription, validate_full_snapshot};
pub use worker_environment::{ pub use worker_environment::{
WorkerBinaryProviderInfo, WorkerE2eEnvironment, WorkerEnvironmentCleanupReport, WorkerBinaryProviderInfo, WorkerE2eEnvironment, WorkerEnvironmentCleanupReport,
@@ -1627,6 +1631,8 @@ fn resolve_yoi_binary() -> Result<BinaryProviderInfo> {
"e2e-test".to_owned(), "e2e-test".to_owned(),
"--bin".to_owned(), "--bin".to_owned(),
"yoi".to_owned(), "yoi".to_owned(),
"--target-dir".to_owned(),
tui_product_target_dir()?.to_string_lossy().into_owned(),
]; ];
if test_profile() == "release" { if test_profile() == "release" {
args.push("--release".to_owned()); args.push("--release".to_owned());
@@ -1648,7 +1654,9 @@ fn resolve_yoi_binary() -> Result<BinaryProviderInfo> {
}); });
} }
let binary = current_target_profile_dir()?.join(binary_name()); let binary = tui_product_target_dir()?
.join(test_profile())
.join(binary_name());
let info = BinaryProviderInfo { let info = BinaryProviderInfo {
provider: "cargo-build".to_owned(), provider: "cargo-build".to_owned(),
binary, binary,
@@ -1675,6 +1683,26 @@ fn workspace_root() -> Result<PathBuf> {
.ok_or_else(|| HarnessError::Protocol("could not resolve workspace root".to_owned())) .ok_or_else(|| HarnessError::Protocol("could not resolve workspace root".to_owned()))
} }
/// Product processes must never reuse the target used by test-support builds.
fn product_target_dir() -> Result<PathBuf> {
dedicated_product_target("e2e-products")
}
// Existing PTY tests need their observer feature, never on restore binaries.
fn tui_product_target_dir() -> Result<PathBuf> {
dedicated_product_target("e2e-tui-products")
}
fn dedicated_product_target(name: &str) -> Result<PathBuf> {
let target = workspace_root()?.join("target").join(name);
if current_target_profile_dir()?.parent() == Some(target.as_path()) {
return Err(HarnessError::Protocol(
"run support tests outside reserved product binary targets".to_owned(),
));
}
Ok(target)
}
fn current_target_profile_dir() -> Result<PathBuf> { fn current_target_profile_dir() -> Result<PathBuf> {
let mut path = std::env::current_exe()?; let mut path = std::env::current_exe()?;
while let Some(name) = path.file_name().and_then(|name| name.to_str()) { while let Some(name) = path.file_name().and_then(|name| name.to_str()) {
+311 -36
View File
@@ -13,8 +13,8 @@ use serde_json::Value;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use super::{ use super::{
FixtureAllocation, HarnessError, Result, allocate_fixture, command_display, FixtureAllocation, HarnessError, Result, allocate_fixture, command_display, now_ms,
current_target_profile_dir, now_ms, test_profile, workspace_root, yoi_binary_info, product_target_dir, test_profile, workspace_root,
}; };
const STARTUP_WAIT: Duration = Duration::from_secs(15); const STARTUP_WAIT: Duration = Duration::from_secs(15);
@@ -61,22 +61,20 @@ pub fn worker_binary_provider_info() -> Result<WorkerBinaryProviderInfo> {
fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> { fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> {
let workspace_root = workspace_root()?; let workspace_root = workspace_root()?;
let profile = test_profile(); let profile = test_profile();
let yoi = yoi_binary_info()?; let mut records = Vec::new();
let mut records = vec![binary_record( let yoi_override = resolve_override("YOI_E2E_BIN", &workspace_root)?;
"yoi",
yoi.binary,
yoi.provider,
yoi.build_command,
)?];
let server_override = resolve_override("YOI_E2E_SERVER_BIN", &workspace_root)?; let server_override = resolve_override("YOI_E2E_SERVER_BIN", &workspace_root)?;
let runtime_override = resolve_override("YOI_E2E_RUNTIME_BIN", &workspace_root)?; let runtime_override = resolve_override("YOI_E2E_RUNTIME_BIN", &workspace_root)?;
let mut build_command = None; let mut build_command = None;
if server_override.is_none() || runtime_override.is_none() { if yoi_override.is_none() || server_override.is_none() || runtime_override.is_none() {
let cargo = PathBuf::from(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())); let cargo = PathBuf::from(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
let mut args = vec![ let mut args = vec![
"build".to_string(), "build".to_string(),
"-p".to_string(), "-p".to_string(),
"yoi".to_string(),
"--bin".to_string(),
"yoi".to_string(),
"-p".to_string(),
"yoi-workspace-server".to_string(), "yoi-workspace-server".to_string(),
"--bin".to_string(), "--bin".to_string(),
"yoi-server".to_string(), "yoi-server".to_string(),
@@ -84,6 +82,8 @@ fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> {
"worker-runtime".to_string(), "worker-runtime".to_string(),
"--bin".to_string(), "--bin".to_string(),
"yoi-runtime".to_string(), "yoi-runtime".to_string(),
"--target-dir".to_string(),
product_target_dir()?.to_string_lossy().into_owned(),
]; ];
if profile == "release" { if profile == "release" {
args.push("--release".to_string()); args.push("--release".to_string());
@@ -106,7 +106,16 @@ fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> {
build_command = Some(rendered); build_command = Some(rendered);
} }
let profile_dir = current_target_profile_dir()?; let profile_dir = product_target_dir()?.join(&profile);
let (yoi_path, yoi_provider, yoi_build) = match yoi_override {
Some(path) => (path, "YOI_E2E_BIN".to_string(), None),
None => (
profile_dir.join(executable_name("yoi")),
"cargo-build".to_string(),
build_command.clone(),
),
};
records.push(binary_record("yoi", yoi_path, yoi_provider, yoi_build)?);
let (server_path, server_provider, server_build) = match server_override { let (server_path, server_provider, server_build) = match server_override {
Some(path) => (path, "YOI_E2E_SERVER_BIN".to_string(), None), Some(path) => (path, "YOI_E2E_SERVER_BIN".to_string(), None),
None => ( None => (
@@ -231,6 +240,7 @@ pub struct WorkerE2eEnvironment {
pub runtime_url: Option<String>, pub runtime_url: Option<String>,
server: Option<ManagedProcess>, server: Option<ManagedProcess>,
runtime: Option<ManagedProcess>, runtime: Option<ManagedProcess>,
runtime_public_identity: Option<Value>,
access_token: Option<String>, access_token: Option<String>,
client: Client, client: Client,
binaries: WorkerBinaryProviderInfo, binaries: WorkerBinaryProviderInfo,
@@ -292,6 +302,7 @@ impl WorkerE2eEnvironment {
runtime_url: None, runtime_url: None,
server: None, server: None,
runtime: None, runtime: None,
runtime_public_identity: None,
access_token: None, access_token: None,
client, client,
binaries, binaries,
@@ -374,6 +385,47 @@ impl WorkerE2eEnvironment {
Ok(()) Ok(())
} }
/// Start a store populated by the Backend's typed test-support API. The seed
/// supplies fixed catalog IDs, but the bearer must be freshly issued for this
/// environment. No database mutation or fixture credential import occurs here.
pub fn start_seeded_backend(
&mut self,
workspace_id: String,
runtime_id: String,
access_token: String,
) -> Result<()> {
if self.server.is_some() || self.runtime.is_some() || self.workspace_id.is_some() {
return Err(HarnessError::Protocol(
"fixture seed requires an unstarted environment".into(),
));
}
if workspace_id.is_empty() || runtime_id.is_empty() || access_token.is_empty() {
return Err(HarnessError::Protocol(
"fixture seed omitted an identity or fresh bearer".into(),
));
}
if !self.server_data_dir.join("server.db").is_file() {
return Err(HarnessError::Protocol(
"typed Backend seed has not created server.db".into(),
));
}
if self.runtime_public_identity.is_some() {
if self.runtime_id != runtime_id {
return Err(HarnessError::Protocol(
"seeded Runtime ID differs from initialized identity".into(),
));
}
} else {
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
}
self.workspace_id = Some(workspace_id);
self.runtime_id = runtime_id;
self.access_token = Some(access_token);
self.start_server_process()?;
self.export_workspace_public_identity()?;
self.write_metadata("seeded-backend-running")
}
pub fn start_backend(&mut self) -> Result<()> { pub fn start_backend(&mut self) -> Result<()> {
self.start_empty_backend()?; self.start_empty_backend()?;
let server_binary = self.binaries.binary("yoi-server")?; let server_binary = self.binaries.binary("yoi-server")?;
@@ -417,7 +469,15 @@ impl WorkerE2eEnvironment {
"create Workspace", "create Workspace",
)?; )?;
let workspace_id = json_string(&created, "/workspace/workspace_id")?; let workspace_id = json_string(&created, "/workspace/workspace_id")?;
self.workspace_id = Some(workspace_id.clone()); self.workspace_id = Some(workspace_id);
self.export_workspace_public_identity()?;
self.write_metadata("backend-running")?;
Ok(())
}
fn export_workspace_public_identity(&self) -> Result<()> {
let server_url = self.required_server_url()?;
let workspace_id = self.required_workspace_id()?;
let identity = self.request_json( let identity = self.request_json(
self.client.get(format!( self.client.get(format!(
"{server_url}/api/w/{workspace_id}/settings/signing-identity" "{server_url}/api/w/{workspace_id}/settings/signing-identity"
@@ -429,17 +489,27 @@ impl WorkerE2eEnvironment {
})?; })?;
let bundle_path = self.root.join("workspace-public-identity.json"); let bundle_path = self.root.join("workspace-public-identity.json");
fs::write(&bundle_path, serde_json::to_vec_pretty(&public_bundle)?)?; fs::write(&bundle_path, serde_json::to_vec_pretty(&public_bundle)?)?;
self.write_metadata("backend-running")?;
Ok(()) Ok(())
} }
fn start_runtime(&mut self) -> Result<()> { fn start_runtime(&mut self) -> Result<()> {
let bundle_path = self.root.join("workspace-public-identity.json"); self.start_runtime_with_seed(|_| Ok(()))?;
let server_url = self.required_server_url()?.to_string(); self.assert_connected_and_empty()
let workspace_id = self.required_workspace_id()?.to_string(); }
let runtime_binary = self.binaries.binary("yoi-runtime")?;
/// Generate fresh keys before offline Backend registration when fixed IDs are
/// required. Returns only the public bundle; no fixture keys are accepted.
pub fn initialize_runtime_identity(&mut self, runtime_id: String) -> Result<Value> {
if self.runtime_public_identity.is_some() || self.runtime.is_some() {
return Err(HarnessError::Protocol(
"Runtime identity is already initialized".into(),
));
}
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
self.runtime_id = runtime_id;
let binary = self.binaries.binary("yoi-runtime")?;
self.run_command( self.run_command(
&runtime_binary, &binary,
&[ &[
"identity", "identity",
"init", "init",
@@ -450,8 +520,8 @@ impl WorkerE2eEnvironment {
], ],
false, false,
)?; )?;
let runtime_identity = self.run_command( let identity = self.run_command(
&runtime_binary, &binary,
&[ &[
"identity", "identity",
"show", "show",
@@ -461,7 +531,31 @@ impl WorkerE2eEnvironment {
], ],
false, false,
)?; )?;
let runtime_identity: Value = serde_json::from_slice(&runtime_identity.stdout)?; let identity: Value = serde_json::from_slice(&identity.stdout)?;
self.runtime_public_identity = Some(identity.clone());
Ok(identity)
}
/// Initialize fresh Runtime keys/trust, then invoke the typed Runtime seed
/// before starting its process. The actual Backend URL is now available.
pub fn start_runtime_with_seed(
&mut self,
seed: impl FnOnce(&Self) -> Result<()>,
) -> Result<()> {
if self.runtime.is_some() {
return Err(HarnessError::Protocol("Runtime is already running".into()));
}
if self.runtime_public_identity.is_none() {
self.initialize_runtime_identity(self.runtime_id.clone())?;
}
let runtime_identity = self
.runtime_public_identity
.clone()
.expect("initialized identity");
let bundle_path = self.root.join("workspace-public-identity.json");
let server_url = self.required_server_url()?.to_string();
let workspace_id = self.required_workspace_id()?.to_string();
let runtime_binary = self.binaries.binary("yoi-runtime")?;
self.run_command( self.run_command(
&runtime_binary, &runtime_binary,
&[ &[
@@ -497,6 +591,8 @@ impl WorkerE2eEnvironment {
)); ));
} }
seed(self)?;
self.write_metadata("runtime-seeded")?;
let mut command = self.isolated_command(&runtime_binary); let mut command = self.isolated_command(&runtime_binary);
command.args([ command.args([
"--bind", "--bind",
@@ -519,22 +615,38 @@ impl WorkerE2eEnvironment {
self.runtime = Some(runtime); self.runtime = Some(runtime);
self.runtime_url = Some(runtime_url.clone()); self.runtime_url = Some(runtime_url.clone());
let registered = self.request_json(
self.client
.get(format!("{server_url}/api/w/{workspace_id}/runtimes")),
"observe Runtime registration before endpoint update",
)?;
let expected_binding_id = registered
.get("items")
.and_then(Value::as_array)
.and_then(|items| {
items.iter().find(|item| {
item.get("runtime_id").and_then(Value::as_str) == Some(self.runtime_id.as_str())
})
})
.and_then(|item| item.pointer("/management/binding/binding_id"))
.cloned();
self.request_json( self.request_json(
self.client self.client
.post(format!("{server_url}/api/w/{workspace_id}/runtimes")) .post(format!("{server_url}/api/w/{workspace_id}/runtimes"))
.json(&serde_json::json!({ .json(&serde_json::json!({
"public_bundle": runtime_identity, "public_bundle": runtime_identity,
"display_name": format!("E2E {}", self.runtime_id), "display_name": format!("E2E {}", self.runtime_id),
"endpoint": runtime_url "endpoint": runtime_url,
"expected_binding_id": expected_binding_id,
})), })),
"register Runtime", "register Runtime",
)?; )?;
self.assert_connected_and_empty()?; self.assert_connected()?;
self.write_metadata("running")?; self.write_metadata("running")?;
Ok(()) Ok(())
} }
pub fn assert_connected_and_empty(&self) -> Result<()> { pub fn assert_connected(&self) -> Result<()> {
let server_url = self.required_server_url()?; let server_url = self.required_server_url()?;
let workspace_id = self.required_workspace_id()?; let workspace_id = self.required_workspace_id()?;
// connection testは実署名要求。保存済みの接続flagを認証成功の代わりにしない。 // connection testは実署名要求。保存済みの接続flagを認証成功の代わりにしない。
@@ -574,11 +686,22 @@ impl WorkerE2eEnvironment {
self.runtime_id self.runtime_id
))); )));
} }
let workers = self.request_json( Ok(())
}
pub fn list_workers(&self) -> Result<Value> {
let server_url = self.required_server_url()?;
let workspace_id = self.required_workspace_id()?;
self.request_json(
self.client self.client
.get(format!("{server_url}/api/w/{workspace_id}/workers")), .get(format!("{server_url}/api/w/{workspace_id}/workers")),
"list Workers", "list Workers",
)?; )
}
pub fn assert_connected_and_empty(&self) -> Result<()> {
self.assert_connected()?;
let workers = self.list_workers()?;
if workers if workers
.get("items") .get("items")
.and_then(Value::as_array) .and_then(Value::as_array)
@@ -591,6 +714,104 @@ impl WorkerE2eEnvironment {
Ok(()) Ok(())
} }
fn worker_url(&self, worker_id: &str, suffix: &str) -> Result<String> {
let mut url = reqwest::Url::parse(self.required_server_url()?)
.map_err(|error| HarnessError::Protocol(format!("invalid Server URL: {error}")))?;
url.path_segments_mut()
.map_err(|_| HarnessError::Protocol("Server URL has no path segments".into()))?
.extend([
"api",
"w",
self.required_workspace_id()?,
"runtimes",
&self.runtime_id,
"workers",
worker_id,
]);
if !suffix.is_empty() {
url.path_segments_mut().unwrap().extend(suffix.split('/'));
}
Ok(url.into())
}
pub fn worker_detail(&self, worker_id: &str) -> Result<Value> {
self.request_json(
self.client.get(self.worker_url(worker_id, "")?),
"read Worker detail",
)
}
/// Exactly one restore submission. No retries, replacement token or new intent.
pub fn restore_worker(
&self,
worker_id: &str,
request_id: &str,
expected_observation_token: &str,
) -> Result<Value> {
self.request_json(
self.client
.post(self.worker_url(worker_id, "restore")?)
.timeout(STARTUP_WAIT)
.json(&serde_json::json!({
"request_id": request_id,
"expected_observation_token": expected_observation_token,
})),
"restore Worker",
)
}
pub fn stop_worker(&self, worker_id: &str) -> Result<Value> {
self.request_json(
self.client
.post(self.worker_url(worker_id, "stop")?)
.json(&serde_json::json!({})),
"stop Worker",
)
}
/// Authorization stays in memory; callers must not persist or debug this request.
pub fn worker_protocol_request(&self, worker_id: &str) -> Result<reqwest::blocking::Request> {
let token = self.access_token.as_deref().ok_or_else(|| {
HarnessError::Protocol("Backend access token is not initialized".into())
})?;
self.client
.get(self.worker_url(worker_id, "protocol/ws")?)
.bearer_auth(token)
.build()
.map_err(|_| HarnessError::Protocol("build Worker protocol request".into()))
}
/// The other environment must reject this environment's fresh bearer even
/// when both contain identical Workspace/Runtime/Worker IDs. Never logs tokens.
pub fn assert_authentication_isolated_from(&self, other: &Self) -> Result<()> {
let token = self
.access_token
.as_deref()
.ok_or_else(|| HarnessError::Protocol("missing source bearer".into()))?;
if other.access_token.as_deref() == Some(token) {
return Err(HarnessError::Protocol(
"environments share authentication material".into(),
));
}
let (server, workspace) = other.backend_target()?;
let response = self
.client
.get(format!("{server}/api/w/{workspace}/workers"))
.bearer_auth(token)
.send()
.map_err(|_| {
HarnessError::Protocol("cross-environment authorization request failed".into())
})?;
let status = response.status().as_u16();
other.append_operation("reject other environment bearer", status)?;
if status != 401 {
return Err(HarnessError::Protocol(format!(
"cross-environment bearer was not rejected with 401: {status}"
)));
}
Ok(())
}
pub fn install_backend_token(&self, config_home: &Path) -> Result<PathBuf> { pub fn install_backend_token(&self, config_home: &Path) -> Result<PathBuf> {
let server_url = self.required_server_url()?; let server_url = self.required_server_url()?;
let access_token = self.access_token.as_deref().ok_or_else(|| { let access_token = self.access_token.as_deref().ok_or_else(|| {
@@ -636,7 +857,7 @@ impl WorkerE2eEnvironment {
"assignee": null, "assignee": null,
"readiness": null, "readiness": null,
"risk_flags": [], "risk_flags": [],
"workflow_state": "planning", "workflow_state": "open",
"queued_by": null, "queued_by": null,
"queued_at": null, "queued_at": null,
"repository_key": null, "repository_key": null,
@@ -853,6 +1074,27 @@ impl WorkerE2eEnvironment {
Ok(output) Ok(output)
} }
/// browser toolだけに必要なtool探索設定を引き継ぎ、profile・HOME・XDG・一時領域は
/// このfixtureへ閉じ込める。DENO_DIR/PLAYWRIGHT_BROWSERS_PATHはインストール済みtoolの
/// 供給元であり、製品データや認証情報を引き継ぐ経路にはしない。
pub fn browser_command(&self, binary: &Path) -> Result<Command> {
let snapshot: std::collections::HashMap<_, _> = std::env::vars_os().collect();
let mut command = self.isolated_command(binary);
for name in ["PATH", "DENO_DIR", "PLAYWRIGHT_BROWSERS_PATH"] {
if let Some(value) = snapshot.get(std::ffi::OsStr::new(name)) {
command.env(name, value);
}
}
let temporary = self.root.join("browser-tmp");
let cache = self.root.join("browser-cache");
fs::create_dir_all(&temporary)?;
fs::create_dir_all(&cache)?;
command
.env("TMPDIR", temporary)
.env("XDG_CACHE_HOME", cache);
Ok(command)
}
fn isolated_command(&self, binary: &Path) -> Command { fn isolated_command(&self, binary: &Path) -> Command {
let mut command = Command::new(binary); let mut command = Command::new(binary);
command command
@@ -1026,7 +1268,7 @@ impl ManagedProcess {
fn shutdown(&mut self, timeout: Duration) -> Result<ExitStatus> { fn shutdown(&mut self, timeout: Duration) -> Result<ExitStatus> {
if let Some(status) = self.child.try_wait()? { if let Some(status) = self.child.try_wait()? {
self.join_readers(); self.join_readers();
return Ok(status); return self.require_successful_exit(status);
} }
let signal_result = unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; let signal_result = unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) };
if signal_result != 0 { if signal_result != 0 {
@@ -1036,13 +1278,7 @@ impl ManagedProcess {
loop { loop {
if let Some(status) = self.child.try_wait()? { if let Some(status) = self.child.try_wait()? {
self.join_readers(); self.join_readers();
if status.success() { return self.require_successful_exit(status);
return Ok(status);
}
return Err(HarnessError::Protocol(format!(
"{} did not complete graceful shutdown: {status}",
self.name
)));
} }
if start.elapsed() >= timeout { if start.elapsed() >= timeout {
let _ = self.child.kill(); let _ = self.child.kill();
@@ -1057,6 +1293,17 @@ impl ManagedProcess {
} }
} }
fn require_successful_exit(&self, status: ExitStatus) -> Result<ExitStatus> {
if status.success() {
Ok(status)
} else {
Err(HarnessError::Protocol(format!(
"{} did not complete graceful shutdown: {status}",
self.name
)))
}
}
fn join_readers(&mut self) { fn join_readers(&mut self) {
for reader in self.readers.drain(..) { for reader in self.readers.drain(..) {
let _ = reader.join(); let _ = reader.join();
@@ -1177,6 +1424,34 @@ fn io_error(operation: &str, error: std::io::Error) -> HarnessError {
mod startup_address_tests { mod startup_address_tests {
use super::parse_address_after; use super::parse_address_after;
#[test]
fn shutdown_rejects_nonzero_exit_even_when_process_already_exited() -> super::Result<()> {
let root = tempfile::tempdir()?;
for code in [0, 7] {
let mut command = std::process::Command::new("/bin/sh");
command
.env_clear()
.current_dir(root.path())
.args(["-c", &format!("exit {code}")]);
let mut process = super::ManagedProcess::spawn(
"already-exited",
command,
&root.path().join(format!("{code}.stdout")),
&root.path().join(format!("{code}.stderr")),
)?;
// Reap first to deterministically exercise the initial try_wait branch.
assert_eq!(process.child.wait()?.code(), Some(code));
let result = process.shutdown(std::time::Duration::from_secs(1));
if code == 0 {
assert!(result?.success());
} else {
assert!(matches!(result, Err(super::HarnessError::Protocol(message))
if message.contains("did not complete graceful shutdown")));
}
}
Ok(())
}
#[test] #[test]
fn startup_address_waits_for_complete_valid_socket_record() { fn startup_address_waits_for_complete_valid_socket_record() {
let record = "yoi-server listening on http://127.0.0.1:49123\n"; let record = "yoi-server listening on http://127.0.0.1:49123\n";
+284
View File
@@ -0,0 +1,284 @@
//! Live public Worker protocol assertions. No Submit/Resume/model request is sent.
use std::net::TcpStream;
use std::time::{Duration, Instant};
use protocol::{Event, SessionSnapshot, WorkerState};
use tungstenite::{Message, WebSocket, client::IntoClientRequest};
use crate::{HarnessError, Result, WorkerE2eEnvironment};
const WS_WAIT: Duration = Duration::from_secs(10);
pub struct WorkerSessionSubscription {
socket: WebSocket<TcpStream>,
}
impl WorkerSessionSubscription {
pub fn connect(
environment: &WorkerE2eEnvironment,
worker_id: &str,
expected: &SessionSnapshot,
) -> Result<Self> {
let authorized = environment.worker_protocol_request(worker_id)?;
let mut url = authorized.url().clone();
url.set_scheme("ws")
.map_err(|_| error("invalid protocol URL"))?;
let mut request = url
.as_str()
.into_client_request()
.map_err(|_| error("build protocol handshake"))?;
request.headers_mut().extend(authorized.headers().clone());
// Only this environment's loopback endpoint is permitted, never a proxy.
let address = url
.socket_addrs(|| None)
.map_err(|_| error("resolve protocol address"))?
.into_iter()
.find(|address| address.ip().is_loopback())
.ok_or_else(|| error("protocol address is not loopback"))?;
let stream = TcpStream::connect_timeout(&address, WS_WAIT)?;
stream.set_read_timeout(Some(WS_WAIT))?;
stream.set_write_timeout(Some(WS_WAIT))?;
let (socket, _) =
tungstenite::client(request, stream).map_err(|_| error("protocol handshake failed"))?;
let mut subscription = Self { socket };
let deadline = Instant::now() + WS_WAIT;
loop {
match subscription.read(deadline)? {
Message::Text(text) => {
let raw: serde_json::Value = serde_json::from_str(&text)?;
validate_full_snapshot(&raw, expected)?;
// Only validated public content is retained, never request headers.
std::fs::write(
environment.artifacts_dir.join("restored-session.json"),
serde_json::to_vec_pretty(&raw)?,
)?;
return Ok(subscription);
}
Message::Ping(_) => subscription
.socket
.flush()
.map_err(|_| error("flush protocol Pong"))?,
Message::Pong(_) => {}
_ => return Err(error("protocol ended before its initial full Snapshot")),
}
}
}
/// Prove an already-open subscription remains usable after the other
/// environment is stopped and cleaned up. This does not invoke the model.
pub fn assert_alive(&mut self) -> Result<()> {
let payload = b"restore-e2e-isolation".to_vec();
self.socket
.send(Message::Ping(payload.clone().into()))
.map_err(|_| error("send protocol Ping"))?;
let deadline = Instant::now() + WS_WAIT;
loop {
match self.read(deadline)? {
Message::Pong(bytes) if bytes.as_ref() == payload.as_slice() => return Ok(()),
Message::Ping(_) => self
.socket
.flush()
.map_err(|_| error("flush protocol Pong"))?,
Message::Pong(_) => {}
_ => {
return Err(error(
"idle subscription changed or ended during isolation check",
));
}
}
}
}
/// A public stop must terminate the connected execution, not merely update
/// a retained catalog row. Require the shutdown event or websocket close.
pub fn expect_stopped(mut self) -> Result<()> {
let deadline = Instant::now() + WS_WAIT;
loop {
match self.read(deadline)? {
Message::Close(_) => return Ok(()),
Message::Text(text) => match serde_json::from_str::<Event>(&text)? {
Event::Shutdown => return Ok(()),
Event::WorkerState { .. } => {}
_ => return Err(error("unexpected protocol event after Worker stop")),
},
Message::Ping(_) => self
.socket
.flush()
.map_err(|_| error("flush protocol Pong"))?,
Message::Pong(_) => {}
_ => return Err(error("unexpected protocol frame after Worker stop")),
}
}
}
fn read(&mut self, deadline: Instant) -> Result<Message> {
let remaining = deadline
.checked_duration_since(Instant::now())
.filter(|duration| !duration.is_zero())
.ok_or_else(|| error("protocol deadline exceeded"))?;
self.socket.get_mut().set_read_timeout(Some(remaining))?;
self.socket
.read()
.map_err(|_| error("protocol read failed or timed out"))
}
}
fn error(message: &str) -> HarnessError {
HarnessError::Protocol(message.into())
}
pub fn validate_full_snapshot(raw: &serde_json::Value, expected: &SessionSnapshot) -> Result<()> {
let event: Event = serde_json::from_value(raw.clone())?;
// Product DTOs intentionally tolerate unknown fields for compatibility. This
// fixture boundary does not: anything discarded by typed decoding must never
// be persisted to an artifact, including fields outside the Session payload.
if serde_json::to_value(&event)? != *raw {
return Err(error(
"noncanonical or unknown fields in full protocol Snapshot envelope",
));
}
let Event::Snapshot {
session,
state,
in_flight,
internal_workers,
..
} = event
else {
return Err(error("first protocol event is not a full Snapshot"));
};
if state.state != WorkerState::Idle || !in_flight.is_empty() || !internal_workers.is_empty() {
return Err(error(
"restored Worker is not an idle, completed standalone Session",
));
}
// Typed equality covers entry order/ID/timestamp/provenance/content; raw
// equality also refuses unknown fields that could leak private data.
if session != *expected
|| raw.pointer("/data/session") != Some(&serde_json::to_value(expected)?)
{
return Err(error(
"full Session snapshot differs from supplied expected rows",
));
}
if !session.pending_submissions.submissions.is_empty() {
return Err(error("fixture has pending submissions"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use protocol::{
Greeting, InFlightSnapshot, Segment, SessionEntryProvenance, SessionSnapshotEntry,
SessionSnapshotEntryData, WorkerStateSnapshot, WorkerStatus,
};
// Protocol-validation samples only, not a substitute saved Worker fixture.
fn sample() -> (serde_json::Value, SessionSnapshot) {
let session = SessionSnapshot {
pending_submissions: Default::default(),
entries: vec![SessionSnapshotEntry {
entry_id: "E-unit-1".into(),
timestamp: 123,
provenance: SessionEntryProvenance::HumanInput,
derived_from: vec![],
use_policy: None,
data: SessionSnapshotEntryData::UserInput {
segments: vec![Segment::text("unit input")],
},
}],
};
let event = Event::Snapshot {
session: session.clone(),
state: WorkerStateSnapshot::from(WorkerStatus::Idle),
in_flight: InFlightSnapshot::default(),
internal_workers: vec![],
greeting: Greeting {
worker_name: "unit".into(),
cwd: ".".into(),
provider: "unit".into(),
model: "unit".into(),
reasoning: None,
scope_summary: "none".into(),
tools: vec![],
context_window: 0,
context_tokens: 0,
context_usage: None,
},
};
(serde_json::to_value(event).unwrap(), session)
}
#[test]
fn full_snapshot_requires_exact_ids_timestamps_contents_and_no_unknown_fields() {
let (raw, expected) = sample();
validate_full_snapshot(&raw, &expected).unwrap();
for (key, value) in [
("entry_id", serde_json::json!("different")),
("timestamp", serde_json::json!(124)),
(
"hidden_reasoning",
serde_json::json!("must never be exposed"),
),
("segments", serde_json::json!([])),
] {
let mut changed = raw.clone();
changed["data"]["session"]["entries"][0][key] = value;
assert!(
validate_full_snapshot(&changed, &expected).is_err(),
"{key}"
);
}
let mut missing = raw.clone();
missing["data"]["session"]["entries"] = serde_json::json!([]);
assert!(validate_full_snapshot(&missing, &expected).is_err());
let mut extra = raw;
let entry = extra["data"]["session"]["entries"][0].clone();
extra["data"]["session"]["entries"]
.as_array_mut()
.unwrap()
.push(entry);
assert!(validate_full_snapshot(&extra, &expected).is_err());
}
#[test]
fn full_snapshot_rejects_unknown_fields_outside_session_before_artifact_write() {
let (raw, expected) = sample();
for pointer in [
"",
"/data",
"/data/greeting",
"/data/state",
"/data/state/state",
] {
let mut changed = raw.clone();
changed
.pointer_mut(pointer)
.unwrap()
.as_object_mut()
.unwrap()
.insert(
"hidden_reasoning".into(),
serde_json::json!("must not reach artifact"),
);
assert!(
validate_full_snapshot(&changed, &expected).is_err(),
"{pointer}"
);
}
let mut changed = raw;
changed["data"]["greeting"]["private_key"] = serde_json::json!("must not reach artifact");
assert!(validate_full_snapshot(&changed, &expected).is_err());
}
#[test]
fn non_snapshot_event_cannot_stand_in_for_live_snapshot() {
let (_, expected) = sample();
assert!(
validate_full_snapshot(&serde_json::to_value(Event::Shutdown).unwrap(), &expected)
.is_err()
);
}
}
+64 -12
View File
@@ -4,8 +4,9 @@
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 //! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが //! 実Server CLIにはstatic assets設定経路がない。明示選択browser_sessionsは本番static shellを
//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。 //! loopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
//! native_migrationは同じshellと実BackendをChromium native WebAuthn/CTAP2へ接続する。
//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。 //! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。
use std::error::Error; use std::error::Error;
@@ -21,8 +22,8 @@ use serde::Deserialize;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::{Value, json}; use serde_json::{Value, json};
use server_api::{ use server_api::{
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest, ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyAdditionOptionsRequest,
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest, PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
PasskeyRegistrationOptionsResponse, WhoamiResponse, PasskeyRegistrationOptionsResponse, WhoamiResponse,
}; };
#[path = "../src/resident_authenticator.rs"] #[path = "../src/resident_authenticator.rs"]
@@ -30,8 +31,12 @@ mod resident_model;
use resident_model::ResidentAuthenticator; use resident_model::ResidentAuthenticator;
use yoi_e2e::WorkerE2eEnvironment; use yoi_e2e::WorkerE2eEnvironment;
#[path = "auth_passkeys/browser_process.rs"]
mod browser_process;
#[path = "auth_passkeys/browser_sessions.rs"] #[path = "auth_passkeys/browser_sessions.rs"]
mod browser_sessions; mod browser_sessions;
#[path = "auth_passkeys/native_migration.rs"]
mod native_migration;
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>; type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
const BROWSER_ORIGIN: &str = "http://localhost:5173"; const BROWSER_ORIGIN: &str = "http://localhost:5173";
@@ -270,6 +275,29 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result
Ok(()) Ok(())
} }
// fixtureも現在の本人/認証bindingをwhoamiから取得する。期待値は対象を選ぶ権限ではない。
fn addition_expectation(
request: RequestBuilder,
artifacts: &Path,
) -> Result<PasskeyAdditionOptionsRequest> {
let identity: WhoamiResponse = decode(send(
request,
"read addition owner and authentication reference",
artifacts,
)?)?;
let actor = identity
.actor
.ok_or("addition fixture requires an authenticated actor")?;
Ok(PasskeyAdditionOptionsRequest {
expected_user_id: actor.user_id,
expected_account_id: actor.account_id,
expected_authentication_reference: identity
.authentication_reference
.ok_or("missing addition authentication reference")?,
browser_origin: Some(BROWSER_ORIGIN.to_owned()),
})
}
fn host_admin_login( fn host_admin_login(
environment: &WorkerE2eEnvironment, environment: &WorkerE2eEnvironment,
client: &Client, client: &Client,
@@ -291,11 +319,17 @@ fn host_admin_login(
let bearer = setup["access_token"] let bearer = setup["access_token"]
.as_str() .as_str()
.ok_or("host setup omitted initial bearer")?; .ok_or("host setup omitted initial bearer")?;
let addition_body = addition_expectation(
client
.get(format!("{base}/api/auth/whoami"))
.bearer_auth(bearer),
artifacts,
)?;
let options: PasskeyRegistrationOptionsResponse = decode(send( let options: PasskeyRegistrationOptionsResponse = decode(send(
client client
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.bearer_auth(bearer) .bearer_auth(bearer)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&addition_body),
"host-created administrator bearer starts actor-derived passkey addition", "host-created administrator bearer starts actor-derived passkey addition",
artifacts, artifacts,
)?)?; )?)?;
@@ -348,6 +382,12 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
policy(&client, base, artifacts, false)?; policy(&client, base, artifacts, false)?;
let mut admin_key = ResidentAuthenticator::new(true); let mut admin_key = ResidentAuthenticator::new(true);
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
let admin_addition = addition_expectation(
client
.get(format!("{base}/api/auth/whoami"))
.header(COOKIE, &admin_cookie),
artifacts,
)?;
policy(&client, base, artifacts, false)?; policy(&client, base, artifacts, false)?;
set_policy(&client, base, artifacts, &admin_cookie, true)?; set_policy(&client, base, artifacts, &admin_cookie, true)?;
let whoami: Value = decode(send( let whoami: Value = decode(send(
@@ -379,7 +419,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
client client
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(ORIGIN, BROWSER_ORIGIN) .header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&admin_addition),
"unauthenticated addition options", "unauthenticated addition options",
artifacts, artifacts,
)?, )?,
@@ -445,12 +485,18 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
)?)?; )?)?;
assert!(options.public_key.public_key.allow_credentials.is_empty()); assert!(options.public_key.public_key.allow_credentials.is_empty());
let addition_body = addition_expectation(
client
.get(format!("{base}/api/auth/whoami"))
.header(COOKIE, &cookie),
artifacts,
)?;
for (field, value) in [ for (field, value) in [
("handle", &other.user.handle), ("handle", &other.user.handle),
("user_id", &other.user.user_id), ("user_id", &other.user.user_id),
("account_id", &other.user.account_id), ("account_id", &other.user.account_id),
] { ] {
let mut body = json!({"browser_origin": BROWSER_ORIGIN}); let mut body = serde_json::to_value(&addition_body)?;
body[field] = json!(value); body[field] = json!(value);
rejected( rejected(
send( send(
@@ -470,7 +516,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
let mut request = client let mut request = client
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, &cookie) .header(COOKIE, &cookie)
.json(&json!({"browser_origin": BROWSER_ORIGIN})); .json(&addition_body);
if let Some(origin) = origin { if let Some(origin) = origin {
request = request.header(ORIGIN, origin); request = request.header(ORIGIN, origin);
} }
@@ -490,7 +536,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, &cookie) .header(COOKIE, &cookie)
.header(ORIGIN, BROWSER_ORIGIN) .header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&addition_body),
"authenticated self addition options", "authenticated self addition options",
artifacts, artifacts,
)?)?; )?)?;
@@ -591,7 +637,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, revoked_cookie) .header(COOKIE, revoked_cookie)
.header(ORIGIN, BROWSER_ORIGIN) .header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&addition_body),
"logout prevents further self addition with the old cookie", "logout prevents further self addition with the old cookie",
artifacts, artifacts,
)?, )?,
@@ -1797,7 +1843,8 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
client.post(format!("{base}/api/auth/device-login/approve")).header(ORIGIN, BROWSER_ORIGIN) client.post(format!("{base}/api/auth/device-login/approve")).header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"user_code": flow["user_code"]})), .json(&json!({"user_code": flow["user_code"]})),
client.post(format!("{base}/api/auth/passkeys/addition/options")).header(ORIGIN, BROWSER_ORIGIN) client.post(format!("{base}/api/auth/passkeys/addition/options")).header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&json!({"browser_origin": BROWSER_ORIGIN, "expected_user_id": issue.user.user_id,
"expected_account_id": issue.user.account_id, "expected_authentication_reference": "unauthenticated-fixture-not-a-credential"})),
]; ];
for mut request in requests { for mut request in requests {
if let Some(bearer) = bearer { if let Some(bearer) = bearer {
@@ -1901,7 +1948,12 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, &cookie) .header(COOKIE, &cookie)
.header(ORIGIN, BROWSER_ORIGIN) .header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&addition_expectation(
client
.get(format!("{base}/api/auth/whoami"))
.header(COOKIE, &cookie),
artifacts,
)?),
"start distinct authenticated self-addition ceremony", "start distinct authenticated self-addition ceremony",
artifacts, artifacts,
)?)?; )?)?;
@@ -0,0 +1,58 @@
//! browser suiteの子process回収と実行期限を共有する。診断にraw IPCを複製しない。
use super::*;
use std::process::{Child, ExitStatus};
use std::sync::mpsc;
pub(super) struct BrowserProcess(pub(super) Option<Child>);
impl BrowserProcess {
pub(super) fn child(&mut self) -> &mut Child {
self.0.as_mut().expect("browser process retained")
}
pub(super) fn wait_bounded(mut self, timeout: Duration) -> Result<ExitStatus> {
let mut child = self.0.take().expect("browser process retained");
let pid = child.id() as libc::pid_t;
let (send, receive) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _ = send.send(child.wait());
});
let result = receive.recv_timeout(timeout);
if result.is_err() {
unsafe {
libc::kill(pid, libc::SIGTERM);
}
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
}
let _ = waiter.join();
Ok(result.map_err(|_| "browser E2E timed out; see sanitized browser diagnostic")??)
}
}
impl Drop for BrowserProcess {
fn drop(&mut self) {
let Some(mut child) = self.0.take() else {
return;
};
if matches!(child.try_wait(), Ok(Some(_))) {
return;
}
let pid = child.id() as libc::pid_t;
// Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。
unsafe {
libc::kill(pid, libc::SIGTERM);
}
let (send, receive) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _ = send.send(child.wait());
});
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
let _ = waiter.join();
}
}
@@ -8,10 +8,11 @@
//! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite: //! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite:
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored` //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored`
//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。 //! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。
use super::browser_process::BrowserProcess;
use super::*; use super::*;
use std::io::{BufRead, BufReader, Read}; use std::io::{BufRead, BufReader, Read};
use std::net::TcpListener; use std::net::TcpListener;
use std::process::{Child, Command, Stdio}; use std::process::Stdio;
use std::sync::mpsc; use std::sync::mpsc;
use std::time::Instant; use std::time::Instant;
use yoi_workspace_server::auth::{ use yoi_workspace_server::auth::{
@@ -62,38 +63,6 @@ fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result<Value>
]})) ]}))
} }
struct BrowserProcess(Option<Child>);
impl BrowserProcess {
fn child(&mut self) -> &mut Child {
self.0.as_mut().expect("browser process retained")
}
}
impl Drop for BrowserProcess {
fn drop(&mut self) {
let Some(mut child) = self.0.take() else {
return;
};
if matches!(child.try_wait(), Ok(Some(_))) {
return;
}
let pid = child.id() as libc::pid_t;
// Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。
unsafe {
libc::kill(pid, libc::SIGTERM);
}
let (send, receive) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _ = send.send(child.wait());
});
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
let _ = waiter.join();
}
}
#[test] #[test]
#[ignore = "requires production Web assets, Deno and Playwright Chromium"] #[ignore = "requires production Web assets, Deno and Playwright Chromium"]
fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result
@@ -132,7 +101,8 @@ fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit
drop(reservation); drop(reservation);
let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?; let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?;
let mut process = BrowserProcess(Some( let mut process = BrowserProcess(Some(
Command::new("deno") environment
.browser_command(Path::new("deno"))?
.current_dir(&root) .current_dir(&root)
.args([ .args([
"run", "run",
@@ -0,0 +1,110 @@
//! 実Backend・本番SPA・Chromium native WebAuthn + CTAP2による旧鍵移行。
//! random user handle × resident=false/true × 同じ/別の認証器を検査する。
//! 旧鍵fixtureのuser.id/residency指定だけを変更し、実attestationを本番verifier/storeへ送る。
//! 現行登録を旧挙動と偽らず、SQL直書き・認証API/mock navigator置換はしない。
//! 起動/DB/HOME/XDG/port/回収はWorkerE2eEnvironment、browser profileはその一時領域が所有する。
//! DenoとPlaywright Chromium、最新production assets/binariesを呼出側が用意する。
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys native_ctap2 -- --ignored`
//! 暗黙buildを避ける実行時は既存providerの3つのbinary overrideを全て指定する。
//! DENO_DIR/PLAYWRIGHT_BROWSERS_PATHは事前取得済みtoolの供給元として明示する。
//! 物理鍵/実スマホ/provider選択は未確認。CI自動配線の代用にはしない。
use super::browser_process::BrowserProcess;
use super::*;
use std::net::TcpListener;
use std::process::Stdio;
#[test]
#[ignore = "requires production assets/binaries, cached Deno dependencies and Playwright Chromium"]
fn native_ctap2_legacy_random_handle_residency_exclusion_and_other_authenticator_migration()
-> Result {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()?;
let build = root.join("web/workspace/build");
if !build.join("index.html").is_file() {
return Err("native browser E2E requires an existing production Web build".into());
}
let mut environment = WorkerE2eEnvironment::new()?;
let reservation = TcpListener::bind("127.0.0.1:0")?;
let origin = format!("http://localhost:{}", reservation.local_addr()?.port());
std::fs::write(
&environment.server_config,
format!("[browser]\npublic_url = \"{origin}\"\n"),
)?;
environment.start_empty_backend()?;
let admin =
environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?;
let bearer = admin["access_token"]
.as_str()
.ok_or("missing setup token")?;
let base = environment
.server_url
.as_deref()
.ok_or("missing Backend URL")?;
let response = http_client()?
.put(format!("{base}/api/hosting/settings"))
.bearer_auth(bearer)
.json(&json!({"self_registration_enabled": true}))
.send()?;
if !response.status().is_success() {
return Err("native fixture could not enable synthetic self-registration".into());
}
// Server public_urlと同一originを使う。API proxyだけでOrigin/Cookieを書き換えない。
drop(reservation);
let stderr =
std::fs::File::create(environment.artifacts_dir.join("native-browser.stderr.log"))?;
let mut command = environment.browser_command(Path::new("deno"))?;
command
.current_dir(&root)
.args([
"run",
"--config",
"tools/web-ux/deno.json",
"--no-lock",
"--cached-only",
"--allow-env",
"--allow-net=127.0.0.1,localhost",
"--allow-read",
"--allow-write",
"--allow-run",
"--allow-sys",
"tests/e2e/browser/native_passkey_migration.ts",
base,
&origin,
build.to_str().ok_or("non-UTF8 build path")?,
&environment.account_handle,
environment
.artifacts_dir
.to_str()
.ok_or("non-UTF8 artifacts path")?,
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(stderr);
let process = BrowserProcess(Some(command.spawn()?));
let status = process.wait_bounded(Duration::from_secs(180))?;
if !status.success() {
return Err(format!(
"native CTAP2 matrix failed; sanitized diagnostics: {}",
environment
.artifacts_dir
.join("native-browser.stderr.log")
.display()
)
.into());
}
let report: Value = serde_json::from_slice(&std::fs::read(
environment
.artifacts_dir
.join("native-migration-result.json"),
)?)?;
if report["status"] != "passed" || report["rows"].as_array().map(Vec::len) != Some(4) {
return Err("native browser did not confirm all four matrix rows".into());
}
let cleanup = environment.cleanup()?;
assert!(
cleanup.cleanup_success,
"owned native browser environment cleanup failed"
);
Ok(())
}
+5 -5
View File
@@ -41,13 +41,13 @@ fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_
&event.data, &event.data,
&ready_ticket_id, &ready_ticket_id,
"Planning E2E Ticket A", "Planning E2E Ticket A",
"planning", "open",
) )
&& has_backend_ticket( && has_backend_ticket(
&event.data, &event.data,
&planning_ticket_id, &planning_ticket_id,
"Planning E2E Ticket B", "Planning E2E Ticket B",
"planning", "open",
) )
}, },
)?; )?;
@@ -82,13 +82,13 @@ fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_
&event.data, &event.data,
&ready_ticket_id, &ready_ticket_id,
"Planning E2E Ticket A", "Planning E2E Ticket A",
"planning", "open",
) )
&& has_backend_ticket( && has_backend_ticket(
&event.data, &event.data,
&planning_ticket_id, &planning_ticket_id,
"Planning E2E Ticket B", "Planning E2E Ticket B",
"planning", "open",
) )
}, },
)?; )?;
@@ -137,7 +137,7 @@ fn backend_panel_shell_enter_path_uses_the_same_backend_authority() -> yoi_e2e::
&event.data, &event.data,
&ready_ticket_id, &ready_ticket_id,
"Planning E2E Ticket A", "Planning E2E Ticket A",
"planning", "open",
) )
}, },
)?; )?;
+274
View File
@@ -0,0 +1,274 @@
//! Fixed, externally supplied Worker restore. Never generates a fixture or sends model input.
#[path = "worker_restore/fixture.rs"]
mod fixture;
#[path = "worker_restore/seed.rs"]
mod seed;
use std::fs;
use std::time::{Duration, Instant};
use fixture::{Fixture, failure};
use seed::Installed;
use server_api::{
RuntimeWorkerLifecycleResult, RuntimeWorkerShowResponse, WorkerListResponse,
WorkerOperationState, WorkerRestoreResponse, WorkerRestoreState, WorkerSummary,
};
use yoi_e2e::{FixtureSource, Result, WorkerE2eEnvironment, WorkerSessionSubscription};
#[test]
fn supplied_stopped_worker_restores_exact_live_session_and_cleans_up() -> Result<()> {
let fixture = Fixture::load()?;
let result = (|| {
let installed = seed::start(&fixture)?;
let subscription = restore(&installed, &fixture, "fixed-fixture-restore-single")?;
stop_and_cleanup(installed, vec![subscription], &fixture)
})();
fixture.source.assert_unchanged()?;
result
}
#[test]
fn concurrent_fixed_id_restores_keep_sessions_storage_and_cleanup_isolated() -> Result<()> {
let fixture = Fixture::load()?;
let result = (|| {
let (first, second) = std::thread::scope(|scope| {
let first = scope.spawn(|| seed::start(&fixture));
let second = seed::start(&fixture);
(
first
.join()
.map_err(|_| failure("first environment startup panicked"))
.and_then(|result| result),
second,
)
});
let first = first?;
let second = second?;
assert_eq!(
first.environment.workspace_id,
second.environment.workspace_id
);
assert_eq!(first.environment.runtime_id, second.environment.runtime_id);
assert_ne!(first.environment.root, second.environment.root);
assert_ne!(
first.environment.server_data_dir,
second.environment.server_data_dir
);
assert_ne!(
first.environment.runtime_data_dir,
second.environment.runtime_data_dir
);
assert_ne!(
first.environment.artifacts_dir,
second.environment.artifacts_dir
);
assert_ne!(
first.environment.account_handle,
second.environment.account_handle
);
assert_ne!(first.runtime_public_key, second.runtime_public_key);
assert_ne!(first.workspace_public_key, second.workspace_public_key);
assert_ne!(
first.environment.server_port()?,
second.environment.server_port()?
);
assert_ne!(
first.environment.runtime_port()?,
second.environment.runtime_port()?
);
assert_ne!(
first.environment.server_pid(),
second.environment.server_pid()
);
assert_ne!(
first.environment.runtime_pid(),
second.environment.runtime_pid()
);
first
.environment
.assert_authentication_isolated_from(&second.environment)?;
second
.environment
.assert_authentication_isolated_from(&first.environment)?;
let (first_subscription, second_subscription) = std::thread::scope(|scope| {
// Deliberately reuse request identity in isolated stores, just like fixed IDs.
let a = scope.spawn(|| restore(&first, &fixture, "fixed-fixture-restore-concurrent"));
let b = restore(&second, &fixture, "fixed-fixture-restore-concurrent");
(
a.join()
.map_err(|_| failure("first restore panicked"))
.and_then(|result| result),
b,
)
});
let first_subscription = first_subscription?;
let mut second_subscription = second_subscription?;
let second_session_root = second
.environment
.runtime_data_dir
.join("runtime/workers")
.join(&fixture.manifest.worker_id)
.join("session");
let second_session = FixtureSource::capture(&second_session_root)?;
let first_root = first.environment.root.clone();
let first_artifacts = first.environment.artifacts_dir.clone();
stop_and_cleanup(first, vec![first_subscription], &fixture)?;
assert!(!first_root.exists());
assert!(first_artifacts.join("cleanup.json").is_file());
assert!(second.environment.root.is_dir());
assert!(second.environment.server_data_dir.is_dir());
assert!(second.environment.runtime_data_dir.is_dir());
assert!(
second
.environment
.artifacts_dir
.join("restored-session.json")
.is_file()
);
second.environment.assert_connected()?;
assert_single_state(&second.environment, &fixture, "idle")?;
second_subscription.assert_alive()?;
// A second live connection independently verifies content after peer cleanup.
let mut reconnected = WorkerSessionSubscription::connect(
&second.environment,
&fixture.manifest.worker_id,
&second.snapshot,
)?;
reconnected.assert_alive()?;
second_session.assert_unchanged()?;
stop_and_cleanup(second, vec![second_subscription, reconnected], &fixture)?;
Ok(())
})();
fixture.source.assert_unchanged()?;
result
}
fn restore(
installed: &Installed,
fixture: &Fixture,
request_id: &str,
) -> Result<WorkerSessionSubscription> {
phase(&installed.environment, "observe-stopped")?;
let stopped = assert_single_state(&installed.environment, fixture, "stopped")?;
let token = stopped
.restore_observation_token
.as_deref()
.filter(|token| !token.is_empty())
.ok_or_else(|| failure("stopped Worker did not expose a restore observation token"))?;
phase(&installed.environment, "restore")?;
let result: WorkerRestoreResponse = serde_json::from_value(
installed
.environment
.restore_worker(&fixture.manifest.worker_id, request_id, token)?,
)?;
if result.workspace_id != fixture.manifest.workspace_id
|| result.runtime_id != fixture.manifest.runtime_id
|| result.worker_id != fixture.manifest.worker_id
|| result.result.state != WorkerRestoreState::Accepted
{
return Err(failure(format!(
"restore did not commit accepted: {:?}",
result.result.state
)));
}
assert_single_state(&installed.environment, fixture, "idle")?;
let detail: RuntimeWorkerShowResponse = serde_json::from_value(
installed
.environment
.worker_detail(&fixture.manifest.worker_id)?,
)?;
if detail.worker.worker_id != fixture.manifest.worker_id
|| detail.worker.runtime_id != fixture.manifest.runtime_id
|| detail.worker.state != "idle"
|| detail
.worker
.worker_state
.as_ref()
.is_none_or(|state| state.state != protocol::WorkerState::Idle)
{
return Err(failure(
"restored Worker detail is not the single live idle execution",
));
}
phase(&installed.environment, "subscribe-live-session")?;
let mut subscription = WorkerSessionSubscription::connect(
&installed.environment,
&fixture.manifest.worker_id,
&installed.snapshot,
)?;
subscription.assert_alive()?;
phase(&installed.environment, "live-session-verified")?;
Ok(subscription)
}
fn assert_single_state(
environment: &WorkerE2eEnvironment,
fixture: &Fixture,
state: &str,
) -> Result<WorkerSummary> {
let deadline = Instant::now() + Duration::from_secs(15);
loop {
let list: WorkerListResponse = serde_json::from_value(environment.list_workers()?)?;
if list.workspace_id != fixture.manifest.workspace_id || list.items.len() > 1 {
return Err(failure(
"Workspace Worker list has wrong identity or duplicate Workers",
));
}
if let Some(worker) = list.items.into_iter().next() {
if worker.worker_id != fixture.manifest.worker_id
|| worker.runtime_id != fixture.manifest.runtime_id
{
return Err(failure("Workspace listed an unexpected Worker identity"));
}
if worker.state == state {
return Ok(worker);
}
}
if Instant::now() >= deadline {
return Err(failure(format!(
"Worker list did not become exactly one {state} Worker"
)));
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn stop_and_cleanup(
installed: Installed,
subscriptions: Vec<WorkerSessionSubscription>,
fixture: &Fixture,
) -> Result<()> {
phase(&installed.environment, "stop")?;
let result: RuntimeWorkerLifecycleResult = serde_json::from_value(
installed
.environment
.stop_worker(&fixture.manifest.worker_id)?,
)?;
if result.state != WorkerOperationState::Accepted {
return Err(failure("Worker stop was not accepted"));
}
installed.environment.assert_connected()?;
for subscription in subscriptions {
subscription.expect_stopped()?;
// A transport close caused by Server/Runtime shutdown is not stop evidence.
installed.environment.assert_connected()?;
}
assert_single_state(&installed.environment, fixture, "stopped")?;
phase(&installed.environment, "shutdown-cleanup")?;
let report = installed.environment.cleanup()?;
if !report.cleanup_success || report.fixture_root_exists_after {
return Err(failure("environment cleanup failed"));
}
if !report.report_path.is_file() {
return Err(failure("cleanup evidence is missing"));
}
Ok(())
}
fn phase(environment: &WorkerE2eEnvironment, phase: &str) -> Result<()> {
fs::write(
environment.artifacts_dir.join("restore-phase.json"),
serde_json::to_vec_pretty(&serde_json::json!({"phase":phase}))?,
)?;
Ok(())
}
+269
View File
@@ -0,0 +1,269 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use protocol::{SessionMessageRole, SessionSnapshot, SessionSnapshotEntryData};
use serde::Deserialize;
use serde_json::{Value, json};
use worker_runtime::test_support::StoppedWorkerTemplate;
use yoi_e2e::{FixtureSource, HarnessError, Result};
pub const PROFILE: &str = "project:fixture-chat";
const ASSETS: [&str; 3] = [
"expected.json",
"worker-template.json",
"workspace-config.dcdl",
];
const MAX_ASSET_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub format_version: u32,
pub source_revision: String,
pub runtime_schema_version: u32,
pub session_schema_version: u32,
pub workspace_id: String,
pub runtime_id: String,
pub worker_id: String,
pub session_id: String,
pub segment_id: String,
pub model: String,
pub state: String,
pub acquisition: String,
pub source_file_sha256: BTreeMap<String, String>,
pub files: BTreeMap<String, Asset>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Asset {
pub bytes: u64,
pub sha256: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Expected {
source_revision: String,
model: String,
worker_id: String,
session_id: String,
rows: Vec<Value>,
}
pub struct Fixture {
pub root: PathBuf,
pub source: FixtureSource,
pub manifest: Manifest,
expected: Expected,
}
pub fn failure(message: impl Into<String>) -> HarnessError {
HarnessError::Protocol(message.into())
}
pub fn support<T, E: std::fmt::Display>(result: std::result::Result<T, E>) -> Result<T> {
result.map_err(|error| failure(error.to_string()))
}
impl Fixture {
pub fn load() -> Result<Self> {
// Candidate override is explicit and read-only. Missing assets are failures,
// never skips, and no test generates or repairs a fixture.
let root = std::env::var_os("YOI_E2E_RESTORE_FIXTURE")
.map(PathBuf::from)
.map(|path| {
if path.is_absolute() {
path
} else {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join(path)
}
})
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/worker-codex-v1")
});
let manifest: Manifest =
serde_json::from_slice(&bounded_read(&root.join("manifest.json"))?)?;
if manifest.format_version != 1
|| manifest.state != "stopped"
|| manifest.runtime_schema_version == 0
|| manifest.session_schema_version == 0
|| manifest.acquisition.is_empty()
|| manifest.source_file_sha256.is_empty()
|| manifest.source_revision.len() != 40
|| !manifest
.source_revision
.bytes()
.all(|c| c.is_ascii_hexdigit())
{
return Err(failure("unsupported/incomplete fixture manifest"));
}
let names: BTreeSet<_> = manifest.files.keys().map(String::as_str).collect();
if names != BTreeSet::from(ASSETS) {
return Err(failure("unexpected fixture asset set"));
}
for name in ASSETS {
bounded_read(&root.join(name))?;
}
let source = FixtureSource::capture(&root)?;
if source.hashes().len() != 4 {
return Err(failure("fixture has unmanifested files"));
}
for (name, asset) in &manifest.files {
if asset.bytes != fs::metadata(root.join(name))?.len()
|| source.hashes().get(Path::new(name)) != Some(&format!("sha256:{}", asset.sha256))
{
return Err(failure(format!("fixture asset hash/size mismatch: {name}")));
}
}
let expected: Expected =
serde_json::from_slice(&bounded_read(&root.join("expected.json"))?)?;
if expected.source_revision != manifest.source_revision
|| expected.model != manifest.model
|| expected.worker_id != manifest.worker_id
|| expected.session_id != manifest.session_id
|| expected.rows.is_empty()
{
return Err(failure(
"expected conversation identity differs from manifest",
));
}
verify_builtin_revision(&manifest.source_revision)?;
let fixture = Self {
root,
source,
manifest,
expected,
};
fixture.template(&fixture.root)?;
Ok(fixture)
}
pub fn template(&self, copy: &Path) -> Result<StoppedWorkerTemplate> {
let bytes = bounded_read(&copy.join("worker-template.json"))?;
let template: StoppedWorkerTemplate = serde_json::from_slice(&bytes)?;
let shape: Value = serde_json::from_slice(&bytes)?;
let session_manifest_bytes: Vec<u8> =
serde_json::from_value(shape["session_manifest"].clone())?;
let segment_bytes: Vec<u8> = serde_json::from_value(shape["segment_jsonl"].clone())?;
for (name, bytes) in [
(
"session/session.json".to_owned(),
session_manifest_bytes.as_slice(),
),
(
format!("session/segments/{}.jsonl", self.manifest.segment_id),
segment_bytes.as_slice(),
),
] {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
if self.manifest.source_file_sha256.get(&name) != Some(&digest) {
return Err(failure(
"exported Session bytes differ from acquisition hash evidence",
));
}
}
let session_manifest: Value = serde_json::from_slice(&session_manifest_bytes)?;
let m = &self.manifest;
if template.workspace_id() != m.workspace_id
|| template.runtime_id() != m.runtime_id
|| template.worker_id().to_string() != m.worker_id
|| template.session_id().to_string() != m.session_id
|| template.segment_id().to_string() != m.segment_id
|| shape["runtime_schema_version"] != m.runtime_schema_version
|| session_manifest["schema_version"] != m.session_schema_version
{
return Err(failure("template schema/identity differs from manifest"));
}
Ok(template)
}
pub fn verify_conversation(&self, snapshot: &SessionSnapshot) -> Result<()> {
let mut rows = Vec::new();
for entry in &snapshot.entries {
let (role, parts) = match &entry.data {
SessionSnapshotEntryData::UserInput { segments } => {
("user", serde_json::to_value(segments)?)
}
SessionSnapshotEntryData::Message {
role: SessionMessageRole::Assistant,
content,
} => ("assistant", serde_json::to_value(content)?),
// Display-only accounting is not a conversation row. Its exact
// IDs, timestamp and values are still checked in the full WS snapshot.
SessionSnapshotEntryData::RunStats { .. } => continue,
_ => {
return Err(failure(
"unexpected non-conversation entry in full Session snapshot",
));
}
};
rows.push(json!({"entry_id":entry.entry_id,"timestamp":entry.timestamp,"role":role,"parts":parts}));
}
if rows != self.expected.rows {
return Err(failure(
"full public conversation rows differ from expected IDs/time/roles/text/order",
));
}
Ok(())
}
}
fn bounded_read(path: &Path) -> Result<Vec<u8>> {
let metadata = fs::symlink_metadata(path).map_err(|error| {
failure(format!(
"required fixture asset {}: {error}",
path.display()
))
})?;
if !metadata.is_file() || metadata.len() > MAX_ASSET_BYTES {
return Err(failure("fixture asset is not a bounded regular file"));
}
Ok(fs::read(path)?)
}
fn verify_builtin_revision(revision: &str) -> Result<()> {
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap();
// Resolve the immutable commit before diff; bad revisions must not be treated
// as an empty diff. All resources are checked, including transitive imports.
let commit = Command::new("git")
.args(["cat-file", "-t", revision])
.current_dir(workspace)
.output()?;
if !commit.status.success() || commit.stdout != b"commit\n" {
return Err(failure(
"fixture source revision is unavailable for builtin verification",
));
}
let status = Command::new("git")
.args([
"diff",
"--quiet",
revision,
"--",
"resources",
"crates/builtin-source",
])
.current_dir(workspace)
.status()?;
if !status.success() {
return Err(failure(
"builtin/config resources differ from fixture source revision; provide a new version or frozen references",
));
}
Ok(())
}
+482
View File
@@ -0,0 +1,482 @@
use std::fs;
use std::net::TcpListener;
use std::path::Path;
use std::sync::Arc;
use config_source::{ConfigTreeChange, VirtualPath};
use protocol::SessionSnapshot;
use serde_json::Value;
use worker_runtime::FsRuntimeStoreOptions;
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource,
WorkspaceApiRef,
};
use yoi_e2e::{Result, WorkerE2eEnvironment};
use yoi_workspace_server::auth::{mint_secret, new_id, now_rfc3339, token_hash};
use yoi_workspace_server::config_source::{ConfigCommitRequest, WorkspaceConfigSchemaRegistry};
use yoi_workspace_server::hosts::{
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
};
use yoi_workspace_server::profile_settings::{
ProfileConfigSchemaProvider, build_virtual_profile_config_bundle,
project_profiles_from_workspace_config,
};
use yoi_workspace_server::store::{
AccountRecord, ApiTokenRecord, UserRecord, WorkspaceBootstrapRecord,
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
};
use yoi_workspace_server::test_support::{FixedWorkerSeed, seed_fixed_worker};
use yoi_workspace_server::workspace_signing_identity::{
FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
workspace_signing_material_root,
};
use yoi_workspace_server::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
use super::fixture::{Fixture, PROFILE, failure, support};
pub struct Installed {
pub environment: WorkerE2eEnvironment,
pub snapshot: SessionSnapshot,
pub runtime_public_key: String,
pub workspace_public_key: String,
}
pub fn start(fixture: &Fixture) -> Result<Installed> {
let mut environment = WorkerE2eEnvironment::new()?;
super::phase(&environment, "fixture-copy-verify")?;
let copy = environment.root.join("provided-fixture");
fixture.source.copy_to(&copy)?;
fs::write(
environment.artifacts_dir.join("fixture-manifest.json"),
fs::read(copy.join("manifest.json"))?,
)?;
let template = fixture.template(&copy)?;
let runtime_identity =
environment.initialize_runtime_identity(fixture.manifest.runtime_id.clone())?;
let runtime_public_key = runtime_identity["public_key"]
.as_str()
.ok_or_else(|| failure("Runtime public bundle omitted key"))?
.to_owned();
// Reserve an environment-local placeholder endpoint only while the Runtime is
// offline. Its real :0 endpoint replaces this binding through public API CAS.
let reserved_endpoint = TcpListener::bind("127.0.0.1:0")?;
super::phase(
&environment,
"offline-backend-bootstrap-profile-worker-seed",
)?;
let bootstrap = bootstrap(
&environment,
fixture,
&copy,
&runtime_identity,
&format!("http://{}", reserved_endpoint.local_addr()?),
&template.worker_name(),
)?;
// Close the placeholder before Backend probes it: an accepting TCP listener
// with no HTTP service would stall discovery until its transport timeout.
drop(reserved_endpoint);
super::phase(&environment, "start-seeded-backend")?;
environment.start_seeded_backend(
fixture.manifest.workspace_id.clone(),
fixture.manifest.runtime_id.clone(),
bootstrap.token,
)?;
let mut snapshot = None;
super::phase(&environment, "initialize-runtime-trust")?;
environment.start_runtime_with_seed(|environment| {
super::phase(environment, "install-runtime-fixture-and-validate-session")?;
let options = FsRuntimeStoreOptions::new(environment.runtime_data_dir.join("runtime"))
.with_runtime_id(&fixture.manifest.runtime_id);
let request = CreateWorkerRequest {
worker_id: template.worker_id(),
create_fingerprint: bootstrap.create_fingerprint.clone(),
profile: ProfileSelector::Named(PROFILE.into()),
display_name: Some("Restore E2E fixture".into()),
profile_source: ProfileSourceArchiveSource::WorkspaceConfig {
archive: bootstrap.archive.clone(),
},
config_bundle: Some(bootstrap.config_bundle.clone()),
workspace_api: Some(WorkspaceApiRef {
workspace_id: fixture.manifest.workspace_id.clone(),
base_url: environment.backend_target()?.0.into(),
}),
initial_input: None,
workdir_attachment_requests: vec![],
workdir_attachments: vec![],
worker_observation_enabled: false,
worker_observation_grants: vec![],
memory_settings: Some(bootstrap.memory_settings.clone()),
subjektiv_attached: false,
backend_job: None,
};
support(template.install(&options, request, &bootstrap.manifest))?;
let sessions = support(session_store::WorkerSessionStore::open_read_only(
options
.root
.join("workers")
.join(template.worker_id().to_string())
.join("session"),
))?;
let (logs, _) = support(sessions.read_all_read_only_bounded(
template.session_id(),
template.segment_id(),
8 * 1024 * 1024,
))?;
let projected = session_store::project_session_snapshot(template.session_id(), &logs);
fixture.verify_conversation(&projected)?;
snapshot = Some(full_restored_snapshot(&sessions, &logs));
Ok(())
})?;
fixture.source.assert_unchanged()?;
Ok(Installed {
environment,
snapshot: snapshot.ok_or_else(|| failure("Runtime seed was not invoked"))?,
runtime_public_key,
workspace_public_key: bootstrap.workspace_public_key,
})
}
fn full_restored_snapshot(
store: &dyn session_store::Store,
logs: &[session_store::LogEntry],
) -> SessionSnapshot {
session_store::public_snapshot::project_current_session_snapshot_with_accounting(store, logs)
}
#[cfg(test)]
mod tests {
use super::*;
use protocol::{
Event, Greeting, InFlightSnapshot, InvokeKind, SessionSnapshotEntryData,
WorkerStateSnapshot, WorkerStatus,
};
use session_store::{
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
};
#[test]
fn completed_run_full_snapshot_retains_invoke_and_usage_accounting() -> Result<()> {
// Projection regression only: no saved Worker or substitute E2E fixture.
let temp = tempfile::tempdir()?;
let store = support(session_store::WorkerSessionStore::new(temp.path()))?;
let session_id = session_store::new_session_id();
let logs = vec![
LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: Default::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
LogEntry::Invoke {
ts: 10,
trigger: InvokeKind::UserSend,
},
LogEntry::AnnotatedAssistantItem {
ts: 20,
entry: LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![LoggedContentPart::Text {
text: "unit response".into(),
}],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId("unit-response".into()),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
input_metadata: None,
},
},
},
LogEntry::LlmUsage {
ts: 30,
history_len: 1,
input_total_tokens: 100,
cache_read_tokens: 20,
cache_write_tokens: 0,
output_tokens: 5,
},
serde_json::from_value(serde_json::json!({"kind":"run_completed", "ts":40,
"interrupted":false,"result":"finished"}))?,
];
let conversation = session_store::project_session_snapshot(session_id, &logs);
let expected = full_restored_snapshot(&store, &logs);
assert_eq!(expected.entries.len(), conversation.entries.len() + 2);
assert!(matches!(
expected.entries[expected.entries.len() - 2].data,
SessionSnapshotEntryData::Invoke {
trigger: InvokeKind::UserSend
}
));
assert_eq!(expected.entries[expected.entries.len() - 2].timestamp, 10);
assert!(matches!(
expected.entries.last().unwrap().data,
SessionSnapshotEntryData::Usage {
input_tokens: 100,
cache_read_input_tokens: 20,
output_tokens: 5
}
));
assert_eq!(expected.entries.last().unwrap().timestamp, 30);
let live = session_store::public_snapshot::project_current_session_snapshot_with_accounting(
&store, &logs,
);
let event = Event::Snapshot {
session: live,
state: WorkerStateSnapshot::from(WorkerStatus::Idle),
in_flight: InFlightSnapshot::default(),
internal_workers: vec![],
greeting: Greeting {
worker_name: "unit".into(),
cwd: ".".into(),
provider: "unit".into(),
model: "unit".into(),
reasoning: None,
scope_summary: "none".into(),
tools: vec![],
context_window: 0,
context_tokens: 0,
context_usage: None,
},
};
let raw = serde_json::to_value(event)?;
yoi_e2e::validate_full_snapshot(&raw, &expected)?;
assert!(yoi_e2e::validate_full_snapshot(&raw, &conversation).is_err());
Ok(())
}
}
struct Bootstrap {
token: String,
workspace_public_key: String,
create_fingerprint: String,
archive: worker_runtime::profile_archive::ProfileSourceArchiveRef,
config_bundle: ConfigBundleRef,
manifest: manifest::WorkerManifest,
memory_settings: manifest::WorkspaceMemorySettingsSnapshot,
}
fn bootstrap(
environment: &WorkerE2eEnvironment,
fixture: &Fixture,
copy: &Path,
runtime_identity: &Value,
endpoint: &str,
worker_name: &str,
) -> Result<Bootstrap> {
fs::create_dir_all(&environment.server_data_dir)?;
let database = environment.server_data_dir.join("server.db");
if database.exists() {
return Err(failure("Backend seed refuses an existing database"));
}
let store = support(SqliteWorkspaceStore::open(&database))?;
let now = now_rfc3339();
let account_id = new_id("acct-user");
let user_id = new_id("user");
support(store.upsert_account(&AccountRecord {
account_id: account_id.clone(),
kind: "user".into(),
handle: environment.account_handle.clone(),
display_name: "Restore E2E owner".into(),
created_at: now.clone(),
updated_at: now.clone(),
}))?;
support(store.upsert_user(&UserRecord {
user_id: user_id.clone(),
account_id: account_id.clone(),
handle: environment.account_handle.clone(),
display_name: "Restore E2E owner".into(),
created_at: now.clone(),
updated_at: now.clone(),
}))?;
let token = mint_secret("yoi_api");
support(store.create_api_token(&ApiTokenRecord {
token_id: new_id("api-token"),
token_hash: token_hash(&token),
user_id,
label: "restore-e2e".into(),
created_at: now.clone(),
expires_at: None,
revoked_at: None,
last_used_at: None,
}))?;
let identities = WorkspaceSigningIdentityService::new(
Arc::new(store.clone()),
Arc::new(FsWorkspaceSigningMaterialStore::new(
workspace_signing_material_root(&database),
)),
);
let (identity, operation) = support(identities.prepare_workspace_creation(
"restore-e2e-bootstrap",
"restore-e2e-bootstrap",
&fixture.manifest.workspace_id,
&account_id,
))?;
let workspace_public_key = identity.public_key.clone();
support(store.create_workspace_bootstrap(
&WorkspaceBootstrapRecord {
operation_key: "restore-e2e-bootstrap".into(),
request_fingerprint: "restore-e2e-bootstrap".into(),
workspace: WorkspaceRecord {
workspace_id: fixture.manifest.workspace_id.clone(),
owner_account_id: account_id,
display_name: "Restore E2E".into(),
state: "active".into(),
created_at: now.clone(),
updated_at: now.clone(),
},
repository: None,
},
&identity,
&operation,
))?;
support(
store.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: fixture.manifest.workspace_id.clone(),
runtime_id: fixture.manifest.runtime_id.clone(),
display_name: format!("E2E {}", fixture.manifest.runtime_id),
base_url: endpoint.into(),
public_key: runtime_identity["public_key"]
.as_str()
.ok_or_else(|| failure("missing Runtime public key"))?
.into(),
public_key_fingerprint: String::new(),
binding_id: String::new(),
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
created_at: now.clone(),
updated_at: now.clone(),
revoked_at: None,
},
false,
),
)?;
let schema = support(
WorkspaceConfigSchemaRegistry::default()
.with_provider(Arc::new(ProfileConfigSchemaProvider))
.with_provider(Arc::new(
yoi_workspace_server::prompt_settings::PromptConfigSchemaProvider,
))
.with_provider(Arc::new(
yoi_workspace_server::runtime_settings::RuntimeConfigSchemaProvider,
))
.with_provider(Arc::new(
yoi_workspace_server::repository_access::RepositoryAccessConfigSchemaProvider,
))
.with_provider(Arc::new(
yoi_workspace_server::skills::SkillConfigSchemaProvider,
))
.compose(),
)?;
let state = support(store.ensure_workspace_config_materialized_with_schema(
&fixture.manifest.workspace_id,
&now,
schema.clone(),
))?;
let main = support(VirtualPath::parse("main.dcdl"))?;
let current = state
.snapshot
.get(&main)
.ok_or_else(|| failure("Workspace config omitted main.dcdl"))?;
let candidate = support(store.evaluate_workspace_config_candidate_with_schema(
&fixture.manifest.workspace_id,
&ConfigCommitRequest {
base_digest: state.snapshot.digest.clone(),
changes: vec![ConfigTreeChange::Update {
path: main.clone(),
expected_digest: current.content_digest.clone(),
content: fs::read_to_string(copy.join("workspace-config.dcdl"))?,
}],
entrypoints: vec![main],
},
schema,
))?;
let state = support(
store.commit_evaluated_workspace_config(&fixture.manifest.workspace_id, &candidate),
)?;
let projection = support(project_profiles_from_workspace_config(
&fixture.manifest.workspace_id,
&state,
))?;
let bundle = support(build_virtual_profile_config_bundle(
&projection,
&state,
&fixture.manifest.workspace_id,
&now,
PROFILE,
))?
.ok_or_else(|| failure("fixture Profile is not registered"))?;
let archive = bundle
.profile_source_archive
.as_ref()
.ok_or_else(|| failure("fixture Profile archive is absent"))?;
let mut resolved = support(
support(archive.verify())?.resolve_profile(
PROFILE,
&environment
.runtime_data_dir
.join("runtime/workers")
.join(&fixture.manifest.worker_id),
worker_name,
),
)?;
if resolved.model.ref_.as_deref() != Some(fixture.manifest.model.as_str()) {
return Err(failure("resolved Profile changed the supplied model"));
}
let request = WorkerSpawnRequest {
intent: WorkerSpawnIntent::WorkspaceCoding,
requested_worker_name: Some("Restore E2E fixture".into()),
singleton_key: None,
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
profile: ProfileSelector::Named(PROFILE.into()),
ticket_assignment: None,
initial_submit: vec![],
workdir_attachment_requests: vec![],
resolved_workdir_attachment_requests: vec![],
resolved_workdir_attachments: vec![],
resolved_config_bundle: None,
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_subjektiv_attached: false,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: vec![],
resolved_control_operation: None,
resolved_execution_grant: None,
};
let reservation = support(seed_fixed_worker(
&store,
FixedWorkerSeed {
workspace_id: &fixture.manifest.workspace_id,
runtime_id: &fixture.manifest.runtime_id,
worker_id: support(fixture.manifest.worker_id.parse())?,
allocation_key: "restore-e2e-worker",
request: &request,
},
))?;
if resolved.feature.memory.profile.enabled {
support(
resolved
.feature
.memory
.bind_workspace_settings(reservation.memory_settings.clone()),
)?;
}
Ok(Bootstrap {
manifest: resolved,
memory_settings: reservation.memory_settings,
token,
workspace_public_key,
create_fingerprint: reservation.create_fingerprint,
archive: archive.reference.clone(),
config_bundle: ConfigBundleRef {
id: bundle.metadata.id,
digest: bundle.metadata.digest,
},
})
}
+2 -2
View File
@@ -21,8 +21,8 @@
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check", "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types", "generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check", "check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check",
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts", "test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts test/auth-browser/passkey-state.browser.test.ts",
"test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts" "test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts test/auth-browser/sidebar-shell.browser.test.ts"
}, },
"imports": { "imports": {
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0", "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0",
+23 -3
View File
@@ -21,13 +21,33 @@ export type RequestActor = { user_id: string, account_id: string, handle: string
export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", }; export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", };
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, }; export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState,
/**
* Non-secret comparison evidence, never an authentication credential.
*/
authentication_reference: string | null, };
export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, }; export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, };
export type AuthUserResponse = { user: AuthenticatedUser, }; export type AuthUserResponse = { user: AuthenticatedUser,
/**
* Comparison evidence for the exact newly issued session, not a credential.
*/
authentication_reference: string, };
export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, }; export type PasskeyAdditionOptionsRequest = { expected_user_id: string, expected_account_id: string, expected_authentication_reference: string, browser_origin?: string | null, };
export type PasskeySummary = { key_reference: string, created_at: string, last_used_at: string | null, normal_login_verified_at: string | null, };
export type PasskeyListResponse = { user: AuthenticatedUser, keys: Array<PasskeySummary>, };
export type PasskeyAdditionResultRequest = { challenge_id: string, expected_user_id: string, expected_account_id: string,
/**
* Original options authentication reference; the current same-owner session may differ.
*/
expected_authentication_reference: string, };
export type PasskeyAdditionResultResponse = { "status": "unknown", } | { "status": "saved", user: AuthenticatedUser, key: PasskeySummary, };
export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, }; export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, };
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -108,7 +108,7 @@
<details class="legacy-login" bind:open={legacyOpen}> <details class="legacy-login" bind:open={legacyOpen}>
<summary>Use an older key with account ID</summary> <summary>Use an older key with account ID</summary>
<form onsubmit={(event) => { event.preventDefault(); void submit(true); }}> <form onsubmit={(event) => { event.preventDefault(); void submit(true); }}>
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> and add a discoverable passkey for ID-free login. Your existing keys are preserved.</p> <p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> to inspect keys and verify migration. Adding with the same older-key authenticator may be excluded; use a different saved-key provider or authenticator. Keep older keys until normal ID-free login to the intended account is tested.</p>
<label>Account ID (handle) <label>Account ID (handle)
<input bind:value={handle} required maxlength="128" autocomplete="username" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="legacy-id-help" /> <input bind:value={handle} required maxlength="128" autocomplete="username" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="legacy-id-help" />
</label> </label>
@@ -131,6 +131,7 @@
{/if} {/if}
{/if} {/if}
{#if auth.status}<p role="status">{auth.status}</p>{/if} {#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if} {#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<nav aria-label="Authentication"> <nav aria-label="Authentication">
{#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor} {#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor}
@@ -56,7 +56,7 @@
{#if !protectedPage} {#if !protectedPage}
{@render children()} {@render children()}
{:else} {:else}
{#if session.kind === 'loading'} {#if session.kind === 'loading' && !admitted}
<div class="auth-page"><p role="status">Loading session…</p></div> <div class="auth-page"><p role="status">Loading session…</p></div>
{:else if session.kind === 'error'} {:else if session.kind === 'error'}
<div class="auth-page"> <div class="auth-page">
@@ -66,7 +66,7 @@
</div> </div>
{/if} {/if}
{#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted} {#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted}
<!-- 再確認中は表示/操作を停止し、進行中のlogout等をunmountで取消さない。 --> <!-- 再確認中は既存内容と操作進捗を可視に保ち、操作だけをinertで停止する。 -->
<div hidden={session.kind !== 'ready'} inert={session.kind !== 'ready'}>{@render children()}</div> <div hidden={session.kind === 'error'} inert={session.kind !== 'ready'}>{@render children()}</div>
{/if} {/if}
{/if} {/if}
@@ -1,10 +1,18 @@
declare const Deno: { test(name: string, fn: () => Promise<void>): void }; declare const Deno: { test(name: string, fn: () => Promise<void>): void };
import { browserSession } from "./session-state.ts";
import { activateWithPasskey } from "./activation.ts"; import { activateWithPasskey } from "./activation.ts";
import { import {
authUser, authUser,
credentialFixture, credentialFixture,
registrationOptions, registrationOptions,
} from "./api.test-fixtures.ts"; } from "./api.test-fixtures.ts";
function confirmedSession() {
return {
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
};
}
function equal(a: unknown, b: unknown) { function equal(a: unknown, b: unknown) {
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ"); if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ");
} }
@@ -52,9 +60,14 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
await boundary(async (input, init) => { await boundary(async (input, init) => {
requests.push({ path: String(input), init: init! }); requests.push({ path: String(input), init: init! });
return Response.json( return Response.json(
String(input).endsWith("/options") String(input) === "/api/auth/whoami"
? confirmedSession()
: String(input).endsWith("/options")
? registrationOptions ? registrationOptions
: { user: authUser }, : {
user: authUser,
authentication_reference: "fixture-authentication",
},
); );
}, async (options) => { }, async (options) => {
equal(options.signal === signal, true); equal(options.signal === signal, true);
@@ -67,16 +80,21 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
equal(requests.map((r) => r.path), [ equal(requests.map((r) => r.path), [
"/api/auth/activation/options", "/api/auth/activation/options",
"/api/auth/activation/complete", "/api/auth/activation/complete",
"/api/auth/whoami",
]); ]);
for (const { init } of requests) { for (const { path, init } of requests) {
equal(init.method, "POST"); equal(init.method ?? "GET", path === "/api/auth/whoami" ? "GET" : "POST");
equal(init.signal === signal, true);
equal(init.credentials, "same-origin"); equal(init.credentials, "same-origin");
equal(init.redirect, "error"); equal(init.redirect, "error");
equal(init.mode, "same-origin"); equal(init.mode, "same-origin");
equal(init.cache, "no-store"); equal(init.cache, "no-store");
if (path !== "/api/auth/whoami") {
equal(init.referrerPolicy, "no-referrer"); equal(init.referrerPolicy, "no-referrer");
}
equal(new Headers(init.headers).has("authorization"), false); equal(new Headers(init.headers).has("authorization"), false);
} }
equal(requests[2].init.body, undefined);
equal(JSON.parse(String(requests[0].init.body)), { equal(JSON.parse(String(requests[0].init.body)), {
handle: "alice", handle: "alice",
code: "synthetic-input-only", code: "synthetic-input-only",
@@ -125,9 +143,10 @@ Deno.test("activation sanitizes HTTP transport malformed and mismatched completi
return Response.json({ unexpected: "synthetic-input-only" }); return Response.json({ unexpected: "synthetic-input-only" });
} }
return Response.json( return Response.json(
String(input).endsWith("/options") String(input).endsWith("/options") ? registrationOptions : {
? registrationOptions user: { ...authUser, handle: "other" },
: { user: { ...authUser, handle: "other" } }, authentication_reference: "fixture-authentication",
},
); );
}, },
async () => credentialFixture(), async () => credentialFixture(),
@@ -173,3 +192,201 @@ Deno.test("activation invalid IDs and oversized code are rejected before network
}, },
); );
}); });
for (
const mode of [
"anonymous",
"bob",
"account-mismatch",
"reference-mismatch",
"api-token",
"unavailable",
] as const
) {
Deno.test(
"activation rejects " + mode +
" whoami after successful completion without retry",
async () => {
const paths: string[] = [];
await boundary(
async (input) => {
const path = String(input);
paths.push(path);
if (path === "/api/auth/activation/options") {
return Response.json(registrationOptions);
}
if (path === "/api/auth/activation/complete") {
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path !== "/api/auth/whoami") {
throw new Error("Unexpected endpoint");
}
if (mode === "unavailable") {
return new Response("private whoami diagnostic", { status: 503 });
}
if (mode === "anonymous") {
return Response.json({
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
}
const confirmation = confirmedSession();
if (mode === "bob") {
confirmation.actor = {
...confirmation.actor,
user_id: "user-bob",
account_id: "account-bob",
handle: "bob",
display_name: "Bob",
};
}
if (mode === "account-mismatch") {
confirmation.actor.account_id = "account-bob";
}
if (mode === "reference-mismatch") {
confirmation.authentication_reference = "another-authentication";
}
if (mode === "api-token") {
confirmation.actor.auth_method = "api_token";
}
return Response.json(confirmation);
},
async () => credentialFixture(),
async () => {
await rejects(
activateWithPasskey(
"alice",
"synthetic-input-only",
new AbortController().signal,
),
);
equal(paths, [
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
},
);
},
);
}
for (const stage of ["prompt", "completion", "confirmation"] as const) {
Deno.test(
"activation session generation change during " + stage +
" fences stale work",
async () => {
const paths: string[] = [];
await boundary(async (input) => {
const path = String(input);
paths.push(path);
if (path === "/api/auth/activation/options") {
return Response.json(registrationOptions);
}
if (path === "/api/auth/activation/complete") {
if (stage === "completion") browserSession.invalidate();
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/whoami") {
if (stage === "confirmation") browserSession.invalidate();
return Response.json(confirmedSession());
}
throw new Error("Unexpected endpoint");
}, async () => {
if (stage === "prompt") browserSession.invalidate();
return credentialFixture();
}, async () => {
await rejects(
activateWithPasskey(
"alice",
"synthetic-input-only",
new AbortController().signal,
),
);
equal(paths, [
"/api/auth/activation/options",
...(stage === "prompt" ? [] : ["/api/auth/activation/complete"]),
...(stage === "confirmation" ? ["/api/auth/whoami"] : []),
]);
});
},
);
}
for (
const phase of ["options", "prompt", "complete", "confirmation"] as const
) {
for (const reason of ["logout", "authentication"] as const) {
Deno.test(`activation fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
const previous = Object.getOwnPropertyDescriptor(
globalThis,
"localStorage",
);
let stamp = "initial-operation-stamp";
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => stamp },
});
const generation = browserSession.authenticationGeneration;
const paths: string[] = [];
try {
await boundary(async (input) => {
const path = String(input);
paths.push(path);
if (
(phase === "options" && path.endsWith("/options")) ||
(phase === "complete" && path.endsWith("/complete")) ||
(phase === "confirmation" && path === "/api/auth/whoami")
) {
stamp = JSON.stringify({ id: "other-tab", reason });
}
return Response.json(
path.endsWith("/options")
? registrationOptions
: path.endsWith("/complete")
? {
user: authUser,
authentication_reference: "fixture-authentication",
}
: confirmedSession(),
);
}, async () => {
if (phase === "prompt") {
stamp = JSON.stringify({ id: "other-tab", reason });
}
return credentialFixture();
}, async () => {
await rejects(
activateWithPasskey(
"alice",
"synthetic-input-only",
new AbortController().signal,
),
);
equal(browserSession.authenticationGeneration, generation);
equal(stamp === "initial-operation-stamp", false);
equal(
paths.filter((path) => path.endsWith("/complete")).length,
phase === "complete" || phase === "confirmation" ? 1 : 0,
);
equal(paths.includes("/api/auth/whoami"), phase === "confirmation");
});
} finally {
if (previous) {
Object.defineProperty(
globalThis,
"localStorage",
previous,
);
} else Reflect.deleteProperty(globalThis, "localStorage");
}
});
}
}
@@ -2,7 +2,8 @@ import type {
ActivationOptionsRequest, ActivationOptionsRequest,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationCompleteRequest,
} from "#lib/generated/auth-api.ts"; } from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts"; import { loadWhoami, readBoundedAuthResponseJson } from "./api.ts";
import { browserSession } from "./session-state.ts";
import { normalizeActivationHandle } from "./hosting-accounts.ts"; import { normalizeActivationHandle } from "./hosting-accounts.ts";
import { import {
type AuthUser, type AuthUser,
@@ -19,6 +20,16 @@ export async function activateWithPasskey(
code: string, code: string,
signal: AbortSignal, signal: AbortSignal,
): Promise<AuthUser> { ): Promise<AuthUser> {
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
// 別tabのstorage event配送より先にcredential/応答が返っても送信・適用しない。
const assertCurrent = () => {
signal.throwIfAborted();
if (
generation !== browserSession.authenticationGeneration ||
stamp !== browserSession.authenticationStamp
) throw new Error("Activation superseded");
};
try { try {
handle = normalizeActivationHandle(handle); handle = normalizeActivationHandle(handle);
if (window.isSecureContext !== true || !code || code.length > 4096) { if (window.isSecureContext !== true || !code || code.length > 4096) {
@@ -28,7 +39,7 @@ export async function activateWithPasskey(
path: "/api/auth/activation/options" | "/api/auth/activation/complete", path: "/api/auth/activation/options" | "/api/auth/activation/complete",
body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest, body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest,
): Promise<unknown> => { ): Promise<unknown> => {
signal.throwIfAborted(); assertCurrent();
const response = await fetch(path, { const response = await fetch(path, {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
@@ -44,7 +55,9 @@ export async function activateWithPasskey(
await response.body?.cancel(); await response.body?.cancel();
throw new Error("Activation rejected"); throw new Error("Activation rejected");
} }
return await readBoundedAuthResponseJson(response); const result = await readBoundedAuthResponseJson(response);
assertCurrent();
return result;
}; };
const options = await request("/api/auth/activation/options", { const options = await request("/api/auth/activation/options", {
handle, handle,
@@ -54,22 +67,33 @@ export async function activateWithPasskey(
code = ""; code = "";
const publicKey = prepareRegistrationOptions(options); const publicKey = prepareRegistrationOptions(options);
const challengeId = (options as { challenge_id: string }).challenge_id; const challengeId = (options as { challenge_id: string }).challenge_id;
signal.throwIfAborted(); assertCurrent();
const credential = await navigator.credentials.create({ const credential = await navigator.credentials.create({
publicKey, publicKey,
signal, signal,
}); });
if (!credential) throw new Error("Registration cancelled"); if (!credential) throw new Error("Registration cancelled");
signal.throwIfAborted(); assertCurrent();
const result = parseAuthUserResponse( const result = parseAuthUserResponse(
await request("/api/auth/activation/complete", { await request("/api/auth/activation/complete", {
challenge_id: challengeId, challenge_id: challengeId,
credential: registrationCredentialToJson(credential), credential: registrationCredentialToJson(credential),
}), }),
).user; );
signal.throwIfAborted(); assertCurrent();
if (result.handle !== handle) throw new Error("Activation target mismatch"); if (result.user.handle !== handle) {
return result; throw new Error("Activation target mismatch");
}
const current = await loadWhoami(signal);
assertCurrent();
if (
current.session.kind !== "authenticated" ||
current.actor?.auth_method !== "browser_session" ||
current.actor.user_id !== result.user.user_id ||
current.actor.account_id !== result.user.account_id ||
current.authentication_reference !== result.authentication_reference
) throw new Error("Activation session unconfirmed");
return result.user;
} catch { } catch {
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。 // HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
throw new Error("Activation could not be confirmed."); throw new Error("Activation could not be confirmed.");
@@ -67,6 +67,7 @@ export function credentialFixture(authentication = false): Credential {
export function whoamiPayload(actor: unknown = null) { export function whoamiPayload(actor: unknown = null) {
return { return {
actor, actor,
authentication_reference: actor === null ? null : "fixture-authentication",
session: actor === null session: actor === null
? { kind: "anonymous" } ? { kind: "anonymous" }
: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, : { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
+180 -51
View File
@@ -1,6 +1,7 @@
import type { import type {
DeviceLoginApproveRequest, DeviceLoginApproveRequest,
PasskeyAdditionOptionsRequest, PasskeyAdditionOptionsRequest,
PasskeyAdditionResultRequest,
PasskeyLoginCompleteRequest, PasskeyLoginCompleteRequest,
PasskeyLoginOptionsRequest, PasskeyLoginOptionsRequest,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationCompleteRequest,
@@ -8,24 +9,38 @@ import type {
} from "#lib/generated/auth-api.ts"; } from "#lib/generated/auth-api.ts";
import { import {
authenticationCredentialToJson, authenticationCredentialToJson,
type AuthUser, AuthPayloadError,
type DeviceApprovalResponse, type DeviceApprovalResponse,
parseAdditionResult,
parseAuthUserResponse, parseAuthUserResponse,
parseDeviceApprovalResponse, parseDeviceApprovalResponse,
parseLogoutResponse, parseLogoutResponse,
parsePasskeyList,
parseWhoamiResponse, parseWhoamiResponse,
type PasskeyUserResponse,
prepareLoginOptions, prepareLoginOptions,
prepareRegistrationOptions, prepareRegistrationOptions,
registrationCredentialToJson, registrationCredentialToJson,
type WhoamiResponse, type WhoamiResponse,
} from "#lib/workspace/auth/model.ts"; } from "#lib/workspace/auth/model.ts";
import {
authCheckpoint,
AuthOperationError,
type AuthStage,
classifyAuthFailure,
expectedAuthSession,
recordAdditionAttempt,
} from "./operation.ts";
export class AuthRequestError extends Error { export class AuthRequestError extends Error {
constructor(readonly status: number) { constructor(readonly status: number, readonly code: string | null = null) {
super(`Auth request failed (${status}).`); super(`Auth request failed (${status}).`);
} }
} }
class AuthResponseError extends Error {}
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024; const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
export async function readBoundedAuthResponseJson( export async function readBoundedAuthResponseJson(
@@ -39,13 +54,15 @@ export async function readBoundedAuthResponseJson(
parsed > MAX_AUTH_RESPONSE_BYTES parsed > MAX_AUTH_RESPONSE_BYTES
) { ) {
await response.body?.cancel(); await response.body?.cancel();
throw new Error( throw new AuthResponseError(
"Invalid auth response: response body exceeds the size limit.", "Invalid auth response: response body exceeds the size limit.",
); );
} }
} }
if (response.body === null) { if (response.body === null) {
throw new Error("Invalid auth response: response body is missing."); throw new AuthResponseError(
"Invalid auth response: response body is missing.",
);
} }
const reader = response.body.getReader(); const reader = response.body.getReader();
@@ -58,7 +75,7 @@ export async function readBoundedAuthResponseJson(
totalBytes += value.byteLength; totalBytes += value.byteLength;
if (totalBytes > MAX_AUTH_RESPONSE_BYTES) { if (totalBytes > MAX_AUTH_RESPONSE_BYTES) {
await reader.cancel(); await reader.cancel();
throw new Error( throw new AuthResponseError(
"Invalid auth response: response body exceeds the size limit.", "Invalid auth response: response body exceeds the size limit.",
); );
} }
@@ -79,7 +96,9 @@ export async function readBoundedAuthResponseJson(
new TextDecoder("utf-8", { fatal: true }).decode(bytes), new TextDecoder("utf-8", { fatal: true }).decode(bytes),
) as unknown; ) as unknown;
} catch { } catch {
throw new Error("Invalid auth response: response body is not valid JSON."); throw new AuthResponseError(
"Invalid auth response: response body is not valid JSON.",
);
} }
} }
@@ -96,8 +115,27 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
}, },
}); });
if (!response.ok) { if (!response.ok) {
await response.body?.cancel(); let code: string | null = null;
throw new AuthRequestError(response.status); try {
const value = await readBoundedAuthResponseJson(response);
const message = typeof value === "object" && value !== null
? (value as { message?: unknown }).message
: null;
if (typeof message === "string") {
code = [
"auth_required",
"passkey_authentication_changed",
"passkey_registration_binding_mismatch",
"webauthn_registration_verification_failed",
"webauthn_login_verification_failed",
"invalid_passkey_challenge",
"expired_passkey_challenge",
"self_registration_disabled",
"passkey_activation_required",
].find((code) => message.startsWith(code + ":")) ?? null;
}
} catch { /* 本文・解析例外は保存しない。statusだけでも失敗として扱う。 */ }
throw new AuthRequestError(response.status, code);
} }
return await readBoundedAuthResponseJson(response); return await readBoundedAuthResponseJson(response);
} }
@@ -122,7 +160,7 @@ export async function registerPasskey(
handle: string, handle: string,
displayName?: string, displayName?: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
const optionsRequest: PasskeyRegistrationOptionsRequest = { const optionsRequest: PasskeyRegistrationOptionsRequest = {
handle, handle,
display_name: displayName ?? null, display_name: displayName ?? null,
@@ -131,8 +169,21 @@ export async function registerPasskey(
return await createPasskey("registration", optionsRequest, signal); return await createPasskey("registration", optionsRequest, signal);
} }
export async function addPasskey(signal?: AbortSignal): Promise<AuthUser> { export async function addPasskey(
signal?: AbortSignal,
): Promise<PasskeyUserResponse> {
const expected = expectedAuthSession(signal) ?? await loadWhoami(signal);
if (!expected.actor || !expected.authentication_reference) {
throw new AuthOperationError(
"session-check",
"session-changed",
"not-sent",
);
}
const optionsRequest: PasskeyAdditionOptionsRequest = { const optionsRequest: PasskeyAdditionOptionsRequest = {
expected_user_id: expected.actor.user_id,
expected_account_id: expected.actor.account_id,
expected_authentication_reference: expected.authentication_reference,
browser_origin: window.location.origin, browser_origin: window.location.origin,
}; };
return await createPasskey("addition", optionsRequest, signal); return await createPasskey("addition", optionsRequest, signal);
@@ -144,38 +195,50 @@ async function createPasskey(
| PasskeyRegistrationOptionsRequest | PasskeyRegistrationOptionsRequest
| PasskeyAdditionOptionsRequest, | PasskeyAdditionOptionsRequest,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
let stage: AuthStage = "options";
let sent = false;
try {
await authCheckpoint(signal, stage);
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, { const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
method: "POST", method: "POST",
body: JSON.stringify(optionsRequest), body: JSON.stringify(optionsRequest),
signal, signal,
}); });
const optionsRecord = typeof options === "object" && options !== null const publicKey = prepareRegistrationOptions(options);
? options as Record<string, unknown> const challengeId = (options as { challenge_id: string }).challenge_id;
: null; stage = "authenticator";
const challengeId = optionsRecord?.challenge_id; await authCheckpoint(signal, stage);
if (typeof challengeId !== "string" || challengeId.length === 0) {
throw new Error(
"Invalid auth payload: registration_options.challenge_id is required.",
);
}
signal?.throwIfAborted();
if (typeof navigator.credentials?.create !== "function") { if (typeof navigator.credentials?.create !== "function") {
throw new DOMException("Passkeys unavailable", "NotSupportedError"); throw new DOMException("", "NotSupportedError");
} }
const credential = await navigator.credentials.create({ const credential = await navigator.credentials.create({
signal, signal,
publicKey: prepareRegistrationOptions(options), publicKey,
}); });
if (!credential) { if (!credential) throw new DOMException("", "NotAllowedError");
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted(); signal?.throwIfAborted();
stage = "conversion";
await authCheckpoint(signal, stage);
const completeRequest: PasskeyRegistrationCompleteRequest = { const completeRequest: PasskeyRegistrationCompleteRequest = {
challenge_id: challengeId, challenge_id: challengeId,
credential: registrationCredentialToJson(credential), credential: registrationCredentialToJson(credential),
}; };
stage = "before-complete";
await authCheckpoint(signal, stage);
stage = "complete";
await authCheckpoint(signal, stage);
if (purpose === "addition") {
const expected = optionsRequest as PasskeyAdditionOptionsRequest;
recordAdditionAttempt(signal, {
challenge_id: challengeId,
expected_user_id: expected.expected_user_id,
expected_account_id: expected.expected_account_id,
expected_authentication_reference:
expected.expected_authentication_reference,
});
}
sent = true;
const result = parseAuthUserResponse( const result = parseAuthUserResponse(
await requestJson(`/api/auth/passkeys/${purpose}/complete`, { await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
method: "POST", method: "POST",
@@ -183,12 +246,37 @@ async function createPasskey(
signal, signal,
}), }),
); );
return result.user; signal?.throwIfAborted();
return result;
} catch (error) {
if (error instanceof AuthRequestError) {
throw new AuthOperationError(
stage,
error.code === "auth_required" ||
error.code === "passkey_authentication_changed" ||
error.code === "passkey_registration_binding_mismatch"
? "session-changed"
: "rejected",
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
error.status,
);
}
if (
error instanceof AuthResponseError || error instanceof AuthPayloadError
) {
throw new AuthOperationError(
stage,
stage === "conversion" ? "conversion" : "invalid-response",
sent ? "unknown" : "not-sent",
);
}
throw classifyAuthFailure(error, stage, sent);
}
} }
export async function loginWithPasskey( export async function loginWithPasskey(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
return await authenticateWithPasskey({ return await authenticateWithPasskey({
browser_origin: window.location.origin, browser_origin: window.location.origin,
}, signal); }, signal);
@@ -197,7 +285,7 @@ export async function loginWithPasskey(
export async function loginWithLegacyPasskey( export async function loginWithLegacyPasskey(
handle: string, handle: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
const loginHandle = handle.trim(); const loginHandle = handle.trim();
if (!loginHandle) { if (!loginHandle) {
throw new Error("User handle is required for legacy passkey login."); throw new Error("User handle is required for legacy passkey login.");
@@ -211,38 +299,37 @@ export async function loginWithLegacyPasskey(
async function authenticateWithPasskey( async function authenticateWithPasskey(
optionsRequest: PasskeyLoginOptionsRequest, optionsRequest: PasskeyLoginOptionsRequest,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
let stage: AuthStage = "options";
let sent = false;
try {
await authCheckpoint(signal, stage);
const options = await requestJson("/api/auth/passkeys/login/options", { const options = await requestJson("/api/auth/passkeys/login/options", {
method: "POST", method: "POST",
body: JSON.stringify(optionsRequest), body: JSON.stringify(optionsRequest),
signal, signal,
}); });
const optionsRecord = typeof options === "object" && options !== null const publicKey = prepareLoginOptions(options);
? options as Record<string, unknown> const challengeId = (options as { challenge_id: string }).challenge_id;
: null; stage = "authenticator";
const challengeId = optionsRecord?.challenge_id; await authCheckpoint(signal, stage);
if (typeof challengeId !== "string" || challengeId.length === 0) {
throw new Error(
"Invalid auth payload: login_options.challenge_id is required.",
);
}
signal?.throwIfAborted();
if (typeof navigator.credentials?.get !== "function") { if (typeof navigator.credentials?.get !== "function") {
throw new DOMException("Passkeys unavailable", "NotSupportedError"); throw new DOMException("", "NotSupportedError");
} }
const credential = await navigator.credentials.get({ const credential = await navigator.credentials.get({ signal, publicKey });
signal, if (!credential) throw new DOMException("", "NotAllowedError");
publicKey: prepareLoginOptions(options),
});
if (!credential) {
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted(); signal?.throwIfAborted();
stage = "conversion";
await authCheckpoint(signal, stage);
const completeRequest: PasskeyLoginCompleteRequest = { const completeRequest: PasskeyLoginCompleteRequest = {
challenge_id: challengeId, challenge_id: challengeId,
credential: authenticationCredentialToJson(credential), credential: authenticationCredentialToJson(credential),
}; };
stage = "before-complete";
await authCheckpoint(signal, stage);
stage = "complete";
await authCheckpoint(signal, stage);
sent = true;
const result = parseAuthUserResponse( const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/login/complete", { await requestJson("/api/auth/passkeys/login/complete", {
method: "POST", method: "POST",
@@ -250,13 +337,39 @@ async function authenticateWithPasskey(
signal, signal,
}), }),
); );
return result.user; signal?.throwIfAborted();
return result;
} catch (error) {
if (error instanceof AuthRequestError) {
throw new AuthOperationError(
stage,
error.code === "auth_required" ||
error.code === "passkey_authentication_changed" ||
error.code === "passkey_registration_binding_mismatch"
? "session-changed"
: "rejected",
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
error.status,
);
}
if (
error instanceof AuthResponseError || error instanceof AuthPayloadError
) {
throw new AuthOperationError(
stage,
stage === "conversion" ? "conversion" : "invalid-response",
sent ? "unknown" : "not-sent",
);
}
throw classifyAuthFailure(error, stage, sent);
}
} }
export async function approveDeviceLogin( export async function approveDeviceLogin(
userCode: string, userCode: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<DeviceApprovalResponse> { ): Promise<DeviceApprovalResponse> {
await authCheckpoint(signal, "before-complete");
const request: DeviceLoginApproveRequest = { user_code: userCode }; const request: DeviceLoginApproveRequest = { user_code: userCode };
return parseDeviceApprovalResponse( return parseDeviceApprovalResponse(
await requestJson("/api/auth/device-login/approve", { await requestJson("/api/auth/device-login/approve", {
@@ -266,3 +379,19 @@ export async function approveDeviceLogin(
}), }),
); );
} }
export async function loadPasskeys(signal?: AbortSignal) {
return parsePasskeyList(await requestJson("/api/auth/passkeys", { signal }));
}
export async function readAdditionResult(
request: PasskeyAdditionResultRequest,
signal?: AbortSignal,
) {
return parseAdditionResult(
await requestJson("/api/auth/passkeys/addition/result", {
method: "POST",
body: JSON.stringify(request),
signal,
}),
);
}
@@ -1,11 +1,18 @@
import { AuthRequestError } from "./api.ts"; import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
import { AuthRequestError, loadWhoami, readAdditionResult } from "./api.ts";
import { import {
browserSession, browserSession,
observeBrowserSession, observeBrowserSession,
sessionMutation, sessionMutation,
} from "./session-state.ts"; } from "./session-state.ts";
import { notifySessionChanged } from "./hosting.ts"; import { notifySessionChanged } from "./hosting.ts";
import type { RequestActor, WhoamiResponse } from "./model.ts"; import type { AuthUser, RequestActor, WhoamiResponse } from "./model.ts";
import {
AuthOperationError,
type AuthStage,
observeAuthOperation,
operationErrorMessage,
} from "./operation.ts";
export type BrowserSession = export type BrowserSession =
| { kind: "loading" } | { kind: "loading" }
@@ -13,41 +20,60 @@ export type BrowserSession =
| ({ kind: "ready" } & WhoamiResponse); | ({ kind: "ready" } & WhoamiResponse);
export function authErrorMessage(error: unknown): string { export function authErrorMessage(error: unknown): string {
if (error instanceof AuthOperationError) return operationErrorMessage(error);
if (error instanceof AuthRequestError) { if (error instanceof AuthRequestError) {
if (error.status === 401) { return `Authentication request was rejected (${error.status}). Check your session or restart the operation.`;
return "Authentication request was rejected. Check your session or restart the Passkey operation.";
} }
if (error.status === 400 || error.status === 410) { // 例外本文/causeや外部応答は表示にも診断にも複写しない。
return "The request or authentication challenge is invalid or expired. Restart the operation."; return "Authentication could not be confirmed. Check your session and the operation result before retrying.";
} }
return error.message; function sameUser(a: AuthUser | null, b: AuthUser | null): boolean {
return a === null
? b === null
: b !== null && a.user_id === b.user_id && a.account_id === b.account_id;
} }
if (error instanceof DOMException && error.name === "NotSupportedError") { function sameSession(a: WhoamiResponse, b: WhoamiResponse): boolean {
return "This browser or key cannot complete this passkey operation. Use a supported device or key with saved passkeys and PIN or biometric verification. Completion is not confirmed."; // anonymous/expiredはいずれも認証主体もbindingもない。別tabのlogoutでexpiredの
// 表示だけが古くなっても、新たな明示loginを拒否しない。認証済みへの変化は拒否する。
if (
!a.actor && !b.actor && a.session.kind !== "authenticated" &&
b.session.kind !== "authenticated"
) return true;
return sameUser(a.actor, b.actor) && a.session.kind === b.session.kind &&
(a.session.kind !== "authenticated" ||
a.authentication_reference === b.authentication_reference &&
b.session.kind === "authenticated" &&
a.session.expires_at === b.session.expires_at &&
a.actor?.auth_method === b.actor?.auth_method);
} }
if (error instanceof DOMException && error.name === "NotAllowedError") { function returnedUser(value: unknown): AuthUser | null {
return "Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again."; if (typeof value !== "object" || value === null) return null;
const user = (value as { user?: Partial<AuthUser> }).user;
return user && typeof user.user_id === "string" &&
typeof user.account_id === "string" && typeof user.handle === "string" &&
typeof user.display_name === "string"
? user as AuthUser
: null;
} }
if (error instanceof DOMException && error.name === "AbortError") {
return "Operation stopped. Completion is not confirmed; check your session before retrying.";
}
// Browser/transport例外の本文を表示せず、credentialや外部応答を診断へ複写しない。
return "Authentication could not be confirmed. Check your session before retrying the operation.";
}
export class BrowserAuth { export class BrowserAuth {
session = $state<BrowserSession>({ kind: "loading" }); session = $state<BrowserSession>({ kind: "loading" });
busy = $state(false); busy = $state(false);
error = $state(""); error = $state("");
status = $state(""); status = $state("");
additionAttempt = $state<PasskeyAdditionResultRequest | null>(null);
additionSaved = $state(false);
private unsubscribe: () => void; private unsubscribe: () => void;
private disposed = false; private disposed = false;
private controller: AbortController | null = null; private controller: AbortController | null = null;
private operationGeneration: number | null = null;
constructor() { constructor() {
observeBrowserSession(); observeBrowserSession();
this.unsubscribe = browserSession.subscribe((snapshot) => { this.unsubscribe = browserSession.subscribe((snapshot) => {
// 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。 if (
this.operationGeneration !== null &&
this.operationGeneration !== browserSession.authenticationGeneration
) this.controller?.abort();
if (this.busy && snapshot.kind === "loading") return; if (this.busy && snapshot.kind === "loading") return;
if (!this.disposed) { if (!this.disposed) {
this.session = snapshot.kind === "ready" this.session = snapshot.kind === "ready"
@@ -56,75 +82,239 @@ export class BrowserAuth {
} }
}); });
} }
get actor(): RequestActor | null { get actor(): RequestActor | null {
return this.session.kind === "ready" ? this.session.actor : null; return this.session.kind === "ready" ? this.session.actor : null;
} }
async ensure(): Promise<void> { async ensure(): Promise<void> {
if (this.busy || this.disposed) return; if (!this.busy && !this.disposed) await browserSession.ensure();
await browserSession.ensure();
} }
async refresh(): Promise<void> { async refresh(): Promise<void> {
if (this.busy || this.disposed) return; if (!this.busy && !this.disposed) await browserSession.refresh();
await browserSession.refresh(); }
cancel(): void {
this.controller?.abort();
} }
async run( async run(
pending: string, pending: string,
success: string, success: string,
operation: (signal: AbortSignal) => Promise<unknown>, operation: (signal: AbortSignal) => Promise<unknown>,
intent: "authentication" | "logout" = "authentication", intent: "authentication" | "logout" | "device-approval" = "authentication",
): Promise<boolean> { ): Promise<boolean> {
if ( if (
this.busy || this.disposed || this.busy || this.disposed ||
(intent !== "logout" && this.session.kind !== "ready") (intent !== "logout" && this.session.kind !== "ready")
) { ) return false;
return false; const mutationReason = intent === "logout" ? "logout" : "authentication";
} const expected = this.session.kind === "ready" ? this.session : null;
this.busy = true; this.busy = true;
sessionMutation(intent); sessionMutation(mutationReason, true);
const authenticationGeneration = browserSession.authenticationGeneration; const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
let acceptedGeneration = generation;
let acceptedStamp = stamp;
let submittedAddition = false;
const controller = new AbortController(); const controller = new AbortController();
this.controller = controller; this.controller = controller;
this.operationGeneration = generation;
this.error = ""; this.error = "";
this.status = pending; this.status = pending;
const verifySession = async () => {
if (stamp !== browserSession.authenticationStamp) controller.abort();
controller.signal.throwIfAborted();
let current: WhoamiResponse;
try { try {
await operation(controller.signal); current = await loadWhoami(controller.signal);
const superseded = } catch {
authenticationGeneration !== browserSession.authenticationGeneration; throw new AuthOperationError(
sessionMutation(intent); "session-check",
"session-changed",
"not-sent",
);
}
if (stamp !== browserSession.authenticationStamp) controller.abort();
controller.signal.throwIfAborted();
if (!expected || !sameSession(expected, current)) {
throw new AuthOperationError(
"session-check",
"session-changed",
"not-sent",
);
}
};
observeAuthOperation(controller.signal, {
verifySession,
expected,
additionAttempt: (attempt) => {
submittedAddition = true;
this.additionAttempt = attempt;
this.additionSaved = false;
},
phase: (stage: AuthStage) => {
if (!this.disposed) {
this.status = stage === "authenticator"
? pending
: stage === "complete"
? "Confirming the operation with the server…"
: stage === "before-complete"
? "Checking the account before completion…"
: stage === "options"
? "Checking the account and requesting passkey options…"
: "Preparing the authenticator response…";
}
},
});
try {
if (intent === "device-approval") await verifySession();
const result = await operation(controller.signal);
const user = returnedUser(result);
const issuedReference = typeof result === "object" && result !== null
? (result as { authentication_reference?: unknown })
.authentication_reference
: null;
controller.signal.throwIfAborted();
if (
this.disposed ||
generation !== browserSession.authenticationGeneration ||
stamp !== browserSession.authenticationStamp
) return false;
// 自身の再確認によるinvalidateと、他の操作が行ったinvalidateを混同しない。
this.operationGeneration = null;
sessionMutation(mutationReason, true);
const confirmationGeneration = browserSession.authenticationGeneration; const confirmationGeneration = browserSession.authenticationGeneration;
acceptedGeneration = confirmationGeneration;
const confirmationStamp = browserSession.authenticationStamp;
acceptedStamp = confirmationStamp;
this.status = "Confirming the expected browser session…";
await browserSession.refresh(); await browserSession.refresh();
if ( if (
this.disposed || superseded || this.disposed || controller.signal.aborted ||
confirmationGeneration !== browserSession.authenticationGeneration confirmationGeneration !== browserSession.authenticationGeneration ||
confirmationStamp !== browserSession.authenticationStamp
) { ) {
if (!this.disposed) this.status = ""; if (!this.disposed) this.status = "";
return false; return false;
} }
const snapshot = browserSession.snapshot;
const confirmed = snapshot.kind === "ready" &&
(intent === "logout"
? snapshot.value.actor === null &&
snapshot.value.session.kind !== "authenticated"
: intent === "device-approval"
? snapshot.value.session.kind === "authenticated" && user !== null &&
sameUser(user, expected?.actor ?? null) &&
sameUser(user, snapshot.value.actor) &&
snapshot.value.authentication_reference ===
expected?.authentication_reference &&
typeof result === "object" && result !== null &&
(result as { status?: unknown }).status === "approved"
: snapshot.value.session.kind === "authenticated" &&
snapshot.value.actor?.auth_method === "browser_session" &&
user !== null && typeof issuedReference === "string" &&
issuedReference === snapshot.value.authentication_reference &&
sameUser(user, snapshot.value.actor) &&
(!expected?.actor || sameUser(expected.actor, user)));
if (!confirmed) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"saved-session-unconfirmed",
);
}
notifySessionChanged(); notifySessionChanged();
const confirmed = this.session.kind === "ready"; if (submittedAddition) this.additionSaved = true;
this.status = confirmed ? success : ""; this.status = success;
return confirmed; return true;
} catch (error) { } catch (error) {
if (!this.disposed) { if (!this.disposed) {
this.error = authErrorMessage(error); this.error = authErrorMessage(error);
this.status = ""; this.status = "";
} }
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。 // 古い処理から新しいログアウト/主体確認をinvalidateしない。
sessionMutation(intent); if (
!this.disposed &&
acceptedGeneration === browserSession.authenticationGeneration &&
acceptedStamp === browserSession.authenticationStamp
) {
this.operationGeneration = null;
sessionMutation(mutationReason, true);
await browserSession.refresh(); await browserSession.refresh();
}
return false; return false;
} finally { } finally {
if (!this.disposed) { this.operationGeneration = null;
this.busy = false;
this.controller = null; this.controller = null;
if (!this.disposed) this.busy = false;
} }
} }
async checkAddition(): Promise<void> {
const attempt = this.additionAttempt;
if (!attempt || this.busy || this.disposed) return;
this.busy = true;
this.error = "";
this.status = "Checking the saved addition result…";
// 保存の履歴は失敗に戻さないが、過去の照合で再追加を許可しない。
this.additionSaved = false;
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
const controller = new AbortController();
this.controller = controller;
this.operationGeneration = generation;
const assertCurrent = () => {
controller.signal.throwIfAborted();
if (
this.disposed ||
generation !== browserSession.authenticationGeneration ||
stamp !== browserSession.authenticationStamp
) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"unknown",
);
}
};
try {
const before = await loadWhoami(controller.signal);
assertCurrent();
if (
!before.actor || before.actor.user_id !== attempt.expected_user_id ||
before.actor.account_id !== attempt.expected_account_id
) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"unknown",
);
}
const result = await readAdditionResult(attempt, controller.signal);
assertCurrent();
const after = await loadWhoami(controller.signal);
assertCurrent();
if (!sameSession(before, after)) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"unknown",
);
}
if (result.status === "saved" && sameUser(result.user, before.actor)) {
this.additionSaved = true;
this.status =
"This addition saved a key to your account. Your current session was checked; the session issued by completion is not confirmed. Test ID-free login with that key before retiring older-key access.";
} else {this.status =
"The addition result is still unknown. Completion may still be in flight. Do not add another key yet; check again or use the registered-key list and ID-free login to verify access.";}
} catch {
if (!this.disposed) {
this.error =
"The addition result could not be read with the current account/session. This does not prove failure. Return to the intended account and check its key list or ID-free login; do not recreate the original session or add blindly.";
this.status = "";
}
} finally {
this.operationGeneration = null;
this.controller = null;
if (!this.disposed) this.busy = false;
}
} }
dispose(): void { dispose(): void {
this.disposed = true; this.disposed = true;
this.unsubscribe(); this.unsubscribe();
@@ -1,7 +1,7 @@
import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts"; import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts"; import { readBoundedAuthResponseJson } from "./api.ts";
import { HostingRequestError } from "./hosting.ts"; import { HostingRequestError } from "./hosting.ts";
import { parseAuthUserResponse } from "./model.ts"; import { parseAuthUser } from "./model.ts";
// Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。 // Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。
export function normalizeActivationHandle(value: string): string { export function normalizeActivationHandle(value: string): string {
@@ -57,7 +57,7 @@ export async function issueActivationCode(
typeof record.expires_at !== "string" || typeof record.expires_at !== "string" ||
!Number.isFinite(Date.parse(record.expires_at)) !Number.isFinite(Date.parse(record.expires_at))
) throw new Error(); ) throw new Error();
const user = parseAuthUserResponse({ user: record.user }).user; const user = parseAuthUser(record.user);
if (user.handle !== handle) throw new Error(); if (user.handle !== handle) throw new Error();
signal.throwIfAborted(); signal.throwIfAborted();
return { user, code: record.code, expires_at: record.expires_at }; return { user, code: record.code, expires_at: record.expires_at };
@@ -7,6 +7,16 @@ import {
registrationOptions, registrationOptions,
} from "./api.test-fixtures.ts"; } from "./api.test-fixtures.ts";
const bearerIdentity = {
actor: { ...authUser, auth_method: "api_token" },
authentication_reference: "fixture-bearer-authentication",
session: { kind: "authenticated", expires_at: null },
};
const completion = {
user: authUser,
authentication_reference: "fixture-issued-authentication",
};
function equal(actual: unknown, expected: unknown): void { function equal(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Values differ"); throw new Error("Values differ");
@@ -65,9 +75,11 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
await boundary(async (input, init) => { await boundary(async (input, init) => {
requests.push({ path: String(input), init: init! }); requests.push({ path: String(input), init: init! });
return Response.json( return Response.json(
String(input).endsWith("/options") String(input).endsWith("/whoami")
? bearerIdentity
: String(input).endsWith("/options")
? registrationOptions ? registrationOptions
: { user: authUser }, : completion,
); );
}, async () => { }, async () => {
equal( equal(
@@ -75,11 +87,13 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
authUser, authUser,
); );
equal(requests.map((request) => request.path), [ equal(requests.map((request) => request.path), [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete", "/api/auth/passkeys/addition/complete",
]); ]);
for (const { init } of requests) { for (const { path, init } of requests) {
equal(init.method, "POST"); equal(init.method, path.endsWith("/whoami") ? "GET" : "POST");
equal(new Headers(init.headers).has("cookie"), false);
equal(init.credentials, "omit"); equal(init.credentials, "omit");
equal(init.mode, "same-origin"); equal(init.mode, "same-origin");
equal(init.redirect, "error"); equal(init.redirect, "error");
@@ -91,10 +105,15 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
); );
equal(String(init.body).includes("host-test-token"), false); equal(String(init.body).includes("host-test-token"), false);
} }
equal(JSON.parse(String(requests[0].init.body)), { equal(requests[0].init.body, undefined);
equal(JSON.parse(String(requests[1].init.body)), {
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference:
bearerIdentity.authentication_reference,
browser_origin: "https://yoi.example", browser_origin: "https://yoi.example",
}); });
const complete = JSON.parse(String(requests[1].init.body)); const complete = JSON.parse(String(requests[2].init.body));
equal(Object.keys(complete).sort(), ["challenge_id", "credential"]); equal(Object.keys(complete).sort(), ["challenge_id", "credential"]);
equal(complete.challenge_id, registrationOptions.challenge_id); equal(complete.challenge_id, registrationOptions.challenge_id);
equal(complete.credential.response.attestationObject, "AQID"); equal(complete.credential.response.attestationObject, "AQID");
@@ -151,29 +170,46 @@ Deno.test("host enrollment validates options before opening credential prompt",
}] }]
) { ) {
let calls = 0; let calls = 0;
await boundary(async () => Response.json(payload), async () => { await boundary(
async (input) =>
Response.json(
String(input).endsWith("/whoami") ? bearerIdentity : payload,
),
async () => {
await rejects( await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal), enrollHostingPasskey("host-test-token", new AbortController().signal),
); );
equal(calls, 0); equal(calls, 0);
}, async () => { },
async () => {
calls++; calls++;
return credentialFixture(); return credentialFixture();
}); },
);
} }
}); });
Deno.test("host enrollment cancellation and teardown prevent completion", async () => { Deno.test("host enrollment cancellation and teardown prevent completion", async () => {
for (const abort of [false, true]) { for (const abort of [false, true]) {
const controller = new AbortController(); const controller = new AbortController();
let requests = 0; const requests: string[] = [];
await boundary(async () => { let prompts = 0;
requests++; await boundary(async (input) => {
return Response.json(registrationOptions); requests.push(String(input));
return Response.json(
String(input).endsWith("/whoami")
? bearerIdentity
: registrationOptions,
);
}, async () => { }, async () => {
await rejects(enrollHostingPasskey("host-test-token", controller.signal)); await rejects(enrollHostingPasskey("host-test-token", controller.signal));
equal(requests, 1); equal(requests, [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
]);
equal(prompts, 1);
}, async () => { }, async () => {
prompts++;
if (abort) { if (abort) {
controller.abort(); controller.abort();
return credentialFixture(); return credentialFixture();
@@ -188,6 +224,9 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
let calls = 0; let calls = 0;
await boundary(async (input) => { await boundary(async (input) => {
calls++; calls++;
if (String(input).endsWith("/whoami")) {
return Response.json(bearerIdentity);
}
if (String(input).endsWith("/options")) { if (String(input).endsWith("/options")) {
return Response.json(registrationOptions); return Response.json(registrationOptions);
} }
@@ -201,7 +240,7 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
await rejects( await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal), enrollHostingPasskey("host-test-token", new AbortController().signal),
); );
equal(calls, 2); equal(calls, 3);
}); });
} }
}); });
@@ -6,6 +6,7 @@ import { readBoundedAuthResponseJson } from "./api.ts";
import { import {
type AuthUser, type AuthUser,
parseAuthUserResponse, parseAuthUserResponse,
parseWhoamiResponse,
prepareRegistrationOptions, prepareRegistrationOptions,
registrationCredentialToJson, registrationCredentialToJson,
} from "./model.ts"; } from "./model.ts";
@@ -26,13 +27,14 @@ export async function enrollHostingPasskey(
} }
const request = async ( const request = async (
path: path:
| "/api/auth/whoami"
| "/api/auth/passkeys/addition/options" | "/api/auth/passkeys/addition/options"
| "/api/auth/passkeys/addition/complete", | "/api/auth/passkeys/addition/complete",
body: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest, body?: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
): Promise<unknown> => { ): Promise<unknown> => {
signal.throwIfAborted(); signal.throwIfAborted();
const response = await fetch(path, { const response = await fetch(path, {
method: "POST", method: path === "/api/auth/whoami" ? "GET" : "POST",
// Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、 // Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、
// completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。 // completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。
credentials: "omit", credentials: "omit",
@@ -53,7 +55,12 @@ export async function enrollHostingPasskey(
} }
return await readBoundedAuthResponseJson(response); return await readBoundedAuthResponseJson(response);
}; };
const identity = parseWhoamiResponse(await request("/api/auth/whoami"));
if (!identity.actor || !identity.authentication_reference || identity.actor.auth_method !== "api_token") throw new Error("Setup identity unconfirmed");
const options = await request("/api/auth/passkeys/addition/options", { const options = await request("/api/auth/passkeys/addition/options", {
expected_user_id: identity.actor.user_id,
expected_account_id: identity.actor.account_id,
expected_authentication_reference: identity.authentication_reference,
browser_origin: window.location.origin, browser_origin: window.location.origin,
}); });
const publicKey = prepareRegistrationOptions(options); const publicKey = prepareRegistrationOptions(options);
@@ -4,6 +4,7 @@ declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void; test(name: string, fn: () => void | Promise<void>): void;
}; };
import { AuthOperationError } from "./operation.ts";
import { loginWithLegacyPasskey, loginWithPasskey } from "./api.ts"; import { loginWithLegacyPasskey, loginWithPasskey } from "./api.ts";
import { import {
authUser, authUser,
@@ -64,7 +65,12 @@ async function boundary(
Response.json(body.handle ? legacyLoginOptions : loginOptions), Response.json(body.handle ? legacyLoginOptions : loginOptions),
); );
} }
return Promise.resolve(Response.json({ user: authUser })); return Promise.resolve(
Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
}),
);
}, },
}, },
}); });
@@ -97,7 +103,7 @@ for (const legacy of [false, true]) {
await (legacy await (legacy
? loginWithLegacyPasskey(" alice ", signal) ? loginWithLegacyPasskey(" alice ", signal)
: loginWithPasskey(signal)), : loginWithPasskey(signal)),
authUser, { user: authUser, authentication_reference: "fixture-authentication" },
); );
equal(state.requests.map(({ path }) => path), [ equal(state.requests.map(({ path }) => path), [
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
@@ -131,8 +137,10 @@ for (const legacy of [false, true]) {
: loginWithPasskey(controller.signal)); : loginWithPasskey(controller.signal));
throw new Error("Unexpected login success"); throw new Error("Unexpected login success");
} catch (error) { } catch (error) {
if (!(error instanceof DOMException)) throw error; if (!(error instanceof AuthOperationError)) throw error;
equal(error.name, late ? "AbortError" : "NotAllowedError"); equal(error.stage, "authenticator");
equal(error.reason, late ? "stopped" : "cancelled-or-unavailable");
equal(error.outcome, "not-sent");
} }
equal(state.requests.length, 1); equal(state.requests.length, 1);
}); });
@@ -2,6 +2,7 @@ import {
authenticationCredentialToJson, authenticationCredentialToJson,
base64UrlToBuffer, base64UrlToBuffer,
bufferToBase64Url, bufferToBase64Url,
parseAuthUserResponse,
parseDeviceLoginPollResponse, parseDeviceLoginPollResponse,
parseDeviceLoginStartResponse, parseDeviceLoginStartResponse,
parseWhoamiResponse, parseWhoamiResponse,
@@ -119,12 +120,21 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
Deno.test("whoami rejects unknown auth methods and extra fields", () => { Deno.test("whoami rejects unknown auth methods and extra fields", () => {
assertEquals( assertEquals(
parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }), parseWhoamiResponse({
{ actor: null, session: { kind: "anonymous" } }, actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
}),
{
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
},
); );
assertThrows( assertThrows(
() => () =>
parseWhoamiResponse({ parseWhoamiResponse({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { actor: {
user_id: "user-1", user_id: "user-1",
@@ -137,7 +147,12 @@ Deno.test("whoami rejects unknown auth methods and extra fields", () => {
"unknown auth method", "unknown auth method",
); );
assertThrows( assertThrows(
() => parseWhoamiResponse({ actor: null, token: "secret" }), () =>
parseWhoamiResponse({
actor: null,
authentication_reference: null,
token: "secret",
}),
"unexpected whoami field", "unexpected whoami field",
); );
}); });
@@ -197,7 +212,11 @@ Deno.test("auth validation enforces cumulative budgets without echoing unknown k
const attackerKey = `secret-${"x".repeat(512)}`; const attackerKey = `secret-${"x".repeat(512)}`;
try { try {
parseWhoamiResponse({ actor: null, [attackerKey]: true }); parseWhoamiResponse({
actor: null,
authentication_reference: null,
[attackerKey]: true,
});
throw new Error("expected an error"); throw new Error("expected an error");
} catch (error) { } catch (error) {
if (!(error instanceof Error) || error.message.includes(attackerKey)) { if (!(error instanceof Error) || error.message.includes(attackerKey)) {
@@ -248,23 +267,153 @@ Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", ()
}; };
for ( for (
const payload of [ const payload of [
{ actor, session: { kind: "anonymous" } }, { actor, authentication_reference: null, session: { kind: "anonymous" } },
{ actor, session: { kind: "expired" } }, { actor, authentication_reference: null, session: { kind: "expired" } },
{ {
actor: null, actor: null,
authentication_reference: null,
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
}, },
{ actor, session: { kind: "authenticated", expires_at: null } }, {
{ actor, session: { kind: "authenticated", expires_at: "tomorrow" } }, actor,
{ actor: null, session: { kind: "future" } }, authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: null },
},
{
actor,
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "tomorrow" },
},
{ {
actor: null, actor: null,
authentication_reference: null,
session: { kind: "future" },
},
{
actor: null,
authentication_reference: null,
session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" },
}, },
] ]
) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami"); ) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami");
assertEquals( assertEquals(
parseWhoamiResponse({ actor: null, session: { kind: "expired" } }), parseWhoamiResponse({
{ actor: null, session: { kind: "expired" } }, actor: null,
authentication_reference: null,
session: { kind: "expired" },
}),
{
actor: null,
authentication_reference: null,
session: { kind: "expired" },
},
);
});
Deno.test("whoami and completion require explicit consistent authentication references", () => {
const user = {
user_id: "u",
account_id: "a",
handle: "alice",
display_name: "Alice",
};
const authenticated = {
actor: { ...user, auth_method: "browser_session" },
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
} as const;
for (const reference of [undefined, null, "", 42, "x".repeat(16385)]) {
assertThrows(
() =>
parseWhoamiResponse({
...authenticated,
authentication_reference: reference,
}),
"invalid authenticated reference",
);
assertThrows(
() =>
parseAuthUserResponse({ user, authentication_reference: reference }),
"invalid issuance reference",
);
}
for (const kind of ["anonymous", "expired"]) {
assertThrows(
() => parseWhoamiResponse({ actor: null, session: { kind } }),
"missing anonymous reference",
);
assertThrows(
() =>
parseWhoamiResponse({
actor: null,
authentication_reference: "issued",
session: { kind },
}),
"anonymous reference must be null",
);
}
const completion = { user, authentication_reference: "issued" };
assertEquals(parseAuthUserResponse(completion), completion);
assertEquals(
parseWhoamiResponse({
...authenticated,
authentication_reference: "issued",
}),
{
actor: authenticated.actor,
authentication_reference: "issued",
session: authenticated.session,
},
);
});
Deno.test("bounded future transports do not discard a created credential and required crypto bytes stay validated", () => {
const bytes = new Uint8Array([1, 2, 3]).buffer;
const credential = {
id: "AQID",
rawId: bytes,
type: "public-key",
getClientExtensionResults: () => ({
credProps: { rk: true },
prf: { results: { first: bytes } },
largeBlob: { blob: bytes },
}),
response: {
clientDataJSON: bytes,
attestationObject: bytes,
getTransports: () => ["internal", "hybrid", "future-bounded-transport"],
},
};
const converted = registrationCredentialToJson(credential) as {
response: { transports: string[] };
clientExtensionResults: unknown;
};
assertEquals(converted.response.transports, ["internal", "hybrid"]);
assertEquals(converted.clientExtensionResults, { credProps: { rk: true } });
assertThrows(
() =>
registrationCredentialToJson({
...credential,
response: { ...credential.response, clientDataJSON: "not-binary" },
}),
"required client data remains binary",
);
assertThrows(
() =>
registrationCredentialToJson({
...credential,
response: {
...credential.response,
getTransports: () => ["x".repeat(16385)],
},
}),
"transport bound remains enforced",
);
assertThrows(
() =>
registrationCredentialToJson({
...credential,
getClientExtensionResults: () => ({ credProps: { rk: bytes } }),
}),
"supported JSON extension never silently converts binary to an empty object",
); );
}); });
+66 -22
View File
@@ -8,6 +8,9 @@ import type {
DeviceLoginPollStatus, DeviceLoginPollStatus,
DeviceLoginStartResponse, DeviceLoginStartResponse,
LogoutResponse, LogoutResponse,
PasskeyListResponse,
PasskeySummary,
PasskeyAdditionResultResponse,
PasskeyLoginOptionsResponse, PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse, PasskeyRegistrationOptionsResponse,
RequestActor, RequestActor,
@@ -21,6 +24,9 @@ export type {
AuthPublicConfig, AuthPublicConfig,
DeviceLoginPollResponse, DeviceLoginPollResponse,
DeviceLoginStartResponse, DeviceLoginStartResponse,
PasskeyListResponse,
PasskeySummary,
PasskeyAdditionResultResponse,
PasskeyLoginOptionsResponse, PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse, PasskeyRegistrationOptionsResponse,
RequestActor, RequestActor,
@@ -37,8 +43,10 @@ const MAX_DEVICE_LOGIN_SECONDS = 24 * 60 * 60;
const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000; const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000;
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/; const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/;
export class AuthPayloadError extends Error {}
function invalid(path: string, reason: string): never { function invalid(path: string, reason: string): never {
throw new Error(`Invalid auth payload: ${path} ${reason}.`); throw new AuthPayloadError(`Invalid auth payload: ${path} ${reason}.`);
} }
function asRecord(value: unknown, path: string): Record<string, unknown> { function asRecord(value: unknown, path: string): Record<string, unknown> {
@@ -119,6 +127,7 @@ function boundedJson(
if (budget.remainingNodes < 0) invalid(path, "has too many values"); if (budget.remainingNodes < 0) invalid(path, "has too many values");
if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested"); if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested");
if (value === null || typeof value === "boolean") return; if (value === null || typeof value === "boolean") return;
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) invalid(path, "contains unsupported binary data");
if (typeof value === "string") { if (typeof value === "string") {
boundedString(value, path, { allowEmpty: true }); boundedString(value, path, { allowEmpty: true });
budget.remainingStringUnits -= value.length; budget.remainingStringUnits -= value.length;
@@ -205,10 +214,10 @@ export function bufferToBase64Url(value: ArrayBuffer): string {
function parseAuthenticatorTransport( function parseAuthenticatorTransport(
value: unknown, value: unknown,
path: string, path: string,
): AuthenticatorTransport { ): AuthenticatorTransport | null {
const transport = boundedString(value, path); const transport = boundedString(value, path);
if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) { if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) {
invalid(path, "contains an unknown authenticator transport"); return null;
} }
return transport as AuthenticatorTransport; return transport as AuthenticatorTransport;
} }
@@ -229,9 +238,10 @@ function parseCredentialDescriptor(
if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) { if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) {
invalid(`${path}.transports`, "has too many items"); invalid(`${path}.transports`, "has too many items");
} }
return descriptor.transports.map((transport, index) => return descriptor.transports.flatMap((transport, index) => {
parseAuthenticatorTransport(transport, `${path}.transports[${index}]`) const parsed = parseAuthenticatorTransport(transport, `${path}.transports[${index}]`);
); return parsed === null ? [] : [parsed];
});
})(); })();
return { return {
type: "public-key", type: "public-key",
@@ -463,7 +473,8 @@ function parseActor(value: unknown, path: string): RequestActor {
export function parseWhoamiResponse(value: unknown): WhoamiResponse { export function parseWhoamiResponse(value: unknown): WhoamiResponse {
const response = asRecord(value, "whoami"); const response = asRecord(value, "whoami");
requireExactKeys(response, "whoami", ["actor", "session"]); requireExactKeys(response, "whoami", ["actor", "session", "authentication_reference"]);
const authentication_reference = optionalString(response.authentication_reference, "whoami.authentication_reference");
const actor = response.actor === null const actor = response.actor === null
? null ? null
: parseActor(response.actor, "whoami.actor"); : parseActor(response.actor, "whoami.actor");
@@ -487,7 +498,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
"must identify the server session expiry", "must identify the server session expiry",
); );
} }
return { actor, session: { kind: "authenticated", expires_at: expiry } }; if (authentication_reference === null) invalid("whoami", "authenticated session requires comparison evidence");
return { actor, authentication_reference, session: { kind: "authenticated", expires_at: expiry } };
} }
requireExactKeys(session, "whoami.session", ["kind"]); requireExactKeys(session, "whoami.session", ["kind"]);
if ( if (
@@ -496,7 +508,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
) { ) {
invalid("whoami", "inconsistent actor or session state"); invalid("whoami", "inconsistent actor or session state");
} }
return { actor: null, session: { kind: session.kind } }; if (authentication_reference !== null) invalid("whoami", "anonymous session cannot identify authentication");
return { actor: null, authentication_reference: null, session: { kind: session.kind } };
} }
export function parseAuthPublicConfig(value: unknown): AuthPublicConfig { export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
@@ -537,10 +550,38 @@ export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
return result; return result;
} }
export function parseAuthUser(value: unknown): AuthUser {
return parseAuthenticatedUser(value, "auth_user.user");
}
export function parseAuthUserResponse(value: unknown): PasskeyUserResponse { export function parseAuthUserResponse(value: unknown): PasskeyUserResponse {
const response = asRecord(value, "auth_user"); const response = asRecord(value, "auth_user");
requireExactKeys(response, "auth_user", ["user"]); requireExactKeys(response, "auth_user", ["user", "authentication_reference"]);
return { user: parseAuthenticatedUser(response.user, "auth_user.user") }; return { user: parseAuthenticatedUser(response.user, "auth_user.user"), authentication_reference: boundedString(response.authentication_reference, "auth_user.authentication_reference") };
}
function parsePasskeySummary(value: unknown): PasskeySummary {
const key = asRecord(value, "passkey_summary");
requireExactKeys(key, "passkey_summary", ["key_reference", "created_at", "last_used_at", "normal_login_verified_at"]);
const date = (value: unknown): string => {
const text = boundedString(value, "passkey_summary.timestamp");
if (!Number.isFinite(Date.parse(text))) invalid("passkey_summary.timestamp", "must be a timestamp");
return text;
};
return { key_reference: boundedString(key.key_reference, "passkey_summary.key_reference"), created_at: date(key.created_at), last_used_at: key.last_used_at === null ? null : date(key.last_used_at), normal_login_verified_at: key.normal_login_verified_at === null ? null : date(key.normal_login_verified_at) };
}
export function parsePasskeyList(value: unknown): PasskeyListResponse {
const list = asRecord(value, "passkey_list");
requireExactKeys(list, "passkey_list", ["user", "keys"]);
if (!Array.isArray(list.keys) || list.keys.length > MAX_AUTH_ARRAY_LENGTH) invalid("passkey_list.keys", "must be a bounded array");
return { user: parseAuthenticatedUser(list.user, "passkey_list.user"), keys: list.keys.map(parsePasskeySummary) };
}
export function parseAdditionResult(value: unknown): PasskeyAdditionResultResponse {
const result = asRecord(value, "addition_result");
if (result.status === "unknown") { requireExactKeys(result, "addition_result", ["status"]); return { status: "unknown" }; }
requireExactKeys(result, "addition_result", ["status", "user", "key"]);
if (result.status !== "saved") invalid("addition_result", "contains an unknown status");
return { status: "saved", user: parseAuthenticatedUser(result.user, "addition_result.user"), key: parsePasskeySummary(result.key) };
} }
export function prepareRegistrationOptions( export function prepareRegistrationOptions(
@@ -583,11 +624,14 @@ function clientExtensionResults(
const value = (credential.getClientExtensionResults as () => unknown).call( const value = (credential.getClientExtensionResults as () => unknown).call(
credential, credential,
); );
boundedJson(value, "credential.clientExtensionResults"); const output = asRecord(value, "credential.clientExtensionResults");
return asRecord( // 現Serverが消費するJSON拡張だけを転送する。PRF/largeBlob等の未要求binary
value, // outputsをJSON.stringifyで{}へ変えたり、未使用機能として有効化したりしない。
"credential.clientExtensionResults", const supported = Object.fromEntries(
) as AuthenticationExtensionsClientOutputs; ["appid", "credProps", "hmacSecret", "credProtect", "minPinLength"].filter(key => key in output).map(key => [key, output[key]]),
);
boundedJson(supported, "credential.clientExtensionResults");
return supported as AuthenticationExtensionsClientOutputs;
} }
export function registrationCredentialToJson(credential: unknown): unknown { export function registrationCredentialToJson(credential: unknown): unknown {
@@ -605,12 +649,12 @@ export function registrationCredentialToJson(credential: unknown): unknown {
) { ) {
invalid("credential.response.transports", "must be a bounded array"); invalid("credential.response.transports", "must be a bounded array");
} }
const transports = transportsValue.map((transport, index) => // transportは補助hint。Serverのwebauthn-rsも未知値をUnknownとして無視する。
parseAuthenticatorTransport( // boundedな新値を理由に、端末上で作成済みの暗号credential全体を捨てない。
transport, const transports = transportsValue.flatMap((value, index) => {
`credential.response.transports[${index}]`, const transport = boundedString(value, `credential.response.transports[${index}]`);
) return ["ble", "hybrid", "internal", "nfc", "usb"].includes(transport) ? [transport] : [];
); });
const authenticatorAttachment = optionalString( const authenticatorAttachment = optionalString(
core.authenticatorAttachment, core.authenticatorAttachment,
"credential.authenticatorAttachment", "credential.authenticatorAttachment",
@@ -0,0 +1,140 @@
import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
import type { WhoamiResponse } from "./model.ts";
export type AuthStage =
| "session-check"
| "options"
| "authenticator"
| "conversion"
| "before-complete"
| "complete"
| "confirmation";
export type AuthFailure =
| "cancelled-or-unavailable"
| "excluded"
| "unsupported"
| "environment"
| "conversion"
| "session-changed"
| "rejected"
| "transport"
| "invalid-response"
| "stopped";
export type AuthOutcome =
| "not-sent"
| "rejected"
| "unknown"
| "saved-session-unconfirmed";
// 診断は固定分類だけを持つ。cause、例外本文、HTTP body、credentialやceremony IDを保持しない。
export class AuthOperationError extends Error {
constructor(
readonly stage: AuthStage,
readonly reason: AuthFailure,
readonly outcome: AuthOutcome,
readonly httpStatus?: number,
) {
super(`Passkey ${stage}: ${reason} (${outcome}).`);
}
}
type Observer = {
phase: (stage: AuthStage) => void;
verifySession: () => Promise<void>;
expected: WhoamiResponse | null;
additionAttempt: (attempt: PasskeyAdditionResultRequest) => void;
};
const observers = new WeakMap<AbortSignal, Observer>();
export function observeAuthOperation(
signal: AbortSignal,
observer: Observer,
): void {
observers.set(signal, observer);
}
export function recordAdditionAttempt(
signal: AbortSignal | undefined,
attempt: PasskeyAdditionResultRequest,
): void {
if (signal) observers.get(signal)?.additionAttempt(attempt);
}
export function expectedAuthSession(
signal?: AbortSignal,
): WhoamiResponse | null {
return signal ? observers.get(signal)?.expected ?? null : null;
}
export async function authCheckpoint(
signal: AbortSignal | undefined,
stage: AuthStage,
): Promise<void> {
signal?.throwIfAborted();
const observer = signal ? observers.get(signal) : undefined;
observer?.phase(stage);
if (stage === "options" || stage === "before-complete") {
await observer?.verifySession();
}
signal?.throwIfAborted();
}
export function classifyAuthFailure(
error: unknown,
stage: AuthStage,
sent: boolean,
): AuthOperationError {
if (error instanceof AuthOperationError) return error;
const outcome = sent ? "unknown" : "not-sent";
if (
error instanceof DOMException &&
(stage === "authenticator" || error.name === "AbortError")
) {
switch (error.name) {
case "AbortError":
return new AuthOperationError(stage, "stopped", outcome);
case "InvalidStateError":
return new AuthOperationError(stage, "excluded", outcome);
case "NotAllowedError":
return new AuthOperationError(
stage,
"cancelled-or-unavailable",
outcome,
);
case "NotSupportedError":
return new AuthOperationError(stage, "unsupported", outcome);
case "SecurityError":
return new AuthOperationError(stage, "environment", outcome);
}
}
return new AuthOperationError(
stage,
stage === "conversion" ? "conversion" : "transport",
outcome,
);
}
export function operationErrorMessage(error: AuthOperationError): string {
const reason: Record<AuthFailure, string> = {
"cancelled-or-unavailable":
"The browser did not complete verification. It may have been cancelled, timed out, or found no usable key; the browser does not distinguish these reasons.",
excluded:
"The authenticator reported an existing key (duplicate exclusion). Keep the older key and choose a different device, security key, or passkey provider; do not delete your only working key.",
unsupported:
"This browser or key cannot complete this passkey operation. Use a supported device that can save passkeys and verify with a PIN or biometrics.",
environment:
"The browser blocked this operation because of an origin, security, or environment constraint. Check that you are using the intended secure site; the exact cause is not known.",
conversion:
"The device may have created a key, but its response could not be converted for the server. Registration was not sent. Keep existing keys; a device-side entry alone does not mean it was saved to this account.",
"session-changed":
"The account or session changed, or could not be confirmed. The result cannot be applied to the previously displayed account.",
rejected:
"The server rejected the request or verification. A challenge may be invalid or expired. Check your account and the operation result before starting a new ceremony.",
transport: "Communication could not be confirmed.",
"invalid-response": "The server response could not be confirmed.",
stopped: "Operation stopped.",
};
const outcome = error.outcome === "not-sent"
? "Completion was not sent."
: error.outcome === "rejected"
? "The server did not accept completion."
: error.outcome === "saved-session-unconfirmed"
? "The server confirmed the operation, but the expected browser session is not confirmed."
: "Completion may already have reached the server. Its result is unknown; check the operation result before adding another key. Stopping the browser does not undo a submitted request.";
return `${reason[error.reason]} ${outcome} [${error.stage}/${error.reason}${
error.httpStatus ? `/HTTP-${error.httpStatus}` : ""
}]`;
}
@@ -59,7 +59,7 @@ for (const kind of ["anonymous", "expired"] as const) {
await browserSession.ensure(); await browserSession.ensure();
expect(fetcher).toHaveBeenCalledTimes(1); expect(fetcher).toHaveBeenCalledTimes(1);
fetcher.mockResolvedValueOnce( fetcher.mockResolvedValueOnce(
Response.json({ actor: null, session: { kind } }), Response.json({ authentication_reference: null, actor: null, session: { kind } }),
); );
await expect(requirePageSession(target)).rejects.toMatchObject({ await expect(requirePageSession(target)).rejects.toMatchObject({
status: 307, status: 307,
@@ -0,0 +1,624 @@
// @vitest-environment happy-dom
// BrowserAuth→本番adapterの競合/結果判定。HTTPと認証器のみをgate付きで代替し、
// Server検証/保存の根拠にはしない。秘密の本文は診断へ保存しない。
import { afterEach, expect, test, vi } from "vitest";
import { BrowserAuth } from "./browser-session.svelte.ts";
import { browserSession, sessionMutation } from "./session-state.ts";
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
import {
authUser,
credentialFixture,
loginOptions,
registrationOptions,
} from "./api.test-fixtures.ts";
const owners: BrowserAuth[] = [];
afterEach(() => {
for (const owner of owners.splice(0)) owner.dispose();
browserSession.invalidate();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function identity(user: typeof authUser | null) {
return Response.json({
authentication_reference: user ? "fixture-authentication" : null,
actor: user ? { ...user, auth_method: "browser_session" } : null,
session: user
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
});
}
function harness() {
const state = { user: authUser as typeof authUser | null, completed: false };
const create = vi.fn<CredentialsContainer["create"]>().mockResolvedValue(
credentialFixture(),
);
const get = vi.fn<CredentialsContainer["get"]>().mockResolvedValue(
credentialFixture(true),
);
vi.stubGlobal("navigator", { credentials: { create, get } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") return identity(state.user);
if (path.endsWith("/options")) {
return Response.json(
path.includes("login") ? loginOptions : registrationOptions,
);
}
if (path.endsWith("/complete")) {
state.completed = true;
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/logout") {
state.user = null;
return Response.json({ status: "logged_out" });
}
throw new Error("Unexpected synthetic endpoint");
});
vi.stubGlobal("fetch", fetcher);
const auth = new BrowserAuth();
owners.push(auth);
return { state, create, get, fetcher, auth };
}
function savedAddition() {
return Response.json({
status: "saved",
user: authUser,
key: {
key_reference: "safe-opaque-reference",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
},
});
}
async function recoveryHarness() {
const context = harness();
await context.auth.refresh();
context.auth.additionAttempt = {
challenge_id: "synthetic-addition",
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference: "original-operation-reference",
};
return context;
}
for (const phase of ["initial-whoami", "receipt", "final-whoami"] as const) {
for (const reason of ["logout", "authentication"] as const) {
test(`recovery ${phase} rejects a shared ${reason} stamp before event delivery`, async () => {
const { auth, fetcher } = await recoveryHarness();
const started = gate<void>(), response = gate<Response>();
let whoami = 0, receiptReads = 0;
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") {
++whoami;
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
started.resolve();
return response.promise;
}
return identity(authUser);
}
if (path.endsWith("/addition/result")) {
++receiptReads;
expect(
JSON.parse(String(init?.body)).expected_authentication_reference,
)
.toBe("original-operation-reference");
if (phase === "receipt") {
started.resolve();
return response.promise;
}
return savedAddition();
}
throw new Error("Unexpected recovery endpoint");
});
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
const checking = auth.checkAddition();
await started.promise;
await auth.checkAddition(); // busy recovery cannot issue a second read
const changed = JSON.stringify({
id: `recovery-${phase}-${reason}`,
reason,
});
localStorage.setItem("yoi.browser-auth-generation", changed);
expect(browserSession.authenticationStamp).not.toBe(stamp);
expect(browserSession.authenticationGeneration).toBe(generation);
response.resolve(
phase === "receipt" ? savedAddition() : identity(authUser),
);
await checking;
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt?.expected_authentication_reference)
.toBe("original-operation-reference");
expect(auth.status).not.toContain("current session was checked");
expect(auth.error).toContain("does not prove failure");
expect(receiptReads).toBe(phase === "initial-whoami" ? 0 : 1);
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
)
.toBe(false);
});
}
}
for (const phase of ["receipt", "final-whoami"] as const) {
for (const invalidation of ["cancel", "dispose", "generation"] as const) {
test(`recovery ${phase} ignores a delayed saved response after ${invalidation}`, async () => {
const { auth, fetcher } = await recoveryHarness();
const started = gate<void>(), response = gate<Response>();
let whoami = 0;
fetcher.mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") {
if (++whoami === 2 && phase === "final-whoami") {
started.resolve();
return response.promise;
}
return identity(authUser);
}
if (path.endsWith("/addition/result")) {
if (phase === "receipt") {
started.resolve();
return response.promise;
}
return savedAddition();
}
throw new Error("Unexpected recovery endpoint");
});
const checking = auth.checkAddition();
await started.promise;
if (invalidation === "cancel") auth.cancel();
else if (invalidation === "dispose") auth.dispose();
else sessionMutation();
response.resolve(
phase === "receipt" ? savedAddition() : identity(authUser),
);
await checking;
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.status).not.toContain("current session was checked");
expect(whoami).toBe(phase === "receipt" ? 1 : 2);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
)
.toBe(false);
});
}
}
test("failed recheck cannot retain repeat-add permission from an older saved confirmation", async () => {
const { auth, fetcher } = await recoveryHarness();
auth.additionSaved = true;
fetcher.mockResolvedValue(identity(null));
await auth.checkAddition();
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.error).toContain("does not prove failure");
});
test("unknown receipt retains the unresolved attempt and never permits blind repetition", async () => {
const { auth, fetcher } = await recoveryHarness();
fetcher.mockImplementation(async (input) =>
String(input).endsWith("/addition/result")
? Response.json({ status: "unknown" })
: identity(authUser)
);
await auth.checkAddition();
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.status).toContain("still unknown");
expect(auth.error).toBe("");
});
for (const outcome of ["anonymous", "other-actor", "unavailable"] as const) {
test(`addition response is not Added when confirming whoami is ${outcome}`, async () => {
const { auth, state, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
if (state.completed && String(input) === "/api/auth/whoami") {
if (outcome === "unavailable") {
throw new TypeError("private network detail");
}
return identity(
outcome === "anonymous" ? null : {
...authUser,
user_id: "bob-user",
account_id: "bob-account",
handle: "bob",
},
);
}
return await request(input, init);
});
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.status).not.toBe("Added");
expect(auth.error).not.toContain("private network detail");
});
}
for (const invalidation of ["logout", "dispose", "account-change"] as const) {
test(`${invalidation} during native prompt never posts a delayed login credential`, async () => {
const { auth, state, get, fetcher } = harness();
state.user = null;
await auth.refresh();
const prompted = gate<void>();
const credential = gate<Credential | null>();
get.mockImplementation(() => {
prompted.resolve();
return credential.promise;
});
const run = auth.run("Waiting", "Logged in", loginWithPasskey);
await prompted.promise;
if (invalidation === "dispose") auth.dispose();
else if (invalidation === "account-change") sessionMutation();
else {
const signout = new BrowserAuth();
owners.push(signout);
await signout.run("Logging out", "Logged out", logout, "logout");
}
credential.resolve(credentialFixture(true));
expect(await run).toBe(false);
expect(
fetcher.mock.calls.some(([path]) =>
String(path).endsWith("/login/complete")
),
).toBe(false);
expect(state.completed).toBe(false);
});
}
const bob = {
...authUser,
user_id: "bob-user",
account_id: "bob-account",
handle: "bob",
};
for (const changeAt of ["start", "before-complete"] as const) {
test(`stale Alice with Bob cookie at ${changeAt} cannot add to Bob`, async () => {
const { auth, state, create, fetcher } = harness();
await auth.refresh();
if (changeAt === "start") state.user = bob;
else {create.mockImplementationOnce(async () => {
state.user = bob;
return credentialFixture();
});}
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(state.completed).toBe(false);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
if (changeAt === "start") expect(create).not.toHaveBeenCalled();
expect(auth.error).toContain("session-changed");
});
}
test("same owner but different authentication binding before complete is not the expected session", async () => {
const { auth, create, fetcher } = harness();
await auth.refresh();
let changed = false;
create.mockImplementationOnce(async () => {
changed = true;
return credentialFixture();
});
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami" && changed
? Response.json({
...(await identity(authUser).json()),
authentication_reference: "different-binding",
})
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
});
test("returned owner alone does not prove the issued browser session", async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input).endsWith("/complete")
? Response.json({
user: authUser,
authentication_reference: "issued-but-not-current",
})
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).toContain("expected browser session is not confirmed");
});
for (
const reason of [
"InvalidStateError",
"NotAllowedError",
"SecurityError",
"NotSupportedError",
] as const
) {
test(`native ${reason} has a safe stage-specific classification without raw exceptions`, async () => {
const { auth, create } = harness();
await auth.refresh();
create.mockRejectedValueOnce(
new DOMException("RAW-CREDENTIAL-TOKEN-COOKIE", reason),
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).not.toContain("RAW-CREDENTIAL-TOKEN-COOKIE");
expect(auth.error).toContain("authenticator/");
expect(auth.error).toContain("Completion was not sent");
if (reason === "InvalidStateError") {
expect(auth.error).toContain("duplicate exclusion");
}
if (reason === "NotAllowedError") {
expect(auth.error).toContain("does not distinguish");
}
});
}
test("device creation followed by conversion failure never sends complete or claims registration", async () => {
const { auth, create, fetcher } = harness();
await auth.refresh();
create.mockResolvedValueOnce(
{
...credentialFixture(),
rawId: "private-invalid-value",
} as unknown as Credential,
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).toContain("conversion");
expect(auth.error).toContain("device may have created");
expect(auth.error).not.toContain("private-invalid-value");
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
});
test("duplicate operations are fenced and explicit cancellation stops late credentials before submission", async () => {
const { auth, create, fetcher } = harness();
await auth.refresh();
const started = gate<void>(), credential = gate<Credential | null>();
create.mockImplementationOnce(() => {
started.resolve();
return credential.promise;
});
const first = auth.run("Waiting", "Added", addPasskey);
await started.promise;
const duplicate = vi.fn(async () => ({}));
expect(await auth.run("Duplicate", "Wrong", duplicate)).toBe(false);
expect(duplicate).not.toHaveBeenCalled();
auth.cancel();
credential.resolve(credentialFixture());
expect(await first).toBe(false);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
});
test("abort after complete dispatch remains an unknown submitted addition with a recoverable operation", async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const started = gate<void>(), response = gate<Response>();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input).endsWith("/complete")) {
started.resolve();
return response.promise;
}
return request(input, init);
});
const run = auth.run("Waiting", "Added", addPasskey);
await started.promise;
auth.cancel();
response.resolve(
Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
}),
);
expect(await run).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.error).toContain("result is unknown");
expect(auth.error).not.toContain("Completion was not sent");
});
for (
const current of [
"original-session",
"cookie-updated",
"anonymous",
"bob",
"unavailable",
] as const
) {
test(`lost completion body with ${current} distinguishes saved, session evidence and unavailable authority`, async () => {
const { auth, state, fetcher } = harness();
await auth.refresh();
let dispatched = false, resultReads = 0;
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path.endsWith("/complete")) {
dispatched = true;
throw new TypeError("RAW-HTTP-BODY-TOKEN");
}
if (path === "/api/auth/whoami" && dispatched) {
if (current === "unavailable") throw new TypeError("RAW-WHOAMI-ERROR");
if (current === "anonymous") return identity(null);
if (current === "bob") return identity(bob);
if (current === "cookie-updated") {
return Response.json({
...(await identity(authUser).json()),
authentication_reference: "new-live-session",
});
}
}
if (path.endsWith("/addition/result")) {
++resultReads;
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
.toBe("fixture-authentication");
return Response.json({
status: "saved",
user: authUser,
key: {
key_reference: "safe-opaque-reference",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
},
});
}
return request(input, init);
});
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.error).not.toContain("RAW-");
expect(auth.error).toContain("result is unknown");
await auth.checkAddition();
if (current === "original-session" || current === "cookie-updated") {
expect(auth.additionSaved).toBe(true);
expect(auth.status).toContain("saved a key");
expect(auth.status).toContain(
"session issued by completion is not confirmed",
);
expect(resultReads).toBe(1);
} else {
expect(auth.additionSaved).toBe(false);
expect(resultReads).toBe(0);
expect(auth.error).toContain("does not prove failure");
}
expect(state.completed).toBe(false); // synthetic boundary never acts as the real store
});
}
test("storage mutation arriving before the storage event fences prompt completion even if whoami stays anonymous", async () => {
const { auth, state, get, fetcher } = harness();
state.user = null;
await auth.refresh();
get.mockImplementationOnce(async () => {
localStorage.setItem(
"yoi.browser-auth-generation",
JSON.stringify({ id: "another-tab", reason: "logout" }),
);
return credentialFixture(true);
});
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
expect(
fetcher.mock.calls.some(([path]) =>
String(path).endsWith("/login/complete")
),
).toBe(false);
});
for (const phase of ["options", "complete"] as const) {
test(`malformed ${phase} response records the stage without leaking body or inventing completion`, async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input).endsWith(`/${phase}`)
? Promise.resolve(
new Response("RAW-CHALLENGE-COOKIE", {
headers: { "content-type": "application/json" },
}),
)
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).toContain(`${phase}/invalid-response`);
expect(auth.error).not.toContain("RAW-CHALLENGE-COOKIE");
expect(auth.additionAttempt === null).toBe(phase === "options");
if (phase === "options") {
expect(auth.error).toContain("Completion was not sent");
} else expect(auth.error).toContain("result is unknown");
});
}
for (
const code of [
"passkey_authentication_changed",
"webauthn_registration_verification_failed",
] as const
) {
test(`HTTP rejection classifies allowlisted ${code} without retaining HTTP body`, async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input).endsWith("/complete")
? Promise.resolve(
Response.json({
message: `${code}: RAW-EXCEPTION-BODY`,
secret: "RAW-TOKEN",
}, { status: 401 }),
)
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).not.toContain("RAW-");
expect(auth.error).toContain(
code === "passkey_authentication_changed"
? "session-changed"
: "rejected",
);
expect(auth.error).toContain("HTTP-401");
expect(auth.error).not.toContain("Completion was not sent");
});
}
test("successful logout never marks an earlier unknown addition as saved", async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input).endsWith("/complete")
? Promise.reject(new TypeError("response lost"))
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.additionSaved).toBe(false);
expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe(
true,
);
expect(auth.additionSaved).toBe(false);
});
test("explicit login from stale expired display remains allowed after another tab cleared the cookie", async () => {
const { auth, state, fetcher } = harness();
state.user = null;
fetcher.mockResolvedValueOnce(
Response.json({
actor: null,
authentication_reference: null,
session: { kind: "expired" },
}),
);
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
if (String(input).endsWith("/complete")) state.user = authUser;
return await request(input, init);
});
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(true);
expect(auth.status).toBe("Logged in");
});
test("explicit login from expired display does not replace another tab's authenticated account", async () => {
const { auth, state, fetcher, get } = harness();
fetcher.mockResolvedValueOnce(
Response.json({
actor: null,
authentication_reference: null,
session: { kind: "expired" },
}),
);
await auth.refresh();
state.user = bob;
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
expect(get).not.toHaveBeenCalled();
expect(state.completed).toBe(false);
});
@@ -10,7 +10,22 @@ function assertEquals(
); );
} }
} }
import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts"; import {
authEntryUrl,
isAuthPage,
isPublicPage,
loginUrl,
safeReturnTo,
} from "./routes.ts";
Deno.test("account uses the authenticated app shell, not the standalone auth entry shell", () => {
assertEquals(String(isAuthPage("/account")), "false");
assertEquals(String(isPublicPage("/account")), "false");
for (const path of ["/login", "/register", "/activate", "/login/device"]) {
assertEquals(String(isAuthPage(path)), "true", path);
assertEquals(String(isPublicPage(path)), "true", path);
}
});
Deno.test("return destination preserves query and fragment only on known app screens", () => { Deno.test("return destination preserves query and fragment only on known app screens", () => {
for ( for (
@@ -80,8 +80,9 @@ export function isPublicPage(path: string): boolean {
.includes(path); .includes(path);
} }
// 認証入口は独立したshellを使う。本人用/accountは通常のナビゲーションを残す。
export function isAuthPage(path: string): boolean { export function isAuthPage(path: string): boolean {
return ["/login", "/register", "/account", "/activate", "/login/device"] return ["/login", "/register", "/activate", "/login/device"]
.includes(path); .includes(path);
} }
@@ -43,7 +43,7 @@ test("active expired state removes private content and preserves browser query a
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async () => vi.fn(async () =>
Response.json({ actor: null, session: { kind: "expired" } }) Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
), ),
); );
await browserSession.refresh(); await browserSession.refresh();
@@ -62,7 +62,7 @@ test("navigation admission does not redirect to the old page return URL", async
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async () => vi.fn(async () =>
Response.json({ actor: null, session: { kind: "expired" } }) Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
), ),
); );
await browserSession.refresh(); await browserSession.refresh();
@@ -13,13 +13,14 @@ afterEach(() => {
localStorage.clear(); localStorage.clear();
}); });
const anonymous: WhoamiResponse = { const anonymous: WhoamiResponse = {
actor: null, actor: null, authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}; };
const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } }; const expired: WhoamiResponse = { actor: null, authentication_reference: null, session: { kind: "expired" } };
const authenticated = ( const authenticated = (
expires_at: string | null = "2099-01-01T00:00:00Z", expires_at: string | null = "2099-01-01T00:00:00Z",
): WhoamiResponse => ({ ): WhoamiResponse => ({
authentication_reference: "fixture-authentication",
actor: { ...authUser, auth_method: "browser_session" }, actor: { ...authUser, auth_method: "browser_session" },
session: { kind: "authenticated", expires_at }, session: { kind: "authenticated", expires_at },
}); });
@@ -17,6 +17,10 @@ export class SessionState {
private listeners = new Set<(state: SessionSnapshot) => void>(); private listeners = new Set<(state: SessionSnapshot) => void>();
private stale = true; private stale = true;
get authenticationStamp(): string | null {
return storageStamp();
}
get authenticationGeneration(): number { get authenticationGeneration(): number {
return this.generation; return this.generation;
} }
@@ -38,7 +42,7 @@ export class SessionState {
++this.generation; ++this.generation;
this.pending = null; this.pending = null;
this.stale = true; this.stale = true;
if (!preserveSnapshot) this.publish({ kind: "loading" }); this.publish(preserveSnapshot ? this.snapshot : { kind: "loading" });
} }
// Page load owns freshness. Components mounted after it reuse that result. // Page load owns freshness. Components mounted after it reuse that result.
@@ -100,8 +104,9 @@ export const browserSession = new SessionState();
export function sessionMutation( export function sessionMutation(
reason: "authentication" | "logout" = "authentication", reason: "authentication" | "logout" = "authentication",
preserveSnapshot = false,
): void { ): void {
browserSession.invalidate(); browserSession.invalidate(preserveSnapshot);
const message = JSON.stringify({ id: crypto.randomUUID(), reason }); const message = JSON.stringify({ id: crypto.randomUUID(), reason });
try { try {
localStorage.setItem(GENERATION_KEY, message); localStorage.setItem(GENERATION_KEY, message);
@@ -35,6 +35,7 @@ function session() {
function whoami(authenticated: boolean) { function whoami(authenticated: boolean) {
return Response.json({ return Response.json({
authentication_reference: authenticated ? "fixture-authentication" : null,
session: authenticated session: authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -75,7 +76,10 @@ function boundary(authenticated = false) {
return new Response(null, { status: state.completeStatus }); return new Response(null, { status: state.completeStatus });
} }
state.authenticated = true; state.authenticated = true;
return Response.json({ user: authUser }); return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
} }
throw new Error(`Unexpected API: ${path}`); throw new Error(`Unexpected API: ${path}`);
}); });
@@ -106,6 +110,7 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut
expect(auth.session).toEqual({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
}); });
@@ -162,6 +167,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
expect(auth.session).toEqual({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
oldResponse.resolve(whoami(true)); oldResponse.resolve(whoami(true));
@@ -169,6 +175,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
expect(auth.session).toEqual({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
expect(auth.status).toBe("Logged out"); expect(auth.status).toBe("Logged out");
@@ -183,26 +190,34 @@ test("busy login rejects duplicate operations and refresh until its post-complet
const confirmingStarted = gate<void>(); const confirmingStarted = gate<void>();
const request = fetcher.getMockImplementation()!; const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => { fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") { if (String(input) === "/api/auth/passkeys/login/options") {
return options.promise;
}
if (
String(input) === "/api/auth/whoami" &&
paths(fetcher).includes("/api/auth/passkeys/login/complete")
) {
confirmingStarted.resolve(); confirmingStarted.resolve();
return confirming.promise; return confirming.promise;
} }
return request(input, init); return request(input, init);
}); });
fetcher.mockReturnValueOnce(options.promise);
const run = auth.run( const run = auth.run(
"Waiting", "Waiting",
"Logged in", "Logged in",
(signal) => loginWithPasskey(signal), (signal) => loginWithPasskey(signal),
); );
expect(auth.busy).toBe(true); expect(auth.busy).toBe(true);
expect(auth.status).toBe("Waiting"); expect(auth.status).toMatch(/Checking the account/);
const duplicate = vi.fn(async () => {}); const duplicate = vi.fn(async () => {});
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false); expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh(); await auth.refresh();
expect(duplicate).not.toHaveBeenCalled(); expect(duplicate).not.toHaveBeenCalled();
expect(paths(fetcher)).toEqual([ // Preflight is asynchronous; wait for the options stage, not an HTTP ordinal.
"/api/auth/whoami", await vi.waitFor(() =>
expect(paths(fetcher)).toContain("/api/auth/passkeys/login/options")
);
expect(paths(fetcher).filter((path) => path !== "/api/auth/whoami")).toEqual([
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
]); ]);
options.resolve(Response.json(loginOptions)); options.resolve(Response.json(loginOptions));
@@ -212,15 +227,17 @@ test("busy login rejects duplicate operations and refresh until its post-complet
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false); expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh(); await auth.refresh();
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami")) expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2); .toHaveLength(4);
confirming.resolve(whoami(true)); confirming.resolve(whoami(true));
expect(await run).toBe(true); expect(await run).toBe(true);
expect(auth.busy).toBe(false); expect(auth.busy).toBe(false);
expect(auth.actor?.handle).toBe("alice"); expect(auth.actor?.handle).toBe("alice");
expect(auth.status).toBe("Logged in"); expect(auth.status).toBe("Logged in");
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/complete", "/api/auth/passkeys/login/complete",
"/api/auth/whoami", "/api/auth/whoami",
]); ]);
@@ -236,16 +253,16 @@ for (const status of [400, 401, 410]) {
expect(result).toBe(false); expect(result).toBe(false);
expect(auth.busy).toBe(false); expect(auth.busy).toBe(false);
expect(auth.status).toBe(""); expect(auth.status).toBe("");
expect(auth.error).toMatch( expect(auth.error).toMatch(/server rejected the request or verification/);
status === 401 expect(auth.error).toContain(`[complete/rejected/HTTP-${status}]`);
? /Authentication request was rejected/ expect(auth.error).toContain("server did not accept completion");
: /The request or authentication challenge is invalid or expired/,
);
expect(auth.session.kind).toBe("ready"); expect(auth.session.kind).toBe("ready");
expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice"); expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice");
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
"/api/auth/whoami",
"/api/auth/passkeys/addition/complete", "/api/auth/passkeys/addition/complete",
"/api/auth/whoami", "/api/auth/whoami",
]); ]);
@@ -278,22 +295,25 @@ for (
).toBe(false); ).toBe(false);
expect(auth.error).toMatch( expect(auth.error).toMatch(
name === "NotAllowedError" name === "NotAllowedError"
? /cancelled or timed out/ ? /cancelled, timed out, or found no usable key/
: name === "NotSupportedError" : name === "NotSupportedError"
? /Completion is not confirmed/ ? /supported device/
: name === "AbortError" : name === "AbortError"
? /Completion is not confirmed/ ? /Operation stopped/
: /Authentication could not be confirmed/, : /Communication could not be confirmed/,
); );
expect(auth.error).toContain("Completion was not sent.");
expect(auth.error).not.toContain("secret credential detail"); expect(auth.error).not.toContain("secret credential detail");
expect(auth.status).toBe(""); expect(auth.status).toBe("");
expect(auth.busy).toBe(false); expect(auth.busy).toBe(false);
expect(auth.session).toEqual({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
"/api/auth/whoami", "/api/auth/whoami",
@@ -314,14 +334,21 @@ test("completion does not confirm authentication when the subsequent whoami fail
const { fetcher } = boundary(); const { fetcher } = boundary();
const auth = session(); const auth = session();
await auth.refresh(); await auth.refresh();
let completed = false;
fetcher.mockImplementation(async (input) => { fetcher.mockImplementation(async (input) => {
const path = String(input); const path = String(input);
if (path === "/api/auth/whoami") return new Response(null, { status: 503 }); if (path === "/api/auth/whoami") {
return completed ? new Response(null, { status: 503 }) : whoami(false);
}
if (path === "/api/auth/passkeys/login/options") { if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions); return Response.json(loginOptions);
} }
if (path === "/api/auth/passkeys/login/complete") { if (path === "/api/auth/passkeys/login/complete") {
return Response.json({ user: authUser }); completed = true;
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
} }
throw new Error(`Unexpected API: ${path}`); throw new Error(`Unexpected API: ${path}`);
}); });
@@ -385,14 +412,15 @@ test("dispose aborts WebAuthn without accepting late credentials, while shared s
expect(auth.session).toEqual({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
expect(auth.status).toBe("Waiting"); expect(auth.status).toBe("Waiting");
expect(auth.error).toBe(""); expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options",
]); ]);
expect( expect(
dispatch.mock.calls.filter(([event]) => dispatch.mock.calls.filter(([event]) =>
@@ -409,6 +437,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not
const started = gate<AbortSignal>(); const started = gate<AbortSignal>();
fetcher.mockImplementation(async (input, init) => { fetcher.mockImplementation(async (input, init) => {
const path = String(input); const path = String(input);
if (path === "/api/auth/whoami") return whoami(false);
if (path === "/api/auth/passkeys/login/options") { if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions); return Response.json(loginOptions);
} }
@@ -427,16 +456,22 @@ test("dispose ignores a completion response even when the HTTP boundary does not
const signal = await started.promise; const signal = await started.promise;
auth.dispose(); auth.dispose();
expect(signal.aborted).toBe(true); expect(signal.aborted).toBe(true);
response.resolve(Response.json({ user: authUser })); response.resolve(
Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
}),
);
expect(await run).toBe(false); expect(await run).toBe(false);
expect(auth.actor).toBeNull(); expect(auth.actor).toBeNull();
expect(auth.status).toBe("Waiting"); expect(auth.status).toBe("Confirming the operation with the server…");
expect(auth.error).toBe(""); expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/complete",
]); ]);
expect( expect(
dispatch.mock.calls.filter(([event]) => dispatch.mock.calls.filter(([event]) =>
@@ -451,15 +486,21 @@ test("dispose during the post-operation whoami prevents its late actor from beco
await auth.refresh(); await auth.refresh();
const response = gate<Response>(); const response = gate<Response>();
const started = gate<void>(); const started = gate<void>();
let completed = false;
fetcher.mockImplementation(async (input) => { fetcher.mockImplementation(async (input) => {
const path = String(input); const path = String(input);
if (path === "/api/auth/passkeys/login/options") { if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions); return Response.json(loginOptions);
} }
if (path === "/api/auth/passkeys/login/complete") { if (path === "/api/auth/passkeys/login/complete") {
return Response.json({ user: authUser }); completed = true;
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
} }
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
if (!completed) return whoami(false);
started.resolve(); started.resolve();
return await response.promise; return await response.promise;
} }
@@ -489,7 +530,10 @@ for (const newer of ["logout", "another-actor"] as const) {
const started = gate<void>(); const started = gate<void>();
const request = fetcher.getMockImplementation()!; const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => { fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") { if (
String(input) === "/api/auth/whoami" &&
paths(fetcher).includes("/api/auth/passkeys/login/complete")
) {
started.resolve(); started.resolve();
return confirming.promise; return confirming.promise;
} }
@@ -509,6 +553,7 @@ for (const newer of ["logout", "another-actor"] as const) {
sessionMutation(newer === "logout" ? "logout" : "authentication"); sessionMutation(newer === "logout" ? "logout" : "authentication");
fetcher.mockResolvedValueOnce( fetcher.mockResolvedValueOnce(
newer === "logout" ? whoami(false) : Response.json({ newer === "logout" ? whoami(false) : Response.json({
authentication_reference: "other-authentication",
actor: { actor: {
...authUser, ...authUser,
user_id: "other-user", user_id: "other-user",
@@ -552,7 +597,8 @@ for (const purpose of ["login", "addition"] as const) {
purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal), purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal),
), ),
).toBe(false); ).toBe(false);
expect(auth.error).toMatch(/cancelled or timed out/); expect(auth.error).toMatch(/cancelled, timed out, or found no usable key/);
expect(auth.error).toContain("Completion was not sent.");
expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe( expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe(
false, false,
); );
@@ -3025,6 +3025,128 @@ Deno.test("projectConsole mirrors live TaskCreate and TaskUpdate calls", () => {
}]); }]);
}); });
function taskCallEntry(
callId: string,
name = "TaskCreate",
args: object = { subject: "Inspect tasks", description: "Task detail" },
): Extract<SessionSnapshotEntry, { kind: "tool_call" }> {
return {
kind: "tool_call", entry_id: `entry:${callId}`, timestamp: 1,
provenance: "model_output", derived_from: [],
call_id: callId, name, arguments: JSON.stringify(args),
};
}
function taskLiveEvent(entry: ReturnType<typeof taskCallEntry>): Event {
return { event: "tool_call_done", data: {
id: entry.call_id, name: entry.name, arguments: entry.arguments,
} };
}
function taskCommitEvent(entry: SessionSnapshotEntry): Event {
return { event: "session_entry_committed", data: { entry } };
}
Deno.test("Tasks apply each call once across live, committed and replay events", () => {
const first = taskCallEntry("first");
const second = taskCallEntry("second"); // Identical arguments, distinct call.
for (const committedFirst of [false, true]) {
const projector = createConsoleProjector();
for (const entry of [first, second]) {
const events = [taskLiveEvent(entry), taskCommitEvent(entry)];
if (committedFirst) events.reverse();
const initial = projector.append([{ eventId: `${entry.call_id}:first`, event: events[0] }]);
const expected = initial.tasks;
const reconciled = projector.append([
{ eventId: `${entry.call_id}:second`, event: events[1] },
{ eventId: `${entry.call_id}:replay-live`, event: taskLiveEvent(entry) },
{ eventId: `${entry.call_id}:replay-commit`, event: taskCommitEvent(entry) },
]);
assertEquals(reconciled.tasks, expected);
assertEquals(initial.tasks, expected);
}
const result = projector.snapshot();
assertEquals(result.tasks.map((task) => task.taskid), [1, 2]);
assertEquals(result.taskNextId, 3);
assertEquals(result.lines.filter((line) => line.kind === "tool").length, 2);
}
});
Deno.test("Tasks snapshot and segment rebuild retain call deduplication and reset old state", () => {
const entry = taskCallEntry("retained");
const discarded = taskCallEntry("discarded");
const snapshot = snapshotEvent("/repo") as SnapshotEvent;
snapshot.data.session.entries = [entry];
for (const event of [snapshot, { event: "segment_rotated", data: { session: snapshot.data.session } } satisfies Event]) {
const projector = createConsoleProjector();
projector.append([
{ eventId: "old", event: taskLiveEvent(discarded) },
{ eventId: "live", event: taskLiveEvent(entry) },
{ eventId: "commit", event: taskCommitEvent(entry) },
]);
const rebuilt = projector.append([{ eventId: "rebuild", event }]);
const replayed = projector.append([
{ eventId: "replayed-live", event: taskLiveEvent(entry) },
{ eventId: "replayed-commit", event: taskCommitEvent(entry) },
]);
assertEquals(replayed.tasks, rebuilt.tasks);
assertEquals(replayed.tasks.map((task) => task.taskid), [1]);
assertEquals(replayed.taskNextId, 2);
const fresh = projector.append([{ eventId: "new", event: taskLiveEvent(discarded) }]);
assertEquals(fresh.tasks.map((task) => task.taskid), [1, 2]);
}
});
Deno.test("Tasks late call replay cannot undo updates or resurrect deleted tasks", () => {
const create = taskCallEntry("create");
const start = taskCallEntry("start", "TaskUpdate", { taskid: 1, status: "inprogress" });
const complete = taskCallEntry("complete", "TaskUpdate", { taskid: 1, status: "completed" });
const remove = taskCallEntry("remove", "TaskUpdate", { taskid: 1, status: "deleted" });
const projector = createConsoleProjector();
for (const entry of [create, start, complete]) {
projector.append([{ eventId: entry.call_id, event: taskLiveEvent(entry) }]);
}
const completed = projector.append([{ eventId: "late-start", event: taskCommitEvent(start) }]);
assertEquals(completed.tasks[0].status, "completed");
const deleted = projector.append([
{ eventId: "remove", event: taskLiveEvent(remove) },
{ eventId: "late-create", event: taskCommitEvent(create) },
{ eventId: "late-complete", event: taskCommitEvent(complete) },
]);
assertEquals(deleted.tasks, []);
assertEquals(deleted.taskNextId, 2);
});
Deno.test("Tasks deduplication does not suppress committed tool result corrections", () => {
const entry = taskCallEntry("create");
const result: SessionSnapshotEntry = {
kind: "tool_result", entry_id: "result", timestamp: 2,
provenance: "tool_output", derived_from: [], call_id: entry.call_id,
summary: "Created task", content: "original result", is_error: false, attachments: [],
};
const projection = projectConsole([
{ eventId: "live", event: taskLiveEvent(entry) },
{ eventId: "commit", event: taskCommitEvent(entry) },
{ eventId: "result", event: taskCommitEvent(result) },
{ eventId: "correction", event: taskCommitEvent({ ...result, content: "corrected result" }) },
]);
assertEquals(projection.tasks.length, 1);
assertEquals(projection.lines[0].toolCall?.output, "corrected result");
});
Deno.test("Tasks call identities are scoped to each Worker projection", () => {
const entry = taskCallEntry("shared-call-id");
const worker = { session_id: "child", name: "child", parent_session_id: "parent", kind: "sub_worker" as const };
const projection = projectConsole([
{ eventId: "parent-live", event: taskLiveEvent(entry) },
{ eventId: "child-live", event: { event: "internal_worker", data: { worker, event: taskLiveEvent(entry) } } },
{ eventId: "parent-commit", event: taskCommitEvent(entry) },
{ eventId: "child-commit", event: { event: "internal_worker", data: { worker, event: taskCommitEvent(entry) } } },
]);
assertEquals(projection.tasks.length, 1);
assertEquals(projection.internalWorkers[0].console.tasks, projection.tasks);
});
Deno.test("Internal Worker output stays separate and follows parent stream arrival order", () => { Deno.test("Internal Worker output stays separate and follows parent stream arrival order", () => {
const worker = { const worker = {
session_id: "child-session", session_id: "child-session",
@@ -201,6 +201,8 @@ export type ConsoleProjection = {
lines: ConsoleLine[]; lines: ConsoleLine[];
tasks: ConsoleTask[]; tasks: ConsoleTask[];
taskNextId: number; taskNextId: number;
/** Task calls arrive both live and committed; apply each call only once. */
appliedTaskCallIds: ReadonlySet<string>;
status: string | null; status: string | null;
workerState: WorkerStateSnapshot | null; workerState: WorkerStateSnapshot | null;
compaction: InFlightCompaction | null; compaction: InFlightCompaction | null;
@@ -308,6 +310,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
lines: [], lines: [],
tasks: [], tasks: [],
taskNextId: 1, taskNextId: 1,
appliedTaskCallIds: new Set(),
status: null, status: null,
workerState: null, workerState: null,
compaction: null, compaction: null,
@@ -1153,6 +1156,7 @@ export function applyProtocolEvent(
lines: [...projection.lines], lines: [...projection.lines],
tasks: [...projection.tasks], tasks: [...projection.tasks],
taskNextId: projection.taskNextId, taskNextId: projection.taskNextId,
appliedTaskCallIds: projection.appliedTaskCallIds,
status: projection.status, status: projection.status,
workerState: projection.workerState, workerState: projection.workerState,
compaction: projection.compaction, compaction: projection.compaction,
@@ -1279,7 +1283,7 @@ export function applyProtocolEvent(
argsStream: event.data.arguments, argsStream: event.data.arguments,
state: "running", state: "running",
}); });
applyTaskTool(next, event.data.name, event.data.arguments); applyTaskTool(next, event.data.id, event.data.name, event.data.arguments);
break; break;
case "tool_result": case "tool_result":
attachToolResult(next, envelope.eventId, event.data.id, { attachToolResult(next, envelope.eventId, event.data.id, {
@@ -1326,6 +1330,7 @@ export function applyProtocolEvent(
next.lines = snapshot.lines; next.lines = snapshot.lines;
next.tasks = snapshot.tasks; next.tasks = snapshot.tasks;
next.taskNextId = snapshot.taskNextId; next.taskNextId = snapshot.taskNextId;
next.appliedTaskCallIds = snapshot.appliedTaskCallIds;
appendSnapshotInFlightLines( appendSnapshotInFlightLines(
next, next,
event.data.in_flight?.blocks ?? [], event.data.in_flight?.blocks ?? [],
@@ -1416,6 +1421,7 @@ export function applyProtocolEvent(
next.lines = [...segment.lines, ...retainedErrors, ...retainedCompaction]; next.lines = [...segment.lines, ...retainedErrors, ...retainedCompaction];
next.tasks = segment.tasks; next.tasks = segment.tasks;
next.taskNextId = segment.taskNextId; next.taskNextId = segment.taskNextId;
next.appliedTaskCallIds = segment.appliedTaskCallIds;
next.workerMetadata = metadataWithoutContext(next.workerMetadata); next.workerMetadata = metadataWithoutContext(next.workerMetadata);
break; break;
} }
@@ -2409,9 +2415,15 @@ function usageText(
function applyTaskTool( function applyTaskTool(
projection: ConsoleProjection, projection: ConsoleProjection,
callId: string,
name: string, name: string,
argumentsJson: string, argumentsJson: string,
): void { ): void {
if (name !== "TaskCreate" && name !== "TaskUpdate") return;
if (projection.appliedTaskCallIds.has(callId)) return;
// Fence task mutations, not tool-row reconciliation or corrected results.
// Copy on write so previously returned projections remain unchanged.
projection.appliedTaskCallIds = new Set(projection.appliedTaskCallIds).add(callId);
const state = applyTaskToolCall( const state = applyTaskToolCall(
{ tasks: projection.tasks, nextTaskId: projection.taskNextId }, { tasks: projection.tasks, nextTaskId: projection.taskNextId },
name, name,
@@ -2448,6 +2460,7 @@ function snapshotProjectionFromSession(
lines: [], lines: [],
tasks: [], tasks: [],
taskNextId: 1, taskNextId: 1,
appliedTaskCallIds: new Set(),
status: null, status: null,
workerState: null, workerState: null,
compaction: null, compaction: null,
@@ -2715,10 +2728,11 @@ function applyLoggedItem(
case "tool_call": { case "tool_call": {
const name = stringField(item, "name") ?? "Tool"; const name = stringField(item, "name") ?? "Tool";
const argumentsJson = stringField(item, "arguments") ?? ""; const argumentsJson = stringField(item, "arguments") ?? "";
const callId = stringField(item, "call_id") ?? eventId;
upsertToolCall( upsertToolCall(
projection, projection,
eventId, eventId,
stringField(item, "call_id") ?? eventId, callId,
{ {
name, name,
arguments: argumentsJson, arguments: argumentsJson,
@@ -2726,7 +2740,7 @@ function applyLoggedItem(
state: "running", state: "running",
}, },
); );
applyTaskTool(projection, name, argumentsJson); applyTaskTool(projection, callId, name, argumentsJson);
break; break;
} }
case "tool_result": case "tool_result":
@@ -1344,7 +1344,7 @@ Deno.test("Root chrome owns global navigation and scoped override slots", async
globalNavSections.includes("workspace.display_name") && globalNavSections.includes("workspace.display_name") &&
globalNavSections.includes("workspaceHref(workspace.workspace_id)") && globalNavSections.includes("workspaceHref(workspace.workspace_id)") &&
rootLayout.includes("observeDeferred(() => data.deferredCatalog") && rootLayout.includes("observeDeferred(() => data.deferredCatalog") &&
rootLayout.includes("workspaces={catalog.data?.accessibleWorkspaces ?? null}") && rootLayout.includes("workspaces={page.url.pathname === '/' ? null : catalog.data?.accessibleWorkspaces ?? null}") &&
rootLayout.includes("workspaceError={catalog.data?.workspaceCatalogError}") && rootLayout.includes("workspaceError={catalog.data?.workspaceCatalogError}") &&
rootLayoutLoad.includes("deferredCatalog: settle(") && rootLayoutLoad.includes("deferredCatalog: settle(") &&
rootLayoutLoad.includes("listWorkspaces(fetch)") && rootLayoutLoad.includes("listWorkspaces(fetch)") &&
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { WorkspaceCatalogRecord } from "#lib/workspace/api/workspace-catalog.ts"; import type { WorkspaceCatalogRecord } from "#lib/workspace/api/workspace-catalog.ts";
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { loadWhoami } from "#lib/workspace/auth/api.ts"; import { browserSession } from '#lib/workspace/auth/session-state.ts';
import { loadHostingAccess, SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts"; import { loadHostingAccess, SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
let mounted = $state(false); let mounted = $state(false);
@@ -12,8 +12,9 @@
const request = ++accessRequest; const request = ++accessRequest;
isAdmin = false; isAdmin = false;
try { try {
const whoami = await loadWhoami(); // ページが確認したsessionを共有し、Sidebar追加でwhoamiの再取得を増やさない。
if (request !== accessRequest || !whoami.actor) return; const session = browserSession.snapshot;
if (session.kind !== 'ready' || !session.value.actor) return;
const access = await loadHostingAccess(); const access = await loadHostingAccess();
if (request === accessRequest) isAdmin = access.is_admin; if (request === accessRequest) isAdmin = access.is_admin;
} catch { } catch {
@@ -24,10 +25,12 @@
onMount(() => { onMount(() => {
mounted = true; mounted = true;
const refresh = () => { void refreshAccess(); }; const refresh = () => { void refreshAccess(); };
const unsubscribe = browserSession.subscribe(refresh);
window.addEventListener(SESSION_CHANGED_EVENT, refresh); window.addEventListener(SESSION_CHANGED_EVENT, refresh);
window.addEventListener("focus", refresh); window.addEventListener("focus", refresh);
return () => { return () => {
++accessRequest; ++accessRequest;
unsubscribe();
window.removeEventListener(SESSION_CHANGED_EVENT, refresh); window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
window.removeEventListener("focus", refresh); window.removeEventListener("focus", refresh);
}; };
@@ -4,9 +4,11 @@ import { afterEach, expect, test, vi } from "vitest";
import GlobalNavSections from "./GlobalNavSections.svelte"; import GlobalNavSections from "./GlobalNavSections.svelte";
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts"; import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
import { notifySessionChanged } from "#lib/workspace/auth/hosting.ts"; import { notifySessionChanged } from "#lib/workspace/auth/hosting.ts";
import { browserSession } from "#lib/workspace/auth/session-state.ts";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
browserSession.invalidate();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -18,6 +20,7 @@ for (
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
if (String(input) === "/api/auth/whoami") { if (String(input) === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: actor ? "fixture-authentication" : null,
session: actor session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -33,6 +36,7 @@ for (
throw new Error(`Unexpected API ${input}`); throw new Error(`Unexpected API ${input}`);
}); });
vi.stubGlobal("fetch", fetcher); vi.stubGlobal("fetch", fetcher);
await browserSession.refresh();
render(GlobalNavSections, { render(GlobalNavSections, {
currentPath: "/hosting/settings", currentPath: "/hosting/settings",
workspaces: [], workspaces: [],
@@ -45,6 +49,7 @@ for (
expect(link.getAttribute("href")).toBe("/hosting/settings"); expect(link.getAttribute("href")).toBe("/hosting/settings");
expect(link.getAttribute("aria-current")).toBe("page"); expect(link.getAttribute("aria-current")).toBe("page");
actor = false; actor = false;
await browserSession.refresh();
notifySessionChanged(); notifySessionChanged();
await waitFor(() => await waitFor(() =>
expect(screen.queryByRole("link", { name: "Hosting settings" })) expect(screen.queryByRole("link", { name: "Hosting settings" }))
@@ -708,6 +708,14 @@
pointer-events: none; pointer-events: none;
transition-delay: 0s, var(--sidebar-motion-duration); transition-delay: 0s, var(--sidebar-motion-duration);
} }
/* Preview is visual only: closed content stays inert until committed.
Pointer events remain with the original touch target during the drag. */
.app-shell.sidebar-swiping .sidebar-frame {
transform: translateX(var(--sidebar-swipe-offset));
visibility: visible;
transition: none;
pointer-events: none;
}
.sidebar-frame .sidebar-frame-content, .sidebar-frame .sidebar-frame-content,
.sidebar-frame.folded .sidebar-frame-content { .sidebar-frame.folded .sidebar-frame-content {
transform: none; transform: none;
@@ -0,0 +1,312 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, test, vi } from "vitest";
import { swipeSidebar } from "./swipe-sidebar.ts";
let destroy: (() => void) | undefined;
afterEach(() => {
destroy?.();
destroy = undefined;
document.body.replaceChildren();
document.getSelection()?.removeAllRanges();
vi.restoreAllMocks();
});
describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
function fixture(html = "<p>Page content</p>") {
const node = document.createElement("div");
node.innerHTML = html;
const target = node.firstElementChild!;
const panel = document.createElement("aside");
panel.className = "sidebar-frame";
node.append(panel);
document.body.append(node);
let enabled = true;
let isOpen = initiallyOpen;
let route = "/account";
vi.spyOn(panel, "getBoundingClientRect").mockImplementation(() => ({
width: 300,
left: isOpen ? 0 : -300,
} as DOMRect));
const open = vi.fn((next: boolean) => {
isOpen = next;
action.update(options());
});
const options = () => ({ enabled, open: isOpen, route, setOpen: open });
const listeners = vi.spyOn(node, "addEventListener");
const action = swipeSidebar(node, options());
destroy = action.destroy;
const clientX = (x: number) => initiallyOpen ? window.innerWidth - x : x;
const send = (
type: string,
x: number,
y: number,
time = 100,
count = 1,
id = 1,
cancelable = true,
) => {
const touch = { identifier: id, clientX: clientX(x), clientY: y };
const event = new Event(type, { bubbles: true, cancelable });
Object.defineProperties(event, {
touches: { value: type === "touchend" ? [] : Array(count).fill(touch) },
changedTouches: { value: [touch] },
timeStamp: { value: time },
});
target.dispatchEvent(event);
return event;
};
const click = (x: number, y: number, detail = 1) => {
const event = new MouseEvent("click", {
bubbles: true,
cancelable: true,
detail,
clientX: clientX(x),
clientY: y,
});
target.dispatchEvent(event);
return event;
};
return {
node,
target,
panel,
open,
send,
click,
listeners,
flipState: () => {
isOpen = !isOpen;
action.update(options());
},
disable: () => {
enabled = false;
action.update(options());
},
navigate: () => {
route = "/other";
action.update(options());
},
};
}
test("horizontal intent previews position, then settles only on release", async () => {
const { node, open, send, listeners } = fixture();
expect(send("touchstart", 120, 200).defaultPrevented).toBe(false);
expect(node.classList.contains("sidebar-swiping")).toBe(false);
expect(send("touchmove", 150, 203, 150).defaultPrevented).toBe(true);
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe(
initiallyOpen ? "-30px" : "-270px",
);
send("touchmove", 270, 205, 500);
expect(open).not.toHaveBeenCalled();
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe(
"-150px",
);
send("touchend", 270, 205, 900);
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
await Promise.resolve();
expect(node.classList.contains("sidebar-swiping")).toBe(false);
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe("");
expect(node.style.touchAction).toBe("");
expect(listeners.mock.calls.find(([name]) => name === "touchmove")?.[2])
.toEqual({ passive: false });
});
test("ambiguous start remains pending; horizontal lock tolerates an arcing thumb", () => {
const { node, open, send } = fixture();
send("touchstart", 120, 200);
expect(send("touchmove", 132, 210, 120).defaultPrevented).toBe(false);
expect(node.classList.contains("sidebar-swiping")).toBe(false);
expect(send("touchmove", 160, 214, 170).defaultPrevented).toBe(true);
send("touchmove", 280, 260, 500);
send("touchend", 280, 260, 700);
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
});
test("short fast flick commits without the former 80px requirement", () => {
const { open, send } = fixture();
send("touchstart", 120, 200);
send("touchmove", 132, 202, 115);
send("touchmove", 156, 203, 150);
send("touchend", 156, 203, 160);
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
});
test("a noncancelable release still settles an already claimed drag", () => {
const { open, send } = fixture();
send("touchstart", 120, 200);
send("touchmove", 280, 203, 300);
send("touchend", 280, 203, 400, 1, 1, false);
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
});
test("slow long drag still commits after 700ms", () => {
const { open, send } = fixture();
send("touchstart", 120, 200);
send("touchmove", 150, 202, 400);
send("touchmove", 280, 203, 1500);
send("touchend", 280, 203, 1800);
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
});
for (
const [name, points] of [
["tap", [[120, 200, 100], [122, 201, 130]]],
["short slow drag", [[120, 200, 100], [160, 202, 500]]],
["tiny fast flick", [[120, 200, 100], [138, 202, 120]]],
["vertical intent", [[120, 200, 100], [125, 240, 150], [280, 242, 200]]],
["ambiguous diagonal throughout", [[120, 200, 100], [135, 215, 130], [
200,
270,
200,
]]],
["opposite direction", [[240, 200, 100], [210, 202, 140], [
120,
203,
200,
]]],
["browser edge", [[10, 200, 100], [180, 201, 300]]],
["fast motion followed by a pause", [[120, 200, 100], [160, 202, 150], [
160,
202,
600,
]]],
["drag returned to start", [[120, 200, 100], [300, 202, 300], [
125,
203,
900,
]]],
["reverse flick overrides distance", [[120, 200, 100], [320, 202, 200], [
320,
202,
380,
], [260, 203, 430]]],
] as const
) {
test(`${name} leaves the sidebar in its original state`, async () => {
const { node, open, send } = fixture();
points.forEach(([x, y, time], i) =>
send(i === 0 ? "touchstart" : "touchmove", x, y, time)
);
const [x, y, time] = points.at(-1)!;
send("touchend", x, y, time + 10);
await Promise.resolve();
expect(open).not.toHaveBeenCalled();
expect(node.classList.contains("sidebar-swiping")).toBe(false);
});
}
for (
const html of [
'<input type="range">',
"<textarea></textarea>",
"<select></select>",
'<div contenteditable="true">Editor</div>',
'<div role="slider">Slider</div>',
"<dialog open>Dialog</dialog>",
"<div data-sidebar-swipe-ignore>Custom gesture</div>",
]
) {
test(`keeps native controls unclaimed: ${html}`, () => {
const { open, send, listeners } = fixture(html);
send("touchstart", 120, 200);
expect(send("touchmove", 290, 201, 300).defaultPrevented).toBe(false);
send("touchend", 290, 201, 310);
expect(open).not.toHaveBeenCalled();
expect(listeners.mock.calls.some(([name]) => name === "touchmove")).toBe(
false,
);
});
}
test("nested horizontal scrolling remains excluded even at its boundary", () => {
const { target, open, send } = fixture();
(target as HTMLElement).style.overflowX = "auto";
Object.defineProperty(target, "scrollWidth", { value: 800 });
Object.defineProperty(target, "clientWidth", { value: 300 });
send("touchstart", 120, 200);
expect(send("touchmove", 290, 201, 300).defaultPrevented).toBe(false);
send("touchend", 290, 201, 310);
expect(open).not.toHaveBeenCalled();
});
for (
const reason of [
"scroll",
"touchcancel",
"multitouch",
"disabled",
"state-change",
"navigation",
"resize",
"hidden",
"identifier",
"destroy",
"selection",
"native-claimed",
]
) {
test(`cancels preview after ${reason}`, () => {
const { node, target, open, send, disable, flipState, navigate } =
fixture();
send("touchstart", 120, 200);
send("touchmove", 160, 202, 150);
expect(node.classList.contains("sidebar-swiping")).toBe(true);
if (reason === "scroll" || reason === "touchcancel") {
target.dispatchEvent(
new Event(reason, { bubbles: reason === "touchcancel" }),
);
}
if (reason === "multitouch") send("touchstart", 160, 202, 160, 2);
if (reason === "disabled") disable();
if (reason === "state-change") flipState();
if (reason === "navigation") navigate();
if (reason === "resize") window.dispatchEvent(new Event("resize"));
if (reason === "hidden") {
document.dispatchEvent(new Event("visibilitychange"));
}
if (reason === "destroy") destroy?.();
if (reason === "selection") {
const range = document.createRange();
range.selectNodeContents(target);
document.getSelection()?.addRange(range);
}
if (reason === "native-claimed") {
send("touchmove", 200, 203, 200, 1, 1, false);
}
send("touchend", 290, 203, 300, 1, reason === "identifier" ? 2 : 1);
expect(open).not.toHaveBeenCalled();
expect(node.classList.contains("sidebar-swiping")).toBe(false);
});
}
for (const html of ['<a href="#link">Link</a>', "<button>Action</button>"]) {
test(`swipe from ${html} suppresses only its click, not later taps or keyboard activation`, async () => {
const { open, send, click, target } = fixture(html);
const activate = vi.fn();
target.addEventListener("click", activate);
send("touchstart", 120, 200);
send("touchmove", 156, 202, 140);
send("touchend", 156, 202, 150);
await Promise.resolve();
expect(open).toHaveBeenCalledOnce();
expect(click(156, 202).defaultPrevented).toBe(true);
expect(activate).not.toHaveBeenCalled();
expect(click(156, 202, 0).defaultPrevented).toBe(false);
send("touchstart", 160, 200, 200);
send("touchend", 160, 200, 250);
expect(click(160, 200).defaultPrevented).toBe(false);
expect(activate).toHaveBeenCalledTimes(2);
});
}
test("a claimed but incomplete swipe also suppresses accidental activation", async () => {
const { open, send, click } = fixture("<button>Action</button>");
send("touchstart", 120, 200);
send("touchmove", 160, 202, 400);
send("touchend", 160, 202, 500);
await Promise.resolve();
expect(open).not.toHaveBeenCalled();
expect(click(160, 202).defaultPrevented).toBe(true);
});
});
@@ -0,0 +1,239 @@
type Options = {
enabled: boolean;
open: boolean;
route: string;
setOpen: (open: boolean) => void;
};
type Sample = { x: number; time: number };
const SLOP = 10;
const AXIS_RATIO = 1.5;
const MIN_FLING_DISTANCE = 24;
const FLING_VELOCITY = 450; // CSS px/s, measured over the last 120ms.
const COMMIT_FRACTION = 0.4;
// Leave undecided/vertical gestures to the browser. Only a confirmed horizontal
// drag claims touchmove; no ancestor touch-action restriction disables nested
// horizontal scrolling or pinch zoom. Preview never changes admission/focus.
export function swipeSidebar(node: HTMLElement, options: Options) {
let gesture: {
id: number;
x: number;
y: number;
horizontal: boolean;
wasOpen: boolean;
width: number;
startOffset: number;
offset: number;
peak: number;
samples: Sample[];
} | null = null;
let suppressedClick: { x: number; y: number; until: number } | null = null;
let cleanupVersion = 0;
const view = node.ownerDocument.defaultView;
const selectedText = () =>
node.ownerDocument.getSelection()?.isCollapsed === false;
const clearPreview = () => {
node.classList.remove("sidebar-swiping");
node.style.removeProperty("--sidebar-swipe-offset");
};
const cancel = () => {
gesture = null;
node.removeEventListener("touchmove", move);
cleanupVersion++;
clearPreview();
};
function excluded(target: EventTarget | null): boolean {
let element = target instanceof Element ? target : null;
if (
!element || element.closest(
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="spinbutton"], [role="scrollbar"], [role="dialog"], [role="menu"], [role="listbox"], dialog, video, audio, canvas, [draggable="true"], [data-sidebar-swipe-ignore]',
)
) return true;
// A horizontal scroll surface owns the whole gesture, including its edges.
for (; element; element = element.parentElement) {
if (
element.scrollWidth > element.clientWidth + 1 &&
/^(auto|scroll|overlay)$/.test(getComputedStyle(element).overflowX)
) return true;
if (element === node) break;
}
return false;
}
function start(event: TouchEvent) {
cancel();
suppressedClick = null; // A new tap must not inherit the previous swipe.
if (
!options.enabled || event.defaultPrevented ||
event.touches.length !== 1 || selectedText() || excluded(event.target)
) return;
const touch = event.touches[0];
const viewportWidth = view?.innerWidth ?? 0;
if (touch.clientX < 24 || touch.clientX > viewportWidth - 24) return;
const panel = node.querySelector<HTMLElement>(".sidebar-frame");
if (!panel) return;
const bounds = panel.getBoundingClientRect();
if (bounds.width <= 0) return;
const offset = Math.min(
0,
Math.max(-bounds.width, bounds.left - node.getBoundingClientRect().left),
);
gesture = {
id: touch.identifier,
x: touch.clientX,
y: touch.clientY,
horizontal: false,
wasOpen: options.open,
width: bounds.width,
startOffset: offset,
offset,
peak: 0,
samples: [{ x: 0, time: event.timeStamp }],
};
// Install a potentially blocking listener only for an eligible gesture.
node.addEventListener("touchmove", move, { passive: false });
}
function track(event: TouchEvent, touch: Touch): boolean {
const current = gesture;
if (!current) return false;
if (
!options.enabled || options.open !== current.wasOpen ||
event.defaultPrevented || selectedText() ||
touch.identifier !== current.id
) {
cancel();
return false;
}
const rawDx = touch.clientX - current.x;
const dx = rawDx * (current.wasOpen ? -1 : 1);
const dy = Math.abs(touch.clientY - current.y);
if (!current.horizontal) {
if (
(dy >= SLOP && dy > Math.abs(dx) * AXIS_RATIO) ||
(dx <= -SLOP && -dx > dy * AXIS_RATIO)
) {
cancel();
return false;
}
// An ambiguous diagonal is not a failed swipe. Keep observing until
// intent becomes clear, unless the browser starts scrolling first.
if (dx < SLOP || dx < dy * AXIS_RATIO) return false;
if (!event.cancelable) {
cancel(); // Native scrolling already owns this stream.
return false;
}
current.horizontal = true;
}
if (!event.cancelable && event.type !== "touchend") {
cancel();
return false;
}
if (event.cancelable) event.preventDefault();
current.offset = Math.min(
0,
Math.max(-current.width, current.startOffset + rawDx),
);
current.peak = Math.max(current.peak, dx);
current.samples.push({ x: dx, time: event.timeStamp });
current.samples = current.samples.filter((sample) =>
sample.time >= event.timeStamp - 120
);
suppressedClick = {
x: touch.clientX,
y: touch.clientY,
until: performance.now() + 750,
};
node.style.setProperty("--sidebar-swipe-offset", `${current.offset}px`);
node.classList.add("sidebar-swiping");
return true;
}
function move(event: TouchEvent) {
if (event.touches.length !== 1) {
cancel();
return;
}
track(event, event.touches[0]);
}
function end(event: TouchEvent) {
// Do not turn an unclaimed touch into a swipe only after the finger lifts.
if (
!gesture?.horizontal || event.touches.length !== 0 ||
event.changedTouches.length !== 1 ||
!track(event, event.changedTouches[0])
) {
cancel();
return;
}
const current = gesture;
const last = current.samples.at(-1)!;
const first = current.samples[0];
const duration = last.time - first.time;
const velocity = duration >= 8 ? (last.x - first.x) * 1000 / duration : 0;
const progress = current.wasOpen
? -current.offset
: current.width + current.offset;
const reversed = velocity <= -FLING_VELOCITY &&
current.peak - last.x >= MIN_FLING_DISTANCE;
const commit = !reversed && (
progress >= current.width * COMMIT_FRACTION ||
(last.x >= MIN_FLING_DISTANCE && velocity >= FLING_VELOCITY)
);
gesture = null;
node.removeEventListener("touchmove", move);
if (commit) options.setOpen(!current.wasOpen);
// Let Svelte apply the final open/closed class before removing the preview;
// CSS then settles from the actual dragged position, including snap-back.
const version = ++cleanupVersion;
queueMicrotask(() => {
if (version === cleanupVersion) clearPreview();
});
}
function click(event: MouseEvent) {
if (!suppressedClick || performance.now() > suppressedClick.until) return;
// Keep keyboard activation and a subsequent deliberate tap intact.
if (event.detail === 0) return;
if (
Math.hypot(
event.clientX - suppressedClick.x,
event.clientY - suppressedClick.y,
) > 32
) return;
event.preventDefault();
event.stopImmediatePropagation();
suppressedClick = null;
}
const passive = { passive: true };
node.addEventListener("touchstart", start, passive);
node.addEventListener("touchend", end, { passive: false });
node.addEventListener("touchcancel", cancel, passive);
node.addEventListener("scroll", cancel, { passive: true, capture: true });
node.addEventListener("click", click, true);
view?.addEventListener("resize", cancel);
node.ownerDocument.addEventListener("visibilitychange", cancel);
return {
update(next: Options) {
if (
!next.enabled || next.open !== options.open ||
next.route !== options.route
) cancel();
options = next;
},
destroy() {
cancel();
node.removeEventListener("touchstart", start);
node.removeEventListener("touchend", end);
node.removeEventListener("touchcancel", cancel);
node.removeEventListener("scroll", cancel, true);
node.removeEventListener("click", click, true);
view?.removeEventListener("resize", cancel);
node.ownerDocument.removeEventListener("visibilitychange", cancel);
},
};
}
+21 -5
View File
@@ -6,7 +6,8 @@
// MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。 // MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。
import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte'; import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { setContext } from 'svelte'; import { onMount, setContext } from 'svelte';
import { browserSession, type SessionSnapshot } from '#lib/workspace/auth/session-state.ts';
import { MediaQuery } from 'svelte/reactivity'; import { MediaQuery } from 'svelte/reactivity';
import WorkspaceAlerts from '#lib/workspace/alerts/WorkspaceAlerts.svelte'; import WorkspaceAlerts from '#lib/workspace/alerts/WorkspaceAlerts.svelte';
import { dismissWorkspaceAlert, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts'; import { dismissWorkspaceAlert, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
@@ -20,6 +21,7 @@
import SidebarFrame from '#lib/workspace/sidebar/SidebarFrame.svelte'; import SidebarFrame from '#lib/workspace/sidebar/SidebarFrame.svelte';
import SidebarToggleIcon from '#lib/workspace/sidebar/SidebarToggleIcon.svelte'; import SidebarToggleIcon from '#lib/workspace/sidebar/SidebarToggleIcon.svelte';
import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '#lib/workspace/sidebar/context.ts'; import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '#lib/workspace/sidebar/context.ts';
import { swipeSidebar } from '#lib/workspace/sidebar/swipe-sidebar.ts';
import { createOverrideStack } from '#lib/workspace/sidebar/override-stack.ts'; import { createOverrideStack } from '#lib/workspace/sidebar/override-stack.ts';
import '../app.css'; import '../app.css';
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
@@ -28,8 +30,11 @@
let { children, data }: LayoutProps = $props(); let { children, data }: LayoutProps = $props();
import { isAuthPage } from '#lib/workspace/auth/routes.ts'; import { isAuthPage } from '#lib/workspace/auth/routes.ts';
// 公開入口・認証・Workspace選択はWorkspace catalogやSidebarに依存しない。 let session = $state<SessionSnapshot>(browserSession.snapshot);
const standalone = $derived(page.url.pathname === '/' || page.url.pathname === '/dashboard' || isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/')); onMount(() => browserSession.subscribe((snapshot) => { session = snapshot; }));
const authenticated = $derived(session.kind === 'ready' && session.value.session.kind === 'authenticated');
// 公開トップのSidebarはログイン確認済みの場合だけ表示する。
const standalone = $derived((page.url.pathname === '/' && !authenticated) || page.url.pathname === '/dashboard' || isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/'));
const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog'); const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog');
let sidebar = $state<SidebarSnippet | null>(null); let sidebar = $state<SidebarSnippet | null>(null);
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => { const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
@@ -103,8 +108,19 @@
{@render children()} {@render children()}
</main> </main>
{:else} {:else}
<!-- Workspace通知は公開トップや本人設定へ持ち越さない。 -->
{#if page.url.pathname !== '/' && page.url.pathname !== '/account'}
<WorkspaceAlerts /> <WorkspaceAlerts />
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}> {/if}
<div
use:swipeSidebar={{
enabled: mobileLayout.current && authenticated,
open: sidebarOpen,
route: page.url.href,
setOpen: (open) => { sidebarTransientOpen = open; },
}}
class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}
>
<SidebarFrame <SidebarFrame
mode={sidebarMode} mode={sidebarMode}
open={sidebarOpen} open={sidebarOpen}
@@ -115,7 +131,7 @@
<GlobalSidebar <GlobalSidebar
currentPath={page.url.pathname} currentPath={page.url.pathname}
content={sidebar} content={sidebar}
workspaces={catalog.data?.accessibleWorkspaces ?? null} workspaces={page.url.pathname === '/' ? null : catalog.data?.accessibleWorkspaces ?? null}
workspaceError={catalog.data?.workspaceCatalogError} workspaceError={catalog.data?.workspaceCatalogError}
/> />
</SidebarFrame> </SidebarFrame>
-3
View File
@@ -2,7 +2,6 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts'; import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
import { AUTHENTICATED_HOME } from '#lib/workspace/auth/routes.ts'; import { AUTHENTICATED_HOME } from '#lib/workspace/auth/routes.ts';
import RegistrationLink from '#lib/workspace/auth/RegistrationLink.svelte';
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte'; import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
import '#lib/workspace/auth/auth-page.css'; import '#lib/workspace/auth/auth-page.css';
@@ -31,8 +30,6 @@
{#key auth.actor.account_id}<AccountNavigation />{/key} {#key auth.actor.account_id}<AccountNavigation />{/key}
{:else} {:else}
<a class="entry-primary" href="/login">Log in</a> <a class="entry-primary" href="/login">Log in</a>
<RegistrationLink />
<a href="/activate">Activate an account with a code</a>
{/if} {/if}
</section> </section>
</div> </div>
+90 -10
View File
@@ -1,30 +1,72 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { addPasskey, logout } from '#lib/workspace/auth/api.ts'; import type { PasskeyListResponse } from '#lib/generated/auth-api.ts';
import { addPasskey, logout, loadPasskeys, loadWhoami } from '#lib/workspace/auth/api.ts';
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts'; import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
import { browserSession } from '#lib/workspace/auth/session-state.ts';
import { loginUrl } from '#lib/workspace/auth/routes.ts'; import { loginUrl } from '#lib/workspace/auth/routes.ts';
import '#lib/workspace/auth/auth-page.css'; import '#lib/workspace/auth/auth-page.css';
const auth = new BrowserAuth(); const auth = new BrowserAuth();
async function refresh() { let keys = $state<PasskeyListResponse | null>(null);
await auth.refresh(); let keysError = $state('');
let keysLoading = $state(false);
let keyRead = 0;
let active = true;
let keyController: AbortController | null = null;
function time(value: string | null) { return value ? new Date(value).toLocaleString() : 'Not recorded'; }
async function refreshKeys() {
const actor = auth.actor;
if (!actor || !active) return;
keyController?.abort();
const controller = new AbortController();
keyController = controller;
const read = ++keyRead;
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
const assertCurrent = () => {
controller.signal.throwIfAborted();
if (!active || read !== keyRead || generation !== browserSession.authenticationGeneration || stamp !== browserSession.authenticationStamp) throw new Error('Session changed');
};
keysLoading = true; keysError = '';
try {
const before = await loadWhoami(controller.signal);
assertCurrent();
if (before.actor?.user_id !== actor.user_id || before.actor.account_id !== actor.account_id) throw new Error('Account changed');
const result = await loadPasskeys(controller.signal);
assertCurrent();
const after = await loadWhoami(controller.signal);
assertCurrent();
if (result.user.user_id !== actor.user_id || result.user.account_id !== actor.account_id ||
after.actor?.user_id !== actor.user_id || after.actor.account_id !== actor.account_id ||
before.authentication_reference !== after.authentication_reference) throw new Error('Session changed');
keys = result;
} catch {
if (active && read === keyRead && !controller.signal.aborted) { keys = null; keysError = 'Registered keys could not be confirmed for this account. Refresh your session before trying again.'; }
} finally { if (read === keyRead) keysLoading = false; }
} }
async function refresh() { await auth.refresh(); await refreshKeys(); }
async function add() { async function add() {
if (!auth.actor) return; if (!auth.actor || auth.additionAttempt && !auth.additionSaved) return;
await auth.run('Waiting for passkey addition…', 'Passkey added to your account.', addPasskey); await auth.run('Waiting for passkey addition…', 'Passkey added to your account; the expected session was confirmed.', addPasskey);
await refreshKeys();
} }
async function checkAddition() { await auth.checkAddition(); await refreshKeys(); }
async function signOut() { async function signOut() {
if (!auth.actor) return; if (!auth.actor) return;
const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout'); const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
if (confirmed && !auth.actor) await goto('/', { replaceState: true }); if (confirmed && !auth.actor) await goto('/', { replaceState: true });
} }
onMount(() => { void auth.ensure(); return () => auth.dispose(); }); onMount(() => { void auth.ensure().then(refreshKeys); return () => { active = false; ++keyRead; keyController?.abort(); auth.dispose(); }; });
</script> </script>
<svelte:head><title>Account · Yoi</title></svelte:head> <svelte:head><title>Account · Yoi</title></svelte:head>
<div class="auth-page" data-sveltekit-preload-data="off"> <div class="auth-page" data-sveltekit-preload-data="off">
<h1>Account</h1> <h1>Account</h1>
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
{#if auth.session.kind === 'loading'} {#if auth.session.kind === 'loading'}
<p class="muted" role="status">Loading session…</p> <p class="muted" role="status">Loading session…</p>
{:else if auth.session.kind === 'error'} {:else if auth.session.kind === 'error'}
@@ -37,8 +79,41 @@
</dl> </dl>
<section aria-label="Your Passkeys"> <section aria-label="Your Passkeys">
<h2>Passkeys</h2> <h2>Passkeys</h2>
<p class="muted">Add a discoverable passkey for login without an account ID. Use a device or key that can save passkeys and verify you with a PIN or biometrics. Existing keys are preserved; do not remove them before confirming the new key works.</p> <p class="muted">Add a passkey for login without an account ID. Use a device or key that saves passkeys and verifies you with a PIN or biometrics. Existing keys are preserved.</p>
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button> <p>If an older key is in the same authenticator or saved-key provider, duplicate exclusion can block addition there. Choose a different device, security key, or passkey provider. A different provider on the same device may work only if it does not contain an excluded key. Do not delete an older key to bypass this check.</p>
<button type="button" disabled={auth.busy || !!auth.additionAttempt && !auth.additionSaved} onclick={add}>Add passkey to {auth.actor.handle}</button>
{#if auth.additionAttempt}
<button type="button" disabled={auth.busy} onclick={checkAddition}>Check addition result</button>
<p class="muted">A saved result confirms this addition's server record, not that ID-free login works. If the session changed, result access may be unavailable; do not restore an old session or add blindly.</p>
{/if}
{#if keysLoading}<p role="status">Checking registered keys…</p>{/if}
{#if keysError}<p role="alert" class="error">{keysError}</p>{/if}
{#if keys && keys.user.user_id === auth.actor.user_id && keys.user.account_id === auth.actor.account_id}
<ul class="passkey-list" aria-label="Registered keys">
{#each keys.keys as key (key.key_reference)}
<li>
<dl>
<div><dt>Created</dt><dd>{time(key.created_at)}</dd></div>
<div><dt>Last used</dt><dd>{time(key.last_used_at)}</dd></div>
<div><dt>ID-free login</dt><dd>{key.normal_login_verified_at ? `Verified ${time(key.normal_login_verified_at)}` : 'Not yet verified — storage alone does not prove support'}</dd></div>
</dl>
<details><summary>Key reference</summary><code>{key.key_reference}</code></details>
</li>
{/each}
</ul>
{#if keys.keys.length === 0}<p>No registered keys were returned for this account.</p>{/if}
{/if}
<button type="button" disabled={auth.busy || keysLoading} onclick={refreshKeys}>Refresh registered keys</button>
<details>
<summary>Verify migration before retiring older-key login</summary>
<ol>
<li>Keep this working session and every working older key. If addition is excluded, use a different saved-key provider or authenticator; removing your last key first can lock you out.</li>
<li>In a separate browser profile or private window, open <a href="/login" target="_blank" rel="noopener">normal passkey login</a>. Do not open the older-key/ID form. Select the newly saved key and complete verification.</li>
<li>Confirm the intended account ID in that window's Account page. Refresh the key list here and check that the chosen key now records verified ID-free login. Repeat for every key and device you intend to rely on; a stored key or resident-key hint alone is not proof.</li>
<li>Retain a tested independent way back in before ceasing to rely on an older key. If any required device cannot complete normal login, keep the older-key path available. Do not delete old keys as a prerequisite.</li>
</ol>
<p>Removing the older-key entry for the service is not safe based on one account's success. The operator must first confirm migration and a working alternative for every affected account/device, keep a rollback path to the older-key entry, and verify normal login before retiring that entry. This page does not delete keys or disable compatibility login.</p>
</details>
</section> </section>
<div class="actions"> <div class="actions">
<button type="button" disabled={auth.busy} onclick={signOut}>Log out</button> <button type="button" disabled={auth.busy} onclick={signOut}>Log out</button>
@@ -48,7 +123,12 @@
<p>No browser session is active.</p> <p>No browser session is active.</p>
<a href={loginUrl('/account')}>Log in to your account</a> <a href={loginUrl('/account')}>Log in to your account</a>
{/if} {/if}
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<a href="/dashboard">Dashboard / Workspaces</a> <a href="/dashboard">Dashboard / Workspaces</a>
</div> </div>
<style>
.passkey-list { padding-inline-start: var(--space-5); display: grid; gap: var(--space-4); }
.passkey-list li { min-width: 0; }
code { overflow-wrap: anywhere; }
ol { padding-inline-start: var(--space-5); }
li + li { margin-block-start: var(--space-2); }
</style>
@@ -63,6 +63,9 @@ function boundary({
return new Response(null, { status: state.whoamiStatus }); return new Response(null, { status: state.whoamiStatus });
} }
return Response.json({ return Response.json({
authentication_reference: state.authenticated
? "fixture-authentication"
: null,
session: state.authenticated session: state.authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -71,6 +74,9 @@ function boundary({
: null, : null,
}); });
} }
if (path === "/api/auth/passkeys") {
return Response.json({ user: authUser, keys: [] });
}
if (path === "/api/auth/registration-policy") { if (path === "/api/auth/registration-policy") {
if (state.policy === "loading") return await policyResponse.promise; if (state.policy === "loading") return await policyResponse.promise;
if (state.policy === "error") return new Response(null, { status: 503 }); if (state.policy === "error") return new Response(null, { status: 503 });
@@ -97,7 +103,10 @@ function boundary({
return new Response(null, { status: state.completeStatus }); return new Response(null, { status: state.completeStatus });
} }
state.authenticated = true; state.authenticated = true;
return Response.json({ user: authUser }); return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
} }
if (path === "/api/auth/logout") { if (path === "/api/auth/logout") {
state.authenticated = false; state.authenticated = false;
@@ -149,6 +158,143 @@ async function submit(name: string) {
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!); await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
} }
for (const phase of ["initial-whoami", "list", "final-whoami"] as const) {
for (const reason of ["logout", "authentication"] as const) {
test(`registered-key ${phase} confirmation rejects shared ${reason} before event delivery`, async () => {
const { fetcher } = boundary({ authenticated: true });
render(Account);
await screen.findByText(
"No registered keys were returned for this account.",
);
const request = fetcher.getMockImplementation()!;
const started = gate<void>(), response = gate<Response>();
let whoami = 0, listReads = 0;
const metadata = () =>
Response.json({
user: authUser,
keys: [{
key_reference: "delayed-key-metadata",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
}],
});
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") {
++whoami;
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
started.resolve();
return response.promise;
}
}
if (path === "/api/auth/passkeys") {
++listReads;
if (phase === "list") {
started.resolve();
return response.promise;
}
return metadata();
}
return request(input, init);
});
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
await fireEvent.click(
screen.getByRole("button", { name: "Refresh registered keys" }),
);
await started.promise;
const changed = JSON.stringify({ id: `keys-${phase}-${reason}`, reason });
localStorage.setItem("yoi.browser-auth-generation", changed);
expect(browserSession.authenticationStamp).not.toBe(stamp);
expect(browserSession.authenticationGeneration).toBe(generation);
response.resolve(
phase === "list" ? metadata() : await request("/api/auth/whoami"),
);
await screen.findByText(/Registered keys could not be confirmed/);
expect(screen.queryByText("delayed-key-metadata")).toBeNull();
expect(listReads).toBe(phase === "initial-whoami" ? 0 : 1);
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
expect(posted(fetcher)).toEqual([]);
});
}
}
for (const reason of ["logout", "authentication"] as const) {
test(`stale receipt confirmation after shared ${reason} leaves visible repeat-add disabled`, async () => {
const { fetcher } = boundary({ authenticated: true });
render(Account);
await screen.findByText(
"No registered keys were returned for this account.",
);
const request = fetcher.getMockImplementation()!;
const started = gate<void>(), response = gate<Response>();
let receiptRead = false;
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path.endsWith("/addition/complete")) {
throw new TypeError("lost response");
}
if (path.endsWith("/addition/result")) {
receiptRead = true;
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
.toBe("fixture-authentication");
return Response.json({
status: "saved",
user: authUser,
key: {
key_reference: "saved-but-session-unconfirmed",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
},
});
}
if (path === "/api/auth/whoami" && receiptRead) {
receiptRead = false;
started.resolve();
return response.promise;
}
return request(input, init);
});
const add = screen.getByRole("button", {
name: /Add passkey to/,
}) as HTMLButtonElement;
await fireEvent.click(add);
await screen.findByText(/result is unknown/);
await waitFor(() =>
expect(screen.queryByText("Checking registered keys…")).toBeNull()
);
expect(add.disabled).toBe(true);
const checking = screen.getByRole("button", {
name: "Check addition result",
});
await waitFor(() =>
expect((checking as HTMLButtonElement).disabled).toBe(false)
);
await fireEvent.click(checking);
await started.promise;
const generation = browserSession.authenticationGeneration;
localStorage.setItem(
"yoi.browser-auth-generation",
JSON.stringify({ id: `visible-${reason}`, reason }),
);
expect(browserSession.authenticationGeneration).toBe(generation);
response.resolve(await request("/api/auth/whoami"));
await screen.findByText(/The addition result could not be read/);
await waitFor(() =>
expect((checking as HTMLButtonElement).disabled).toBe(false)
);
expect(screen.queryByText(/Your current session was checked/)).toBeNull();
expect(add.disabled).toBe(true);
await fireEvent.click(add);
expect(
posted(fetcher).filter(({ path }) => path.endsWith("/addition/complete")),
).toHaveLength(1);
});
}
test("normal login needs no account ID and links separate registration and code activation", async () => { test("normal login needs no account ID and links separate registration and code activation", async () => {
const { fetcher } = boundary({ policy: true }); const { fetcher } = boundary({ policy: true });
page.url = new URL("https://example.test/login?returnTo=%2Faccount"); page.url = new URL("https://example.test/login?returnTo=%2Faccount");
@@ -231,19 +377,23 @@ for (
expect(get).toHaveBeenCalledOnce(); expect(get).toHaveBeenCalledOnce();
expect(create).not.toHaveBeenCalled(); expect(create).not.toHaveBeenCalled();
expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami")) expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2); .toHaveLength(4);
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve"); expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
}); });
} }
test("successful login completion without confirmed whoami never navigates", async () => { test("successful login completion without confirmed whoami never navigates", async () => {
const { state, get } = boundary(); const { state, fetcher } = boundary();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
const response = await request(input, init);
if (String(input) === "/api/auth/passkeys/login/complete") {
state.whoamiStatus = 503;
}
return response;
});
render(Login); render(Login);
await enterIdentity(); await enterIdentity();
get.mockImplementationOnce(async () => {
state.whoamiStatus = 503;
return credentialFixture(true);
});
await submit("Log in with passkey"); await submit("Log in with passkey");
await screen.findByText(/Your session could not be loaded/); await screen.findByText(/Your session could not be loaded/);
expect(goto).not.toHaveBeenCalled(); expect(goto).not.toHaveBeenCalled();
@@ -347,7 +497,7 @@ test("policy disabled during WebAuthn rejects registration, reloads policy and n
await waitFor(() => expect(create).toHaveBeenCalledOnce()); await waitFor(() => expect(create).toHaveBeenCalledOnce());
state.completeStatus = 403; state.completeStatus = 403;
credential.resolve(credentialFixture()); credential.resolve(credentialFixture());
await screen.findByText("Auth request failed (403)."); await screen.findByText(/\[complete\/rejected\/HTTP-403\]/);
await screen.findByText(/New account registration is disabled/); await screen.findByText(/New account registration is disabled/);
expect(screen.queryByRole("button", { name: "Register new account" })) expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull(); .toBeNull();
@@ -376,7 +526,9 @@ test("account displays the current identity without editable target fields and a
await fireEvent.click( await fireEvent.click(
screen.getByRole("button", { name: "Add passkey to alice" }), screen.getByRole("button", { name: "Add passkey to alice" }),
); );
await screen.findByText("Passkey added to your account."); await screen.findByText(
"Passkey added to your account; the expected session was confirmed.",
);
await waitFor(() => await waitFor(() =>
expect( expect(
(screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement) (screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement)
@@ -388,7 +540,12 @@ test("account displays the current identity without editable target fields and a
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete", "/api/auth/passkeys/addition/complete",
]); ]);
expect(requests[0].body).toEqual({ browser_origin: window.location.origin }); expect(requests[0].body).toEqual({
browser_origin: window.location.origin,
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference: "fixture-authentication",
});
expect(Object.keys(requests[1].body).sort()).toEqual([ expect(Object.keys(requests[1].body).sort()).toEqual([
"challenge_id", "challenge_id",
"credential", "credential",
@@ -485,11 +642,18 @@ test("logout confirms the server session is cleared and returns to the public ho
expect(posted(fetcher)).toEqual([ expect(posted(fetcher)).toEqual([
{ path: "/api/auth/logout", body: {}, credentials: "same-origin" }, { path: "/api/auth/logout", body: {}, credentials: "same-origin" },
]); ]);
expect(apiPaths(fetcher)).toEqual([ const paths = apiPaths(fetcher);
expect(paths[0]).toBe("/api/auth/whoami");
expect(paths.slice(paths.indexOf("/api/auth/logout") + 1)).toContain(
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/logout", );
"/api/auth/whoami", expect(
]); paths.every((path) =>
["/api/auth/whoami", "/api/auth/logout", "/api/auth/passkeys"].includes(
path,
)
),
).toBe(true);
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull(); expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
}); });
@@ -499,10 +663,14 @@ test("expired addition revokes stale identity without silently retrying as regis
await fireEvent.click( await fireEvent.click(
await screen.findByRole("button", { name: "Add passkey to alice" }), await screen.findByRole("button", { name: "Add passkey to alice" }),
); );
await screen.findByText(/Authentication request was rejected/); await screen.findByText(/server rejected the request or verification/);
await screen.findByRole("link", { name: "Log in to your account" }); await screen.findByRole("link", { name: "Log in to your account" });
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull(); expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
expect(screen.queryByText("Passkey added to your account.")).toBeNull(); expect(
screen.queryByText(
"Passkey added to your account; the expected session was confirmed.",
),
).toBeNull();
expect(posted(fetcher).map(({ path }) => path)).toEqual([ expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete", "/api/auth/passkeys/addition/complete",
@@ -545,7 +713,13 @@ test("WebAuthn cancellation is visible, sends no completion, and allows an expli
await enterIdentity(); await enterIdentity();
await submit("Log in with passkey"); await submit("Log in with passkey");
await screen.findByText( await screen.findByText(
"Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.", /cancelled, timed out, or found no usable key/,
);
expect(screen.getByRole("alert").textContent).toContain(
"Completion was not sent.",
);
expect(screen.getByRole("alert").textContent).toContain(
"[authenticator/cancelled-or-unavailable]",
); );
await waitFor(() => await waitFor(() =>
expect( expect(
@@ -651,7 +825,12 @@ test("device approval locks its code and rejects a second submit while the serve
name: "Approve device login", name: "Approve device login",
}); });
const response = gate<Response>(); const response = gate<Response>();
fetcher.mockReturnValueOnce(response.promise); const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input) === "/api/auth/device-login/approve"
? response.promise
: request(input, init)
);
const form = approve.closest("form")!; const form = approve.closest("form")!;
await fireEvent.submit(form); await fireEvent.submit(form);
await screen.findByText("Approving device login…"); await screen.findByText("Approving device login…");
@@ -659,9 +838,11 @@ test("device approval locks its code and rejects a second submit while the serve
expect((screen.getByLabelText("User code") as HTMLInputElement).disabled) expect((screen.getByLabelText("User code") as HTMLInputElement).disabled)
.toBe(true); .toBe(true);
await fireEvent.submit(form); await fireEvent.submit(form);
await waitFor(() =>
expect(posted(fetcher).map(({ path }) => path)).toEqual([ expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/device-login/approve", "/api/auth/device-login/approve",
]); ])
);
response.resolve(Response.json({ status: "approved", user: authUser })); response.resolve(Response.json({ status: "approved", user: authUser }));
await screen.findByText("Device login approved. You can return to the CLI."); await screen.findByText("Device login approved. You can return to the CLI.");
expect(posted(fetcher)).toHaveLength(1); expect(posted(fetcher)).toHaveLength(1);
@@ -679,11 +860,8 @@ for (const status of [401, 410]) {
(await screen.findByRole("button", { name: "Approve device login" })) (await screen.findByRole("button", { name: "Approve device login" }))
.closest("form")!, .closest("form")!,
); );
await screen.findByText( await screen.findByText(/Authentication request was rejected/);
status === 401 expect(screen.getByRole("alert").textContent).toContain("(" + status + ")");
? /Authentication request was rejected/
: /The request or authentication challenge is invalid or expired/,
);
expect( expect(
screen.queryByText("Device login approved. You can return to the CLI."), screen.queryByText("Device login approved. You can return to the CLI."),
).toBeNull(); ).toBeNull();
@@ -721,7 +899,7 @@ test("compatibility login keeps its selected form and ID after cancellation and
target: { value: "alice" }, target: { value: "alice" },
}); });
await submit("Log in with older key"); await submit("Log in with older key");
await screen.findByText(/cancelled or timed out/); await screen.findByText(/cancelled, timed out, or found no usable key/);
await screen.findByText("Use an older key with account ID"); await screen.findByText("Use an older key with account ID");
expect( expect(
(screen.getByText("Use an older key with account ID").closest( (screen.getByText("Use an older key with account ID").closest(
@@ -3,6 +3,7 @@
import { activateWithPasskey } from '#lib/workspace/auth/activation.ts'; import { activateWithPasskey } from '#lib/workspace/auth/activation.ts';
import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts'; import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
import { notifySessionChanged } from '#lib/workspace/auth/hosting.ts'; import { notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
import { browserSession } from '#lib/workspace/auth/session-state.ts';
import type { AuthUser } from '#lib/workspace/auth/model.ts'; import type { AuthUser } from '#lib/workspace/auth/model.ts';
let handle = $state(''); let handle = $state('');
@@ -19,12 +20,15 @@
try { handle = normalizeActivationHandle(handle); } try { handle = normalizeActivationHandle(handle); }
catch { error = 'Check the account ID supplied by your administrator.'; return; } catch { error = 'Check the account ID supplied by your administrator.'; return; }
const current = ++generation; const current = ++generation;
const authGeneration = browserSession.authenticationGeneration;
const authStamp = browserSession.authenticationStamp;
controller = new AbortController(); busy = true; error = ''; controller = new AbortController(); busy = true; error = '';
const pending = activateWithPasskey(handle, code, controller.signal); const pending = activateWithPasskey(handle, code, controller.signal);
code = ''; code = '';
try { try {
const confirmed = await pending; const confirmed = await pending;
if (current !== generation) return; if (current !== generation) return;
if (authGeneration !== browserSession.authenticationGeneration || authStamp !== browserSession.authenticationStamp) throw new Error('Activation superseded');
user = confirmed; user = confirmed;
notifySessionChanged(); notifySessionChanged();
} catch { } catch {
@@ -7,6 +7,11 @@ import {
waitFor, waitFor,
} from "@testing-library/svelte"; } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import {
browserSession,
sessionMutation,
} from "#lib/workspace/auth/session-state.ts";
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
import Activate from "./+page.svelte"; import Activate from "./+page.svelte";
import { import {
authUser, authUser,
@@ -15,9 +20,34 @@ import {
} from "#lib/workspace/auth/api.test-fixtures.ts"; } from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
browserSession.invalidate();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
function boundary(mode: "success" | "pending" | "cancel" | "unknown") { function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function confirmedSession() {
return {
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
};
}
function boundary(
mode:
| "success"
| "pending"
| "cancel"
| "unknown"
| "anonymous"
| "bob"
| "reference-mismatch"
| "unavailable",
) {
Object.defineProperty(window, "isSecureContext", { Object.defineProperty(window, "isSecureContext", {
configurable: true, configurable: true,
value: true, value: true,
@@ -34,7 +64,36 @@ function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
} }
if (String(input) === "/api/auth/activation/complete") { if (String(input) === "/api/auth/activation/complete") {
if (mode === "unknown") throw new Error("private diagnostic"); if (mode === "unknown") throw new Error("private diagnostic");
return Response.json({ user: authUser }); return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (String(input) === "/api/auth/whoami") {
if (mode === "unavailable") {
return new Response("private diagnostic", { status: 503 });
}
if (mode === "anonymous") {
return Response.json({
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
}
const confirmation = confirmedSession();
if (mode === "bob") {
confirmation.actor = {
...confirmation.actor,
user_id: "user-bob",
account_id: "account-bob",
handle: "bob",
display_name: "Bob",
};
}
if (mode === "reference-mismatch") {
confirmation.authentication_reference = "another-authentication";
}
return Response.json(confirmation);
} }
throw new Error("Unexpected endpoint"); throw new Error("Unexpected endpoint");
}); });
@@ -54,12 +113,29 @@ async function submit() {
} }
test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => { test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => {
const { fetcher } = boundary("success"); const { fetcher } = boundary("success");
const confirmation = gate<Response>();
const started = gate<void>();
const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") {
started.resolve();
return confirmation.promise;
}
return original(input, init);
});
render(Activate); render(Activate);
await submit(); await submit();
await started.promise;
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
expect((screen.getByLabelText("Activation code") as HTMLInputElement).value)
.toBe("");
confirmation.resolve(Response.json(confirmedSession()));
await screen.findByText(/registration and activation confirmed/); await screen.findByText(/registration and activation confirmed/);
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([ expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options", "/api/auth/activation/options",
"/api/auth/activation/complete", "/api/auth/activation/complete",
"/api/auth/whoami",
]); ]);
expect(screen.queryByLabelText("Activation code")).toBeNull(); expect(screen.queryByLabelText("Activation code")).toBeNull();
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe( expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe(
@@ -99,3 +175,106 @@ for (const mode of ["cancel", "unknown"] as const) {
.toBe(""); .toBe("");
}); });
} }
for (
const mode of [
"anonymous",
"bob",
"reference-mismatch",
"unavailable",
] as const
) {
test(
"activation " + mode +
" confirmation never claims signed in after successful completion",
async () => {
const { fetcher, create } = boundary(mode);
const changed = vi.fn();
window.addEventListener(SESSION_CHANGED_EVENT, changed);
try {
render(Activate);
await submit();
await screen.findByRole("alert");
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
expect(create).toHaveBeenCalledOnce();
expect(screen.getByRole("alert").textContent).toContain(
"Passkey login",
);
expect(screen.getByRole("alert").textContent).not.toContain(
"private diagnostic",
);
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
expect(
(screen.getByLabelText("Activation code") as HTMLInputElement).value,
).toBe("");
expect(changed).not.toHaveBeenCalled();
} finally {
window.removeEventListener(SESSION_CHANGED_EVENT, changed);
}
},
);
}
test("logout during the activation prompt fences a late credential before completion", async () => {
const { fetcher, create } = boundary("success");
const credential = gate<Credential>();
create.mockReturnValueOnce(credential.promise);
render(Activate);
await submit();
await waitFor(() => expect(create).toHaveBeenCalledOnce());
sessionMutation("logout");
credential.resolve(credentialFixture());
await screen.findByRole("alert");
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options",
]);
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
});
for (const phase of ["prompt", "confirmation"] as const) {
for (const reason of ["logout", "authentication"] as const) {
test(`activation UI rejects shared ${reason} stamp during ${phase} before event delivery`, async () => {
const { create, fetcher } = boundary("success");
const started = gate<void>();
const pending = gate<void>();
const request = fetcher.getMockImplementation()!;
if (phase === "prompt") {
create.mockImplementation(async () => {
started.resolve();
await pending.promise;
return credentialFixture();
});
} else {fetcher.mockImplementation(async (input, init) => {
if (String(input) === "/api/auth/whoami") {
started.resolve();
await pending.promise;
}
return await request(input, init);
});}
render(Activate);
await submit();
await started.promise;
const generation = browserSession.authenticationGeneration;
localStorage.setItem(
"yoi.browser-auth-generation",
JSON.stringify({ id: crypto.randomUUID(), reason }),
);
expect(browserSession.authenticationGeneration).toBe(generation);
pending.resolve();
await screen.findByRole("alert");
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(
fetcher.mock.calls.filter(([path]) =>
path === "/api/auth/activation/complete"
),
).toHaveLength(phase === "prompt" ? 0 : 1);
});
}
}
+11 -16
View File
@@ -53,6 +53,7 @@ function boundary() {
if (state.sessionGate) return state.sessionGate.promise; if (state.sessionGate) return state.sessionGate.promise;
return state.whoamiStatus === 200 return state.whoamiStatus === 200
? Response.json({ ? Response.json({
authentication_reference: state.signedIn ? "fixture-authentication" : null,
session: state.signedIn session: state.signedIn
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -118,8 +119,8 @@ function boundary() {
}; };
} }
for (const policy of [true, false, "error", "loading"] as const) { for (const policy of [true, false, "error", "loading"] as const) {
test(`public home keeps login and exposes registration only for confirmed allowed policy: ${policy}`, async () => { test(`public home routes account actions through login without loading registration policy: ${policy}`, async () => {
const { state, workspaceCalls } = boundary(); const { state, fetcher, workspaceCalls } = boundary();
state.policy = policy; state.policy = policy;
state.catalogStatus = 401; state.catalogStatus = 401;
render(Home); render(Home);
@@ -128,22 +129,15 @@ for (const policy of [true, false, "error", "loading"] as const) {
"href", "href",
), ),
).toBe("/login"); ).toBe("/login");
if (policy === true) {
expect(
(await screen.findByRole("link", { name: "Create a new account" }))
.getAttribute("href"),
).toBe("/register?returnTo=%2Fdashboard");
} else {
await screen.findByText(
policy === false
? /registration is disabled/
: policy === "error"
? /availability is unknown/
: /Checking registration/,
);
expect(screen.queryByRole("link", { name: "Create a new account" })) expect(screen.queryByRole("link", { name: "Create a new account" }))
.toBeNull(); .toBeNull();
} expect(
screen.queryByRole("link", { name: "Activate an account with a code" }),
)
.toBeNull();
expect(screen.queryByText(/registration/i)).toBeNull();
expect(fetcher.mock.calls.map(([path]) => String(path)))
.not.toContain("/api/auth/registration-policy");
expect(workspaceCalls()).toEqual([]); expect(workspaceCalls()).toEqual([]);
expect(screen.queryByText(/No accessible Workspaces/)).toBeNull(); expect(screen.queryByText(/No accessible Workspaces/)).toBeNull();
}); });
@@ -333,6 +327,7 @@ test("pending create remains single during an explicit authentication change and
expect(goto).not.toHaveBeenCalled(); expect(goto).not.toHaveBeenCalled();
state.sessionGate.resolve( state.sessionGate.resolve(
Response.json({ Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "browser_session" }, actor: { ...authUser, auth_method: "browser_session" },
}), }),
@@ -22,6 +22,7 @@ function boundary(
const fetcher = vi.fn<typeof fetch>(async (input, init) => { const fetcher = vi.fn<typeof fetch>(async (input, init) => {
if (input === "/api/auth/whoami") { if (input === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
session: mode !== "anonymous" session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -27,6 +27,7 @@ function boundary(
const path = String(input); const path = String(input);
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: persona !== "anonymous" ? "fixture-authentication" : null,
session: persona !== "anonymous" session: persona !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts"; import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
import { loginWithPasskey } from '#lib/workspace/auth/api.ts'; import { loginWithPasskey, loadWhoami } from '#lib/workspace/auth/api.ts';
import { observeAuthOperation } from '#lib/workspace/auth/operation.ts';
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts'; import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts'; import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
import type { AuthUser } from '#lib/workspace/auth/model.ts'; import type { AuthUser } from '#lib/workspace/auth/model.ts';
@@ -12,10 +13,12 @@
let verifiedLogin = $state(false); let verifiedLogin = $state(false);
let error = $state(''); let error = $state('');
const controller = new AbortController(); const controller = new AbortController();
let loginController: AbortController | null = null;
let active = true; let active = true;
let verifiedGeneration: number | null = null; let verifiedGeneration: number | null = null;
const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration; let verifiedStamp: string | null = null;
const isCurrent = (generation: number, stamp: string | null) => active && generation === browserSession.authenticationGeneration && stamp === browserSession.authenticationStamp;
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。 // 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
function matchesSession(expected: AuthUser): boolean { function matchesSession(expected: AuthUser): boolean {
const snapshot = browserSession.snapshot; const snapshot = browserSession.snapshot;
@@ -28,9 +31,10 @@
return browserSession.subscribe(() => { return browserSession.subscribe(() => {
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。 // 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。 // 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) { if (!user || verifiedGeneration !== browserSession.authenticationGeneration || verifiedStamp !== browserSession.authenticationStamp || !matchesSession(user)) {
verifiedLogin = false; verifiedLogin = false;
verifiedGeneration = null; verifiedGeneration = null;
verifiedStamp = null;
} }
}); });
}); });
@@ -61,34 +65,56 @@
error = ''; error = '';
sessionMutation(); sessionMutation();
let generation = browserSession.authenticationGeneration; let generation = browserSession.authenticationGeneration;
let stamp = browserSession.authenticationStamp;
const operation = new AbortController();
loginController = operation;
let promptGeneration: number | null = generation;
const unsubscribe = browserSession.subscribe(() => {
if (promptGeneration !== null && !isCurrent(promptGeneration, stamp)) operation.abort();
});
try { try {
const signedIn = await loginWithPasskey(controller.signal); const initial = await loadWhoami(operation.signal);
if (!isCurrent(generation)) throw new Error('Login superseded'); if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) { observeAuthOperation(operation.signal, {
expected: initial, phase: () => {}, additionAttempt: () => {},
verifySession: async () => {
if (!isCurrent(generation, stamp)) operation.abort();
operation.signal.throwIfAborted();
const current = await loadWhoami(operation.signal);
if (!isCurrent(generation, stamp) || initial.actor?.user_id !== current.actor?.user_id || initial.actor?.account_id !== current.actor?.account_id || initial.authentication_reference !== current.authentication_reference) throw new Error('Session changed');
},
});
const signedIn = await loginWithPasskey(operation.signal);
if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
if (signedIn.user.user_id !== expected.user_id || signedIn.user.account_id !== expected.account_id) {
throw new Error('Unexpected login actor'); throw new Error('Unexpected login actor');
} }
// Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。 // Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。
promptGeneration = null;
sessionMutation(); sessionMutation();
generation = browserSession.authenticationGeneration; generation = browserSession.authenticationGeneration;
stamp = browserSession.authenticationStamp;
await browserSession.refresh(); await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified'); if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session not verified');
const access = await loadHostingAccess(); const access = await loadHostingAccess();
if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified'); if (!isCurrent(generation, stamp) || !access.is_admin) throw new Error('Access not verified');
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。 // accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
await browserSession.refresh(); await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded'); if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session superseded');
verifiedGeneration = generation; verifiedGeneration = generation;
verifiedStamp = stamp;
verifiedLogin = true; verifiedLogin = true;
notifySessionChanged(); notifySessionChanged();
} catch { } catch {
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.'; if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
// 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを // 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。 // invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
if (isCurrent(generation)) { if (isCurrent(generation, stamp)) {
sessionMutation(); sessionMutation();
await browserSession.refresh(); await browserSession.refresh();
} }
} finally { } finally {
promptGeneration = null; unsubscribe(); loginController = null;
if (active) busy = false; if (active) busy = false;
} }
} }
@@ -97,6 +123,7 @@
active = false; active = false;
token = ''; token = '';
controller.abort(); controller.abort();
loginController?.abort();
}); });
</script> </script>
@@ -36,10 +36,24 @@ function boundary(
const create = vi.fn(async () => credentialFixture()); const create = vi.fn(async () => credentialFixture());
const get = vi.fn(async () => credentialFixture(true)); const get = vi.fn(async () => credentialFixture(true));
vi.stubGlobal("navigator", { credentials: { create, get } }); vi.stubGlobal("navigator", { credentials: { create, get } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(
async (input, init) => {
const path = String(input); const path = String(input);
if (
path === "/api/auth/whoami" &&
new Headers(init?.headers).has("authorization")
) {
return status === 200
? Response.json({
actor: { ...authUser, auth_method: "api_token" },
authentication_reference: "fixture-bearer-authentication",
session: { kind: "authenticated", expires_at: null },
})
: new Response("host-test-token", { status });
}
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: actor ? "fixture-authentication" : null,
session: actor session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -55,18 +69,23 @@ function boundary(
: new Response("host-test-token", { status }); : new Response("host-test-token", { status });
} }
if (path === "/api/auth/passkeys/addition/complete") { if (path === "/api/auth/passkeys/addition/complete") {
return Response.json({ user: authUser }); return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
} }
if (path === "/api/auth/passkeys/login/options") { if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions); return Response.json(loginOptions);
} }
if (path === "/api/auth/passkeys/login/complete") { if (path === "/api/auth/passkeys/login/complete") {
return failLogin return failLogin ? new Response(null, { status: 401 }) : Response.json({
? new Response(null, { status: 401 }) user: authUser,
: Response.json({ user: authUser }); authentication_reference: "fixture-authentication",
});
} }
throw new Error(`Unexpected API: ${path}`); throw new Error(`Unexpected API: ${path}`);
}); },
);
vi.stubGlobal("fetch", fetcher); vi.stubGlobal("fetch", fetcher);
return { fetcher, create, get }; return { fetcher, create, get };
} }
@@ -117,8 +136,10 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
for (const [path, init] of requests) { for (const [path, init] of requests) {
expect(String(path)).not.toContain("host-test-token"); expect(String(path)).not.toContain("host-test-token");
expect(String(init?.body)).not.toContain("host-test-token"); expect(String(init?.body)).not.toContain("host-test-token");
expect(init?.credentials).toBe("omit");
expect(new Headers(init?.headers).has("cookie")).toBe(false);
expect(new Headers(init?.headers).get("authorization")).toBe( expect(new Headers(init?.headers).get("authorization")).toBe(
String(path).includes("/addition/") ? "Bearer host-test-token" : null, "Bearer host-test-token",
); );
} }
await fireEvent.click( await fireEvent.click(
@@ -130,7 +151,7 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
expect(settings.getAttribute("href")).toBe("/hosting/settings"); expect(settings.getAttribute("href")).toBe("/hosting/settings");
expect(get).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami")) expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
.toHaveLength(2); .toHaveLength(6);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access")) expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
.toHaveLength(1); .toHaveLength(1);
expect( expect(
@@ -179,8 +200,10 @@ test("a different cookie actor cannot be accepted as the enrolled administrator"
const { fetcher } = boundary(); const { fetcher } = boundary();
const original = fetcher.getMockImplementation()!; const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami" String(input) === "/api/auth/whoami" &&
!new Headers(init?.headers).has("authorization")
? Response.json({ ? Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { actor: {
...authUser, ...authUser,
@@ -221,7 +244,12 @@ test("leaving during the passkey prompt aborts enrollment and prevents later com
unmount(); unmount();
expect(signal.aborted).toBe(true); expect(signal.aborted).toBe(true);
resolve(credentialFixture()); resolve(credentialFixture());
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); await waitFor(() =>
expect(fetcher.mock.calls.map(([path]) => path)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
])
);
const { unmount: unmountAgain } = render(Setup); const { unmount: unmountAgain } = render(Setup);
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value) expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe(""); .toBe("");
@@ -248,7 +276,7 @@ test("completion response loss does not claim success and points to login before
); );
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value) expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe(""); .toBe("");
expect(fetcher).toHaveBeenCalledTimes(2); expect(fetcher).toHaveBeenCalledTimes(3);
expect(screen.queryByRole("button", { name: "Sign in with new passkey" })) expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
.toBeNull(); .toBeNull();
}); });
@@ -259,6 +287,7 @@ test("API token identity is not mistaken for a verified browser session", async
fetcher.mockImplementation(async (input, init) => fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami" String(input) === "/api/auth/whoami"
? Response.json({ ? Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "api_token" }, actor: { ...authUser, auth_method: "api_token" },
}) })
@@ -292,6 +321,7 @@ const replacedActor = {
function identityResponse(actor: typeof setupActor | null) { function identityResponse(actor: typeof setupActor | null) {
return Response.json({ return Response.json({
actor, actor,
authentication_reference: actor ? "fixture-authentication" : null,
session: actor session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -317,15 +347,25 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
const started = gate<void>(); const started = gate<void>();
const pending = gate<Response>(); const pending = gate<Response>();
let current: typeof setupActor | null = setupActor; let current: typeof setupActor | null = setupActor;
let reads = 0; let loginCompleted = false;
let accessRead = false;
let held = false;
fetcher.mockImplementation((input, init) => { fetcher.mockImplementation((input, init) => {
const path = String(input); const path = String(input);
// Bearer identity resolution belongs to enrollment, never the cookie-login gates.
if (new Headers(init?.headers).has("authorization")) {
return original(input, init);
}
if (path === "/api/auth/passkeys/login/complete") loginCompleted = true;
if (path === "/api/hosting/access") accessRead = true;
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
++reads;
if ( if (
(phase === "whoami" && reads === 1) || !held && loginCompleted && (
(phase === "confirmation" && reads === 2) (phase === "whoami" && !accessRead) ||
(phase === "confirmation" && accessRead)
)
) { ) {
held = true;
started.resolve(); started.resolve();
return pending.promise; return pending.promise;
} }
@@ -357,7 +397,10 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
phase === "access" phase === "access"
? Response.json({ is_admin: true }) ? Response.json({ is_admin: true })
: phase === "login" : phase === "login"
? Response.json({ user: authUser }) ? Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
})
: identityResponse(setupActor), : identityResponse(setupActor),
); );
await waitFor(() => await waitFor(() =>
@@ -421,3 +464,154 @@ for (const replacement of [null, replacedActor]) {
).toBe(false); ).toBe(false);
}); });
} }
test("setup logout during native login prompt aborts and never submits the late credential", async () => {
const { get, fetcher } = boundary();
const prompted = gate<void>();
const credential = gate<Credential>();
get.mockImplementation(() => {
prompted.resolve();
return credential.promise;
});
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await prompted.promise;
await changedInAnotherTab(null);
const signal = (get.mock.calls[0] as unknown as [CredentialRequestOptions])[0]
.signal!;
expect(signal.aborted).toBe(true);
credential.resolve(credentialFixture(true));
await screen.findByRole("alert");
expect(
fetcher.mock.calls.some(([path]) =>
path === "/api/auth/passkeys/login/complete"
),
).toBe(false);
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
});
test("setup final confirmation rejects replacement session for the same owner", async () => {
const { fetcher } = boundary();
const request = fetcher.getMockImplementation()!;
let accessRead = false;
fetcher.mockImplementation(async (input, init) => {
if (String(input) === "/api/hosting/access") accessRead = true;
if (String(input) === "/api/auth/whoami" && accessRead) {
return Response.json({
actor: setupActor,
authentication_reference: "same-owner-different-session",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
}
return await request(input, init);
});
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await screen.findByRole("alert");
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
});
for (
const phase of [
"initial",
"options",
"prompt",
"pre-complete",
"complete",
"whoami",
"access",
"confirmation",
] as const
) {
for (const reason of ["logout", "authentication"] as const) {
test(`setup fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
const { fetcher, get } = boundary();
const request = fetcher.getMockImplementation()!;
const started = gate<void>();
const pending = gate<void>();
let optionsRead = false,
promptReturned = false,
completionSent = false,
accessRead = false,
held = false;
const hold = async () => {
held = true;
started.resolve();
await pending.promise;
};
get.mockImplementation(async () => {
if (phase === "prompt") await hold();
promptReturned = true;
return credentialFixture(true);
});
fetcher.mockImplementation(async (input, init) => {
if (new Headers(init?.headers).has("authorization")) {
return await request(input, init);
}
const path = String(input);
const point = path === "/api/auth/whoami"
? !optionsRead
? "initial"
: !completionSent && promptReturned
? "pre-complete"
: accessRead
? "confirmation"
: "whoami"
: path.endsWith("/login/options")
? "options"
: path.endsWith("/login/complete")
? "complete"
: path === "/api/hosting/access"
? "access"
: null;
if (point === "options") optionsRead = true;
if (point === "complete") completionSent = true;
if (point === "access") accessRead = true;
const response = await request(input, init);
if (!held && point === phase) await hold();
return response;
});
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await started.promise;
const generation = browserSession.authenticationGeneration;
const stamp = JSON.stringify({ id: crypto.randomUUID(), reason });
// 同一documentのsetItemはstorage eventを配送しない。generationを直接変えず順序を固定する。
localStorage.setItem("yoi.browser-auth-generation", stamp);
expect(browserSession.authenticationGeneration).toBe(generation);
pending.resolve();
await screen.findByRole("alert");
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Sign in with new passkey",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(
fetcher.mock.calls.filter(([path]) =>
path === "/api/auth/passkeys/login/complete"
),
).toHaveLength(
["complete", "whoami", "access", "confirmation"].includes(phase)
? 1
: 0,
);
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
// 古いcatchが新tabのstampを自身のsessionMutationで上書きしない。
expect(browserSession.authenticationStamp).toBe(stamp);
});
}
}

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