feat: explicit hosting administrators and persistent self-registration policy

This commit is contained in:
2026-10-16 07:26:21 +09:00
parent 7042ec7d50
commit af008f802d
51 changed files with 6342 additions and 85 deletions
+43
View File
@@ -170,6 +170,8 @@ macro_rules! impl_openapi_schema {
impl_openapi_schema!( impl_openapi_schema!(
AuthBootstrapUserRequest, AuthBootstrapUserRequest,
AuthPublicConfig, AuthPublicConfig,
HostingSettings,
HostingAccess,
AuthUserResponse, AuthUserResponse,
DeviceLoginApproveRequest, DeviceLoginApproveRequest,
DeviceLoginApproveResponse, DeviceLoginApproveResponse,
@@ -515,6 +517,28 @@ pub trait ServerApi {
#[get("/api/auth/config", status = 200, error_status = 400)] #[get("/api/auth/config", status = 200, error_status = 400)]
async fn auth_config(&self) -> Result<AuthPublicConfig, RepositoryApiError>; async fn auth_config(&self) -> Result<AuthPublicConfig, RepositoryApiError>;
#[get("/api/auth/registration-policy", status = 200, error_status = 400)]
async fn auth_registration_policy(&self) -> Result<HostingSettings, RepositoryApiError>;
#[get("/api/hosting/access", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)]
async fn hosting_access(
&self,
#[extension] context: ServerRequestContext,
) -> Result<HostingAccess, RepositoryApiError>;
#[get("/api/hosting/settings", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)]
async fn hosting_settings(
&self,
#[extension] context: ServerRequestContext,
) -> Result<HostingSettings, RepositoryApiError>;
#[put("/api/hosting/settings", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)]
async fn hosting_settings_update(
&self,
#[extension] context: ServerRequestContext,
#[body] request: HostingSettings,
) -> Result<HostingSettings, RepositoryApiError>;
#[post( #[post(
"/api/auth/bootstrap-user", "/api/auth/bootstrap-user",
status = 200, status = 200,
@@ -3846,6 +3870,22 @@ pub struct AuthPublicConfig {
pub cookie_name: String, pub cookie_name: String,
} }
/// The only public registration policy. No administrator identities or host secrets are exposed.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct HostingSettings {
pub self_registration_enabled: bool,
}
/// Hosting authority for the current authenticated human, not Workspace permissions.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct HostingAccess {
pub is_admin: bool,
}
/// Authentication method that established the current request actor. /// Authentication method that established the current request actor.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -11395,6 +11435,8 @@ pub fn auth_api_typescript() -> String {
let config = ts_rs::Config::default(); let config = ts_rs::Config::default();
let declarations = [ let declarations = [
AuthPublicConfig::decl(&config), AuthPublicConfig::decl(&config),
HostingSettings::decl(&config),
HostingAccess::decl(&config),
ActorAuthMethod::decl(&config), ActorAuthMethod::decl(&config),
AuthenticatedUser::decl(&config), AuthenticatedUser::decl(&config),
RequestActor::decl(&config), RequestActor::decl(&config),
@@ -14380,6 +14422,7 @@ mod openapi_artifact_tests {
const PUBLIC_UNAUTHENTICATED: &[&str] = &[ const PUBLIC_UNAUTHENTICATED: &[&str] = &[
"health", "health",
"auth_config", "auth_config",
"auth_registration_policy",
"auth_bootstrap_user", "auth_bootstrap_user",
"auth_passkey_registration_options", "auth_passkey_registration_options",
"auth_passkey_registration_complete", "auth_passkey_registration_complete",
@@ -472,7 +472,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
), ),
(96,"Backend Job common notification routing".into()), (96,"Backend Job common notification routing".into()),
(97, "MR notifications and authenticated implementation results".into()), (97, "MR notifications and authenticated implementation results".into()),
(98, "Assignment-independent implementation reports".into()) (98, "Assignment-independent implementation reports".into()),
(99, "Explicit hosting administrators and self-registration policy".into())
] ]
); );
for (table, rows) in retained.iter().zip(original.iter()) { for (table, rows) in retained.iter().zip(original.iter()) {
@@ -54,7 +54,7 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
let before = store.with_conn(|conn| { let before = store.with_conn(|conn| {
// Retention holds are not reporting permissions. // Retention holds are not reporting permissions.
conn.execute("UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w' AND worker_id='replacement'", [])?; conn.execute("UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w' AND worker_id='replacement'", [])?;
assert_eq!(current_schema_version(conn)?, 98); assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_worker_assignments", [], |r| r.get::<_, i64>(0))?, 0); assert_eq!(conn.query_row("SELECT count(*) FROM ticket_worker_assignments", [], |r| r.get::<_, i64>(0))?, 0);
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_table_info('ticket_implementation_results') WHERE name IN ('assignment_id','orchestrator_assignment_id')", [], |r| r.get::<_, i64>(0))?, 0); assert_eq!(conn.query_row("SELECT count(*) FROM pragma_table_info('ticket_implementation_results') WHERE name IN ('assignment_id','orchestrator_assignment_id')", [], |r| r.get::<_, i64>(0))?, 0);
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='implementation_delegation_capture'", [], |r| r.get::<_, i64>(0))?, 0); assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='implementation_delegation_capture'", [], |r| r.get::<_, i64>(0))?, 0);
@@ -372,7 +372,7 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
let (dir, store) = tracking_store_fixture().await; let (dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "delegation", "assign"); tracking_store_assign(&store, "delegation", "assign");
let worker = routing_worker_assign(&store, "implementation").worker; let worker = routing_worker_assign(&store, "implementation").worker;
// Reconstruct schema97 from a fresh schema98 fixture, including its historical capture. // Reconstruct schema97 from a fresh current-schema fixture, including its historical capture.
store.with_conn(|conn| { store.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER notification_mr_event; conn.execute_batch("DROP TRIGGER notification_mr_event;
DROP TRIGGER notification_mr_source; DROP TRIGGER notification_mr_source;
@@ -381,6 +381,8 @@ 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 TABLE hosting_administrators; DROP TABLE hosting_settings;
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
migrate_business_notifications_v96_to_v97(conn)?; migrate_business_notifications_v96_to_v97(conn)?;
assert_eq!(current_schema_version(conn)?, 97); assert_eq!(current_schema_version(conn)?, 97);
@@ -509,6 +511,8 @@ 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 TABLE hosting_administrators; DROP TABLE hosting_settings;
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
conn.execute_batch("INSERT INTO merge_requests VALUES('w','historical-mr','main','open','refs/heads/work','refs/heads/main','3','3'); conn.execute_batch("INSERT INTO merge_requests VALUES('w','historical-mr','main','open','refs/heads/work','refs/heads/main','3','3');
INSERT INTO merge_request_ticket_relations VALUES('w','historical-mr','tracking','implements','3'); INSERT INTO merge_request_ticket_relations VALUES('w','historical-mr','tracking','implements','3');
@@ -663,7 +667,9 @@ 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;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
} }
conn.execute_batch("INSERT INTO merge_requests VALUES('w','actor-mr','main','open','work','main','3','3'); conn.execute_batch("INSERT INTO merge_requests VALUES('w','actor-mr','main','open','work','main','3','3');
INSERT INTO merge_request_ticket_relations VALUES('w','actor-mr','tracking','implements','3');")?; INSERT INTO merge_request_ticket_relations VALUES('w','actor-mr','tracking','implements','3');")?;
@@ -12,6 +12,15 @@ DROP TABLE typed_ticket_events;
DROP TABLE typed_tickets; DROP TABLE typed_tickets;
DROP TABLE workspace_resource_key_counters; DROP TABLE workspace_resource_key_counters;
DROP TABLE workspace_resource_keys; DROP TABLE workspace_resource_keys;
CREATE TABLE hosting_administrators (
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE TABLE hosting_settings (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1))
);
INSERT INTO hosting_settings(singleton, self_registration_enabled) VALUES(1, 0);
CREATE TABLE accounts ( CREATE TABLE accounts (
account_id TEXT PRIMARY KEY, account_id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')), kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')),
+149 -3
View File
@@ -22,6 +22,7 @@ enum Command {
Serve(ServeOptions), Serve(ServeOptions),
Migrate(MigrateOptions), Migrate(MigrateOptions),
BootstrapAuth(BootstrapAuthOptions), BootstrapAuth(BootstrapAuthOptions),
HostingAdmin(HostingAdminOptions),
Skills(SkillsCommand), Skills(SkillsCommand),
Help, Help,
} }
@@ -46,6 +47,13 @@ struct BootstrapAuthOptions {
label: Option<String>, label: Option<String>,
} }
#[derive(Debug)]
struct HostingAdminOptions {
handle: String,
create_user: bool,
database: Option<PathBuf>,
}
#[derive(Debug)] #[derive(Debug)]
struct SkillWorkspaceOptions { struct SkillWorkspaceOptions {
workspace_id: String, workspace_id: String,
@@ -86,6 +94,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Command::Serve(options) => run_serve(options).await, Command::Serve(options) => run_serve(options).await,
Command::Migrate(options) => run_migrate(options), Command::Migrate(options) => run_migrate(options),
Command::BootstrapAuth(options) => run_bootstrap_auth(options), Command::BootstrapAuth(options) => run_bootstrap_auth(options),
Command::HostingAdmin(options) => run_hosting_admin(options),
Command::Skills(command) => run_skills(command), Command::Skills(command) => run_skills(command),
Command::Help => Ok(()), Command::Help => Ok(()),
} }
@@ -100,6 +109,13 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
match command.as_str() { match command.as_str() {
"migrate" => parse_migrate_options(rest).map(Command::Migrate), "migrate" => parse_migrate_options(rest).map(Command::Migrate),
"bootstrap-auth" => parse_bootstrap_auth_options(rest).map(Command::BootstrapAuth), "bootstrap-auth" => parse_bootstrap_auth_options(rest).map(Command::BootstrapAuth),
"hosting-admin" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_hosting_admin_help();
return Ok(Command::Help);
}
parse_hosting_admin_options(rest).map(Command::HostingAdmin)
}
"skills" => parse_skills_command(rest), "skills" => parse_skills_command(rest),
"serve" => { "serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") { if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
@@ -113,7 +129,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help) Ok(Command::Help)
} }
other => Err(CliError(format!( other => Err(CliError(format!(
"unknown command `{other}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`" "unknown command `{other}`; expected `hosting-admin`, `bootstrap-auth`, `migrate`, `skills`, or `serve`"
))), ))),
} }
} }
@@ -224,6 +240,85 @@ fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>
Ok(()) Ok(())
} }
fn run_hosting_admin(options: HostingAdminOptions) -> Result<(), Box<dyn std::error::Error>> {
let database_path = options
.database
.unwrap_or_else(ServerConfig::default_server_database_path);
if let Some(parent) = database_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)?;
}
let store = SqliteWorkspaceStore::open(&database_path)?;
let setup = store.setup_hosting_admin(&options.handle, options.create_user)?;
let mut output = serde_json::json!({
"user": {
"user_id": setup.user.user_id,
"account_id": setup.user.account_id,
"handle": setup.user.handle,
"display_name": setup.user.display_name,
},
"hosting_admin": true,
});
if let Some(bearer) = setup.initial_bearer {
output["access_token"] = bearer.access_token.into();
output["expires_at"] = bearer.expires_at.into();
output["token_type"] = "bearer".into();
}
// Explicit trusted host setup is the only place this secret is printed.
println!("{}", serde_json::to_string(&output)?);
Ok(())
}
fn parse_hosting_admin_options(args: &[String]) -> Result<HostingAdminOptions, CliError> {
let mut handle = None;
let mut database = None;
let mut create_user = false;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
if arg == "--create-user" {
if create_user {
return Err(CliError("--create-user must be provided only once".into()));
}
create_user = true;
index += 1;
continue;
}
let (name, inline) = arg
.split_once('=')
.map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
let target = match name {
"--handle" => &mut handle,
"--database" => &mut database,
other => return Err(CliError(format!("unknown hosting-admin option {other}"))),
};
let value = match inline {
Some(value) => value.to_string(),
None => {
index += 1;
args.get(index)
.filter(|value| !value.starts_with("--"))
.ok_or_else(|| CliError(format!("{name} requires a value")))?
.clone()
}
};
if value.is_empty() {
return Err(CliError(format!("{name} requires a value")));
}
if target.replace(value).is_some() {
return Err(CliError(format!("{name} must be provided only once")));
}
index += 1;
}
Ok(HostingAdminOptions {
handle: handle.ok_or_else(|| CliError("hosting-admin requires --handle".into()))?,
create_user,
database: database.map(PathBuf::from),
})
}
fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box<dyn std::error::Error>> { fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box<dyn std::error::Error>> {
let database_path = ServerConfig::default_server_database_path(); let database_path = ServerConfig::default_server_database_path();
if let Some(parent) = database_path.parent() { if let Some(parent) = database_path.parent() {
@@ -643,7 +738,13 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" "yoi-server\n\nUsage:\n yoi-server hosting-admin --handle <HANDLE> [--create-user] [--database <PATH>]\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
fn print_hosting_admin_help() {
println!(
"yoi-server hosting-admin\n\nUsage:\n yoi-server hosting-admin --handle <HANDLE> [--create-user] [--database <PATH>]\n\nDescription:\n Trusted host-only explicit administrator setup. By default selects an existing known user and grants hosting authority without issuing credentials. --create-user requires a new handle and atomically creates the user, grant and initial bearer (15-minute expiry), printed once to private stdout for passkey addition. Open /hosting/setup on this Server's trusted HTTPS origin (or localhost), enter the token, add a passkey, then explicitly sign in with that key before opening /hosting/settings. Repeating creation fails, never refreshing credentials. Neither mode enables public self-registration. Existing Workspace permissions are unchanged.\n\nOptions:\n --handle <HANDLE> Known user handle, or new handle with --create-user\n --create-user Explicitly create a new user and print its initial bearer\n --database <PATH> Server DB path (default: canonical Yoi server DB)\n -h, --help Print help"
); );
} }
@@ -702,6 +803,51 @@ mod tests {
assert_eq!(error.to_string(), "bootstrap-auth requires --handle"); assert_eq!(error.to_string(), "bootstrap-auth requires --handle");
} }
#[test]
fn hosting_admin_cli_requires_explicit_creation_and_selects_database() {
let args = |values: &[&str]| {
values
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>()
};
let Command::HostingAdmin(existing) =
parse_command(&args(&["hosting-admin", "--handle", "known-user"])).unwrap()
else {
panic!("expected hosting admin")
};
assert!(!existing.create_user);
assert!(existing.database.is_none());
let Command::HostingAdmin(new) = parse_command(&args(&[
"hosting-admin",
"--handle=new-admin",
"--create-user",
"--database=/tmp/isolated.db",
]))
.unwrap() else {
panic!("expected hosting admin")
};
assert!(new.create_user);
assert_eq!(new.handle, "new-admin");
assert_eq!(new.database, Some(PathBuf::from("/tmp/isolated.db")));
for invalid in [
vec![],
vec!["--create-user"],
vec!["--handle"],
vec!["--handle="],
vec!["--handle", "a", "--handle=b"],
vec!["--handle", "a", "--database"],
vec!["--handle", "a", "--database=first", "--database=second"],
vec!["--handle", "a", "--create-user", "--create-user"],
vec!["--handle", "a", "--token", "secret"],
] {
assert!(
parse_hosting_admin_options(&args(&invalid)).is_err(),
"{invalid:?}"
);
}
}
#[test] #[test]
fn parse_migrate_uses_the_shared_schema_path() { fn parse_migrate_uses_the_shared_schema_path() {
let command = parse_command(&[ let command = parse_command(&[
@@ -832,7 +978,7 @@ mod tests {
assert_eq!( assert_eq!(
error.to_string(), error.to_string(),
format!( format!(
"unknown command `{command}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`" "unknown command `{command}`; expected `hosting-admin`, `bootstrap-auth`, `migrate`, `skills`, or `serve`"
) )
); );
} }
+753 -12
View File
@@ -3131,6 +3131,20 @@ async fn authorize_scoped_workspace_request(
)); ));
} }
} }
if mutating || request.headers().contains_key(axum::http::header::UPGRADE) {
let workspace = api
.store
.get_workspace(workspace_id)
.await
.map_err(server_error_response)?;
if !workspace.is_some_and(|workspace| workspace.owner_account_id == actor.account_id) {
return Err(repository_api_rejection(
&request_path,
StatusCode::FORBIDDEN,
"Workspace mutations and interactive control require the owner account",
));
}
}
request.extensions_mut().insert(actor); request.extensions_mut().insert(actor);
require_active_scoped_workspace(api, workspace_id, &request_path).await require_active_scoped_workspace(api, workspace_id, &request_path).await
} }
@@ -3292,8 +3306,23 @@ async fn authorize_workspace_api_request(
); );
} }
} }
if !public_server_api
&& (mutating || request.headers().contains_key(axum::http::header::UPGRADE))
{
let workspace = match api.store.get_workspace(&workspace_id).await {
Ok(workspace) => workspace,
Err(error) => return server_error_response(error),
};
if !workspace.is_some_and(|workspace| workspace.owner_account_id == actor.account_id) {
return repository_api_rejection(
&request_path,
StatusCode::FORBIDDEN,
"Workspace mutations and interactive control require the owner account",
);
}
}
request.extensions_mut().insert(actor); request.extensions_mut().insert(actor);
if mutating { if mutating && !public_server_api {
let workspace = match api.store.get_workspace(&workspace_id).await { let workspace = match api.store.get_workspace(&workspace_id).await {
Ok(workspace) => workspace, Ok(workspace) => workspace,
Err(error) => return server_error_response(error), Err(error) => return server_error_response(error),
@@ -3358,6 +3387,11 @@ async fn dispatch_workspace_request(
request: Request, request: Request,
) -> Response { ) -> Response {
let path = request.uri().path().to_owned(); let path = request.uri().path().to_owned();
// Hosting setup/settings belong to the Server, including zero or multiple
// Workspaces. Serving their public shell does not grant their API authority.
if path.starts_with("/hosting/") {
return serve_server_static_shell(&api, &path).await;
}
let workspace_id = scoped_workspace_id(&path); let workspace_id = scoped_workspace_id(&path);
// Admission is immediate, shared, and cancellation-safe. No resource or // Admission is immediate, shared, and cancellation-safe. No resource or
// business-operation lock may precede proof validation. The lease covers // business-operation lock may precede proof validation. The lease covers
@@ -3464,6 +3498,7 @@ async fn dispatch_admitted_workspace_request(
fn is_server_global_forward(path: &str) -> bool { fn is_server_global_forward(path: &str) -> bool {
path == "/api/auth" path == "/api/auth"
|| path.starts_with("/api/auth/") || path.starts_with("/api/auth/")
|| path.starts_with("/api/hosting/")
|| path == "/health" || path == "/health"
|| path == "/" || path == "/"
|| path.starts_with("/_app/") || path.starts_with("/_app/")
@@ -6700,6 +6735,13 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
)) ))
.layer(middleware::from_fn(attach_server_origin_context)); .layer(middleware::from_fn(attach_server_origin_context));
let actor_context = Router::new() let actor_context = Router::new()
.merge(server_api::server_api_axum::hosting_access(service.clone()))
.merge(server_api::server_api_axum::hosting_settings(
service.clone(),
))
.merge(server_api::server_api_axum::hosting_settings_update(
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(),
)) ))
@@ -6716,6 +6758,9 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
)); ));
Router::new() Router::new()
.merge(server_api::server_api_axum::auth_config(service.clone())) .merge(server_api::server_api_axum::auth_config(service.clone()))
.merge(server_api::server_api_axum::auth_registration_policy(
service.clone(),
))
.merge(server_api::server_api_axum::auth_bootstrap_user( .merge(server_api::server_api_axum::auth_bootstrap_user(
service.clone(), service.clone(),
)) ))
@@ -6730,6 +6775,16 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
.merge(server_api::server_api_axum::auth_device_login_poll(service)) .merge(server_api::server_api_axum::auth_device_login_poll(service))
.merge(origin_context) .merge(origin_context)
.merge(actor_context) .merge(actor_context)
.layer(middleware::map_response(
|mut response: Response| async move {
// Current role/policy and authentication responses must not be cached
// across credential revocation or policy changes.
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
},
))
} }
fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router { fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router {
@@ -7611,6 +7666,78 @@ impl server_api::ServerApi for ServerApiContractService {
Ok(auth_public_config(&self.auth_api().config)) Ok(auth_public_config(&self.auth_api().config))
} }
async fn auth_registration_policy(
&self,
) -> std::result::Result<server_api::HostingSettings, server_api::RepositoryApiError> {
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
api.store
.hosting_self_registration_enabled()
.map(|self_registration_enabled| server_api::HostingSettings {
self_registration_enabled,
})
})
.await
.map_err(|error| ApiError::from(error).into_repository_api_error())
}
async fn hosting_access(
&self,
context: server_api::ServerRequestContext,
) -> std::result::Result<server_api::HostingAccess, server_api::RepositoryApiError> {
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let actor = live_hosting_actor(&api, &context, false)?;
Ok::<_, ApiError>(server_api::HostingAccess {
is_admin: api.store.is_hosting_admin(&actor.user_id)?,
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn hosting_settings(
&self,
context: server_api::ServerRequestContext,
) -> std::result::Result<server_api::HostingSettings, server_api::RepositoryApiError> {
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let actor = live_hosting_actor(&api, &context, false)?;
require_hosting_admin(&api, &actor)?;
Ok::<_, ApiError>(server_api::HostingSettings {
self_registration_enabled: api.store.hosting_self_registration_enabled()?,
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn hosting_settings_update(
&self,
context: server_api::ServerRequestContext,
request: server_api::HostingSettings,
) -> std::result::Result<server_api::HostingSettings, server_api::RepositoryApiError> {
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let actor = live_hosting_actor(&api, &context, true)?;
api.store.set_hosting_self_registration_enabled(
&actor,
request.self_registration_enabled,
)?;
Ok::<_, ApiError>(request)
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn auth_bootstrap_user( async fn auth_bootstrap_user(
&self, &self,
request: AuthBootstrapUserRequest, request: AuthBootstrapUserRequest,
@@ -29374,6 +29501,7 @@ async fn post_passkey_registration_options(
api.store api.store
.db_executor() .db_executor()
.run(move || { .run(move || {
require_self_registration(&api)?;
let user = allocate_new_user(&api, &request.handle, request.display_name.as_deref())?; let user = allocate_new_user(&api, &request.handle, request.display_name.as_deref())?;
start_passkey_registration( start_passkey_registration(
&api, &api,
@@ -29552,6 +29680,7 @@ fn complete_passkey_enrollment(
(PasskeyRegistrationTarget::NewAccount { user }, None) (PasskeyRegistrationTarget::NewAccount { user }, None)
if addition_headers.is_none() && challenge.user_id.is_none() => if addition_headers.is_none() && challenge.user_id.is_none() =>
{ {
require_self_registration(api)?;
user.clone() user.clone()
} }
( (
@@ -30122,6 +30251,51 @@ async fn resolve_actor(
Ok(resolve_request_actor(api.store.clone(), headers, &cookie_name).await?) Ok(resolve_request_actor(api.store.clone(), headers, &cookie_name).await?)
} }
// Hosting endpoints re-resolve actual transport credentials, not actor-like headers,
// JSON or a stale middleware snapshot. The store rechecks live admin authority in
// the settings transaction; cookie writes also enforce Origin at this direct entry.
fn live_hosting_actor(
api: &ServerAuthApi,
context: &server_api::ServerRequestContext,
mutating: bool,
) -> ApiResult<RequestActor> {
let headers = contract_request_headers(context)
.map_err(|error| Error::WorkspacePermissionDenied(error.message))?;
let auth = auth_public_config(&api.config);
let actor = crate::auth::resolve_request_actor_sync(
api.store.as_ref(),
crate::auth::parse_bearer(&headers),
parse_cookie(&headers, &auth.cookie_name),
)?
.ok_or_else(|| auth_error("auth_required", "current authentication required"))?;
if mutating
&& actor.auth_method == ActorAuthMethod::BrowserSession
&& request_origin(&headers).as_deref() != Some(auth.origin.as_str())
{
return Err(Error::WorkspacePermissionDenied(
"hosting settings require configured Browser origin".into(),
)
.into());
}
Ok(actor)
}
fn require_hosting_admin(api: &ServerAuthApi, actor: &RequestActor) -> ApiResult<()> {
if !api.store.is_hosting_admin(&actor.user_id)? {
return Err(
Error::WorkspacePermissionDenied("hosting administrator required".into()).into(),
);
}
Ok(())
}
fn require_self_registration(api: &ServerAuthApi) -> ApiResult<()> {
if !api.store.hosting_self_registration_enabled()? {
return Err(Error::WorkspacePermissionDenied("self_registration_disabled".into()).into());
}
Ok(())
}
async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult<RequestActor> { async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult<RequestActor> {
resolve_actor(api, headers).await?.ok_or_else(|| { resolve_actor(api, headers).await?.ok_or_else(|| {
auth_error( auth_error(
@@ -40032,10 +40206,532 @@ mod tests {
.unwrap(); .unwrap();
} }
/// Router and direct trait admission use real SQLite authority and credentials.
/// Only Runtime execution is replaced by the shared isolated test_api fixture.
/// These tests run in the ordinary workspace-server library suite.
mod hosting_authorization_tests {
use super::*;
use api_macros::HttpError;
use server_api::ServerApi;
async fn response(app: Router, request: Request<Body>) -> (StatusCode, HeaderMap, Value) {
let response = app.oneshot(request).await.unwrap();
let status = response.status();
let headers = response.headers().clone();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(status, headers, body)
}
fn settings_request(token: Option<&str>, method: &str) -> Request<Body> {
let mut request = Request::builder()
.method(method)
.uri("/api/hosting/settings");
if let Some(token) = token {
request = request.header("authorization", format!("Bearer {token}"));
}
request
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"self_registration_enabled":true}"#))
.unwrap()
}
fn context(
token: Option<&str>,
actor: Option<RequestActor>,
) -> server_api::ServerRequestContext {
let mut headers = HeaderMap::new();
if let Some(token) = token {
headers.insert("authorization", format!("Bearer {token}").parse().unwrap());
}
server_request_context(actor, None, None, None, &headers)
}
#[tokio::test]
async fn hosting_shell_is_server_scoped_with_zero_one_or_multiple_workspaces() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_server_config(dir.path());
let assets = dir.path().join("static");
fs::create_dir_all(&assets).unwrap();
fs::write(
assets.join("index.html"),
"<!doctype html><title>Hosting shell</title>",
)
.unwrap();
config.static_assets_dir = Some(assets);
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
let app =
workspace_server_router(WorkspaceServerApi::new(config.clone(), store.clone()));
for count in 0..=2 {
if count > 0 {
config.workspace_id = Uuid::now_v7().to_string();
seed_test_registered_workspace(store.as_ref(), &config)
.await
.unwrap();
}
for path in ["/hosting/settings", "/hosting/setup"] {
let response = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{count}: {path}");
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert!(String::from_utf8_lossy(&body).contains("Hosting shell"));
}
}
}
#[tokio::test]
async fn public_registration_policy_is_noncacheable_boolean_and_tracks_persisted_setting() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let app = build_router(api.clone());
let public = || {
Request::builder()
.uri("/api/auth/registration-policy")
.body(Body::empty())
.unwrap()
};
let (status, headers, body) = response(app.clone(), public()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({"self_registration_enabled":false}));
assert_eq!(headers.get(CACHE_CONTROL).unwrap(), "no-store");
let admin = api
.config_store
.setup_hosting_admin("policy-admin", true)
.unwrap();
let token = admin.initial_bearer.unwrap().access_token;
let (status, _, body) =
response(app.clone(), settings_request(Some(&token), "PUT")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({"self_registration_enabled":true}));
let reopened = SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap();
assert!(reopened.hosting_self_registration_enabled().unwrap());
let (status, headers, body) = response(app, public()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers.get(CACHE_CONTROL).unwrap(), "no-store");
// Exact public shape excludes administrator identities, bearer and setup secrets.
assert_eq!(body, json!({"self_registration_enabled":true}));
}
#[tokio::test]
async fn hosting_router_rejects_anonymous_ordinary_and_forged_roles_and_rechecks_admin_revocation()
{
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let ordinary = seed_test_api_token(api.store.as_ref(), "hosting-ordinary");
let admin = api
.config_store
.setup_hosting_admin("hosting-admin", true)
.unwrap();
let token = admin.initial_bearer.unwrap().access_token;
let app = build_router(api.clone());
for token in [None, Some(ordinary.as_str())] {
for method in ["GET", "PUT"] {
let mut request = settings_request(token, method);
request
.headers_mut()
.insert("x-yoi-role", "hosting-admin".parse().unwrap());
request
.headers_mut()
.insert("x-yoi-user-id", admin.user.user_id.parse().unwrap());
request
.headers_mut()
.insert("x-yoi-hosting-admin", "true".parse().unwrap());
assert_eq!(
response(app.clone(), request).await.0,
StatusCode::FORBIDDEN
);
}
}
let forged = Request::builder().method("PUT").uri("/api/hosting/settings")
.header("authorization", format!("Bearer {ordinary}"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(json!({"self_registration_enabled":true,"role":"hosting-admin","actor":admin.user,"is_admin":true}).to_string())).unwrap();
// Public JSON cannot select an actor or add a role.
assert_eq!(
response(app.clone(), forged).await.0,
StatusCode::UNPROCESSABLE_ENTITY
);
assert!(!api.store.hosting_self_registration_enabled().unwrap());
for (token, is_admin) in [(&ordinary, false), (&token, true)] {
let request = Request::builder()
.uri("/api/hosting/access")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let (status, _, body) = response(app.clone(), request).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({"is_admin":is_admin}));
}
assert_eq!(
response(app.clone(), settings_request(Some(&token), "PUT"))
.await
.0,
StatusCode::OK
);
api.config_store
.with_conn(|conn| {
conn.execute(
"DELETE FROM hosting_administrators WHERE user_id=?1",
[&admin.user.user_id],
)?;
Ok(())
})
.unwrap();
for method in ["GET", "PUT"] {
assert_eq!(
response(app.clone(), settings_request(Some(&token), method))
.await
.0,
StatusCode::FORBIDDEN
);
}
assert!(api.store.hosting_self_registration_enabled().unwrap());
}
#[tokio::test]
async fn direct_hosting_trait_uses_live_transport_not_forged_or_stale_actor_context() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let ordinary = seed_test_api_token(api.store.as_ref(), "direct-hosting-ordinary");
let admin = api
.config_store
.setup_hosting_admin("direct-hosting-admin", true)
.unwrap();
let token = admin.initial_bearer.unwrap().access_token;
let admin_actor = RequestActor {
user_id: admin.user.user_id.clone(),
account_id: admin.user.account_id.clone(),
handle: admin.user.handle.clone(),
display_name: admin.user.display_name.clone(),
auth_method: ActorAuthMethod::ApiToken,
};
let service = ServerApiContractService::Workspace(api.clone());
let update = || server_api::HostingSettings {
self_registration_enabled: true,
};
for input in [
context(None, Some(admin_actor.clone())),
context(Some(&ordinary), Some(admin_actor.clone())),
] {
assert_eq!(
service
.hosting_settings(input.clone())
.await
.unwrap_err()
.status_code(),
403
);
assert_eq!(
service
.hosting_settings_update(input, update())
.await
.unwrap_err()
.status_code(),
403
);
}
assert!(!api.store.hosting_self_registration_enabled().unwrap());
// Conversely, a bogus cached actor cannot replace a valid current credential.
let live = context(Some(&token), Some(test_owner_actor()));
assert!(service.hosting_access(live.clone()).await.unwrap().is_admin);
service
.hosting_settings_update(live.clone(), update())
.await
.unwrap();
api.config_store
.with_conn(|conn| {
conn.execute(
"UPDATE api_tokens SET revoked_at='2026-01-02T00:00:00Z' WHERE user_id=?1",
[&admin.user.user_id],
)?;
Ok(())
})
.unwrap();
assert_eq!(
service
.hosting_access(live.clone())
.await
.unwrap_err()
.status_code(),
403
);
assert_eq!(
service
.hosting_settings_update(
live,
server_api::HostingSettings {
self_registration_enabled: false
}
)
.await
.unwrap_err()
.status_code(),
403
);
assert!(api.store.hosting_self_registration_enabled().unwrap());
}
#[tokio::test]
async fn direct_hosting_cookie_write_requires_actual_origin_and_current_session() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let admin = api
.config_store
.setup_hosting_admin("cookie-hosting-admin", true)
.unwrap();
api.store
.create_browser_session(&BrowserSessionRecord {
token_hash: crate::auth::token_hash("hosting-browser-session"),
session_id: "hosting-browser-session-id".into(),
user_id: admin.user.user_id.clone(),
created_at: "2026-01-01T00:00:00Z".into(),
expires_at: "2099-01-01T00:00:00Z".into(),
revoked_at: None,
})
.unwrap();
let AuthConfig::Passkey {
origin,
cookie_name,
..
} = &api.config.auth;
let cookie_context = |actual_origin: Option<&str>| {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
format!("{cookie_name}=hosting-browser-session")
.parse()
.unwrap(),
);
if let Some(origin) = actual_origin {
headers.insert(ORIGIN, origin.parse().unwrap());
}
// The apparently trusted origin field must not bypass the retained transport header.
server_request_context(None, None, None, Some(origin.clone()), &headers)
};
let service = ServerApiContractService::Workspace(api.clone());
assert!(
service
.hosting_access(cookie_context(None))
.await
.unwrap()
.is_admin
);
for origin in [None, Some("https://attacker.invalid")] {
assert_eq!(
service
.hosting_settings_update(
cookie_context(origin),
server_api::HostingSettings {
self_registration_enabled: true
}
)
.await
.unwrap_err()
.status_code(),
403
);
assert!(!api.store.hosting_self_registration_enabled().unwrap());
}
service
.hosting_settings_update(
cookie_context(Some(origin)),
server_api::HostingSettings {
self_registration_enabled: true,
},
)
.await
.unwrap();
api.config_store.with_conn(|conn| {
conn.execute("UPDATE browser_sessions SET revoked_at='2026-01-02T00:00:00Z' WHERE session_id='hosting-browser-session-id'", [])?;
Ok(())
}).unwrap();
assert_eq!(
service
.hosting_settings_update(
cookie_context(Some(origin)),
server_api::HostingSettings {
self_registration_enabled: false
}
)
.await
.unwrap_err()
.status_code(),
403
);
assert!(api.store.hosting_self_registration_enabled().unwrap());
}
#[tokio::test]
async fn workspace_owner_mutations_and_upgrade_guards_do_not_expand_for_hosting_admins() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let owner =
seed_test_workspace_owner_token(api.store.as_ref(), "hosting-workspace-owner")
.await;
let foreign = seed_test_api_token(api.store.as_ref(), "hosting-workspace-foreign");
let admin = api
.config_store
.setup_hosting_admin("workspace-foreign-admin", true)
.unwrap();
let admin = admin.initial_bearer.unwrap().access_token;
let backend = browser_ticket_backend(&api).unwrap();
let ticket = backend
.create(ticket::NewTicket::new("Owner-only case changes"))
.unwrap();
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
catalog
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
for (router_name, app) in [
("local", build_router(api.clone())),
("catalog", workspace_server_router(catalog)),
] {
for prefix in [
format!("/api/w/{TEST_WORKSPACE_ID}"),
format!("/workspaces/{TEST_WORKSPACE_ID}"),
] {
let path = format!(
"{prefix}/tickets/{}/assignment-tracking/assignments",
ticket.id
);
for (token, owns_workspace) in
[(&owner, true), (&foreign, false), (&admin, false)]
{
let request = |upgrade: bool| {
let mut request = Request::builder()
.uri(&path)
.header("authorization", format!("Bearer {token}"));
if upgrade {
request = request.header(axum::http::header::UPGRADE, "websocket");
}
request.body(Body::empty()).unwrap()
};
// Existing authenticated read-only projections remain accessible.
assert_eq!(
response(app.clone(), request(false)).await.0,
StatusCode::OK
);
assert_eq!(
response(app.clone(), request(true)).await.0,
if owns_workspace {
StatusCode::OK
} else {
StatusCode::FORBIDDEN
}
);
let websocket = Request::builder()
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/protocol/ws"))
.header("authorization", format!("Bearer {token}"))
.header("connection", "upgrade")
.header("upgrade", "websocket")
.header("sec-websocket-version", "13")
.header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
.body(Body::empty())
.unwrap();
let ws_status = response(app.clone(), websocket).await.0;
if owns_workspace {
assert_eq!(
ws_status,
StatusCode::UPGRADE_REQUIRED,
"in-process router has no Hyper upgrade state, but owner passes admission"
);
} else {
assert_eq!(ws_status, StatusCode::FORBIDDEN);
}
let before = backend.show(ticket.id.clone().into()).unwrap();
let action = if before.meta.workflow_state == ticket::TicketState::Open {
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Completed,
}
} else {
ticket::TicketCaseAction::Reopen
};
let input = case_test_request(
&before,
&format!("{router_name}-{prefix}-{owns_workspace}"),
action,
);
let mut input = serde_json::to_value(input).unwrap();
input.as_object_mut().unwrap().remove("author");
let (mutation_path, input) = if prefix.starts_with("/api") {
(
format!("{prefix}/ticket-tags"),
json!({"operation_key":router_name,"action":{"kind":"create","name":format!("owner-{router_name}")}}),
)
} else {
(format!("{prefix}/tickets/{}/case", ticket.id), input)
};
let mutation = Request::builder()
.method("POST")
.uri(mutation_path)
.header("authorization", format!("Bearer {token}"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(input.to_string()))
.unwrap();
assert_eq!(
response(app.clone(), mutation).await.0,
if owns_workspace {
StatusCode::OK
} else {
StatusCode::FORBIDDEN
}
);
}
}
}
let saved = backend.show(ticket.id.into()).unwrap();
assert_eq!(
saved.case_history.len(),
2,
"only owner canonical mutations persist"
);
let tags = backend
.list_workspace_tags(ticket::TagListRequest::default())
.unwrap();
assert_eq!(tags.len(), 2, "only owner browser mutations persist");
assert!(
!api.store
.is_hosting_admin("user-hosting-workspace-owner")
.unwrap()
);
api.config_store
.setup_hosting_admin("user-hosting-workspace-owner", false)
.unwrap();
request_json_authenticated(build_router(api.clone()), "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"), Some(json!({"operation_key":"admin-own-workspace","action":{"kind":"create","name":"admin-owned"}})), &owner, StatusCode::OK).await;
assert_eq!(
backend
.list_workspace_tags(ticket::TagListRequest::default())
.unwrap()
.len(),
3
);
}
}
async fn seed_test_workspace_owner_token(
store: &dyn ControlPlaneStore,
suffix: &str,
) -> String {
let owner = store
.get_workspace(TEST_WORKSPACE_ID)
.await
.unwrap()
.unwrap();
seed_test_api_token_for_account(store, suffix, owner.owner_account_id)
}
fn seed_test_api_token(store: &dyn ControlPlaneStore, suffix: &str) -> String { fn seed_test_api_token(store: &dyn ControlPlaneStore, suffix: &str) -> String {
let account_id = format!("account-{suffix}"); let account_id = format!("account-{suffix}");
let user_id = format!("user-{suffix}");
let token = format!("api-token-{suffix}");
store store
.upsert_account(&AccountRecord { .upsert_account(&AccountRecord {
account_id: account_id.clone(), account_id: account_id.clone(),
@@ -40046,6 +40742,16 @@ mod tests {
updated_at: "2026-01-01T00:00:00Z".to_owned(), updated_at: "2026-01-01T00:00:00Z".to_owned(),
}) })
.unwrap(); .unwrap();
seed_test_api_token_for_account(store, suffix, account_id)
}
fn seed_test_api_token_for_account(
store: &dyn ControlPlaneStore,
suffix: &str,
account_id: String,
) -> String {
let user_id = format!("user-{suffix}");
let token = format!("api-token-{suffix}");
store store
.upsert_user(&UserRecord { .upsert_user(&UserRecord {
user_id: user_id.clone(), user_id: user_id.clone(),
@@ -50824,6 +51530,7 @@ mod tests {
async fn new() -> Self { async fn new() -> Self {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let mut api = test_api(root.path()).await; let mut api = test_api(root.path()).await;
enable_test_self_registration(&api);
let AuthConfig::Passkey { let AuthConfig::Passkey {
rp_id, rp_id,
origin, origin,
@@ -52295,6 +53002,7 @@ mod tests {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
let mut api = test_api(workspace.path()).await; let mut api = test_api(workspace.path()).await;
enable_test_self_registration(&api);
let AuthConfig::Passkey { let AuthConfig::Passkey {
rp_id, rp_id,
origin, origin,
@@ -56658,6 +57366,27 @@ mod tests {
.unwrap() .unwrap()
} }
// These registration verifier suites explicitly opt in. Their fixture does
// not redefine fresh-install defaults or make the public registrant an admin.
fn enable_test_self_registration(api: &WorkspaceApi) {
let setup = api
.config_store
.setup_hosting_admin("test-policy-admin", true)
.unwrap();
api.store
.set_hosting_self_registration_enabled(
&RequestActor {
user_id: setup.user.user_id,
account_id: setup.user.account_id,
handle: setup.user.handle,
display_name: setup.user.display_name,
auth_method: ActorAuthMethod::ApiToken,
},
true,
)
.unwrap();
}
fn test_owner_actor() -> RequestActor { fn test_owner_actor() -> RequestActor {
RequestActor { RequestActor {
user_id: "owner-user".to_string(), user_id: "owner-user".to_string(),
@@ -58285,7 +59014,7 @@ mod tests {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path()); init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await; let api = test_api(workspace.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "workdir-removal"); let token = seed_test_workspace_owner_token(api.store.as_ref(), "workdir-removal").await;
let workdir_id = "account-occupied-workdir"; let workdir_id = "account-occupied-workdir";
seed_cleanup_workdir_for_runtime( seed_cleanup_workdir_for_runtime(
&api, &api,
@@ -58320,12 +59049,15 @@ mod tests {
.find_workdir_removal_operation_by_intent( .find_workdir_removal_operation_by_intent(
TEST_WORKSPACE_ID, TEST_WORKSPACE_ID,
workdir_id, workdir_id,
"account:account-workdir-removal", &format!("account:account-{TEST_WORKSPACE_ID}"),
reason, reason,
) )
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(operation.source_actor, "account:account-workdir-removal"); assert_eq!(
operation.source_actor,
format!("account:account-{TEST_WORKSPACE_ID}")
);
assert_eq!( assert_eq!(
operation.disposition, operation.disposition,
Some(WorkdirRemovalDisposition::Retained) Some(WorkdirRemovalDisposition::Retained)
@@ -61836,7 +62568,9 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "explicit-runtime-no-fallback"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "explicit-runtime-no-fallback")
.await;
set_test_default_runtime(&api, EMBEDDED_WORKER_RUNTIME_ID); set_test_default_runtime(&api, EMBEDDED_WORKER_RUNTIME_ID);
let operation_id = "workdir-create-explicit-runtime"; let operation_id = "workdir-create-explicit-runtime";
@@ -61881,7 +62615,9 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "migrated-workdir-create-replay"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "migrated-workdir-create-replay")
.await;
let operation_id = "migrated-workdir-create"; let operation_id = "migrated-workdir-create";
// Schema-86 hash of (test-repository, frozen-selector, None, // Schema-86 hash of (test-repository, frozen-selector, None,
// Original Name, sha256:frozen-source), followed by source ordinal 9. // Original Name, sha256:frozen-source), followed by source ordinal 9.
@@ -62002,7 +62738,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "stale-workdir-create-json"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "stale-workdir-create-json").await;
let response = request_json_authenticated( let response = request_json_authenticated(
build_router(api.clone()), build_router(api.clone()),
@@ -62038,7 +62775,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "missing-workdir-repository"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "missing-workdir-repository").await;
let response = request_json_authenticated( let response = request_json_authenticated(
build_router(api.clone()), build_router(api.clone()),
@@ -62073,7 +62811,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path()); init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "failed-default-resolution"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "failed-default-resolution").await;
set_test_default_runtime(&api, EMBEDDED_WORKER_RUNTIME_ID); set_test_default_runtime(&api, EMBEDDED_WORKER_RUNTIME_ID);
let original_projection_digest = api let original_projection_digest = api
.config_store .config_store
@@ -65769,7 +66508,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn passkey_registration_rejects_unverified_credential_response() { async fn passkey_registration_rejects_unverified_credential_response() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await; let api = test_api(dir.path()).await;
enable_test_self_registration(&api);
let app = build_inner_router(api);
let registration_options = post_json( let registration_options = post_json(
app.clone(), app.clone(),
@@ -506,7 +506,7 @@ impl Drop for AbortServer {
async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real_http() { async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real_http() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let api = test_api(temp.path()).await; let api = test_api(temp.path()).await;
seed_test_api_token(api.store.as_ref(), "drive-client"); seed_test_workspace_owner_token(api.store.as_ref(), "drive-client").await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap()); let url = format!("http://{}", listener.local_addr().unwrap());
let app = build_router(api); let app = build_router(api);
@@ -32,6 +32,7 @@ struct RealDriveFixture {
api: WorkspaceApi, api: WorkspaceApi,
owner: String, owner: String,
second: String, second: String,
foreign: String,
_temp: tempfile::TempDir, _temp: tempfile::TempDir,
} }
impl RealDriveFixture { impl RealDriveFixture {
@@ -41,7 +42,7 @@ impl RealDriveFixture {
config.repositories.clear(); config.repositories.clear();
let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap());
let owner = seed_test_api_token(store.as_ref(), "drive-web-owner"); let owner = seed_test_api_token(store.as_ref(), "drive-web-owner");
let second = seed_test_api_token(store.as_ref(), "drive-web-second"); let foreign = seed_test_api_token(store.as_ref(), "drive-web-foreign");
let server_api = WorkspaceServerApi::new_for_test(config, store.clone()); let server_api = WorkspaceServerApi::new_for_test(config, store.clone());
let server = LoopbackServer::start(workspace_server_router(server_api.clone())).await; let server = LoopbackServer::start(workspace_server_router(server_api.clone())).await;
// Exercise T-717's real repositoryless Workspace bootstrap, not a // Exercise T-717's real repositoryless Workspace bootstrap, not a
@@ -56,6 +57,25 @@ impl RealDriveFixture {
let created = http_json(response, StatusCode::CREATED).await; let created = http_json(response, StatusCode::CREATED).await;
assert!(created["repository"].is_null(), "{created}"); assert!(created["repository"].is_null(), "{created}");
let workspace_id = created["workspace"]["workspace_id"].as_str().unwrap(); let workspace_id = created["workspace"]["workspace_id"].as_str().unwrap();
// Both concurrent clients need current owner authority. A foreign
// Account must be rejected by admission before it can reach Drive CAS.
let second = "api-token-drive-web-second".to_string();
store
.create_api_token(&ApiTokenRecord {
token_hash: crate::auth::token_hash(&second),
token_id: "drive-web-second-token".into(),
user_id: store
.get_user_by_handle("user-drive-web-owner")
.unwrap()
.unwrap()
.user_id,
label: "second owner client".into(),
created_at: "2026-01-01T00:00:00Z".into(),
expires_at: None,
last_used_at: None,
revoked_at: None,
})
.unwrap();
assert!(store.list_repositories(workspace_id).unwrap().is_empty()); assert!(store.list_repositories(workspace_id).unwrap().is_empty());
let api = server_api let api = server_api
.api_for_workspace(workspace_id) .api_for_workspace(workspace_id)
@@ -76,6 +96,7 @@ impl RealDriveFixture {
api, api,
owner, owner,
second, second,
foreign,
_temp: temp, _temp: temp,
} }
} }
@@ -405,7 +426,7 @@ async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_re
.expect("Deno must be installed for this explicit process test"); .expect("Deno must be installed for this explicit process test");
let wire = serde_json::to_vec(&json!({ let wire = serde_json::to_vec(&json!({
"origin":fixture.server.origin,"workspace_id":fixture.api.workspace_id(), "origin":fixture.server.origin,"workspace_id":fixture.api.workspace_id(),
"owner_token":fixture.owner,"second_token":fixture.second,"worker_entry":worker_entry, "owner_token":fixture.owner,"second_token":fixture.second,"foreign_token":fixture.foreign,"worker_entry":worker_entry,
})) }))
.unwrap(); .unwrap();
let mut stdin = child.stdin.take().unwrap(); let mut stdin = child.stdin.take().unwrap();
@@ -422,6 +443,7 @@ async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_re
String::from_utf8_lossy(bytes) String::from_utf8_lossy(bytes)
.replace(&fixture.owner, "[redacted]") .replace(&fixture.owner, "[redacted]")
.replace(&fixture.second, "[redacted]") .replace(&fixture.second, "[redacted]")
.replace(&fixture.foreign, "[redacted]")
}; };
assert!( assert!(
output.status.success(), output.status.success(),
@@ -195,7 +195,8 @@ async fn unfinished_dependencies_allow_manual_and_orchestrator_resource_free_sta
} else { } else {
None None
}; };
let token = seed_test_api_token(api.store.as_ref(), "partial-start-owner"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "partial-start-owner").await;
let ticket = browser_ticket_backend(&api) let ticket = browser_ticket_backend(&api)
.unwrap() .unwrap()
.create(ticket::NewTicket::new( .create(ticket::NewTicket::new(
@@ -349,7 +350,8 @@ async fn unfinished_dependencies_allow_read_only_start_and_assign_without_write_
assign_test_orchestrator(api, &fixture.ticket_id); assign_test_orchestrator(api, &fixture.ticket_id);
} }
let delegation = observed_start_delegation(api, &fixture.ticket_id, source.as_ref()); let delegation = observed_start_delegation(api, &fixture.ticket_id, source.as_ref());
let token = seed_test_api_token(api.store.as_ref(), "read-only-start-owner"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "read-only-start-owner").await;
let worker = if existing { let worker = if existing {
// Drop the unrelated checkout so the entire attachment set is reference-only. // Drop the unrelated checkout so the entire attachment set is reference-only.
let main = api let main = api
@@ -482,7 +484,7 @@ async fn unfinished_dependencies_do_not_bypass_http_auth_closed_or_resource_bind
let fixture = manual_worker_assignment_fixture().await; let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api; let api = &fixture.api;
add_unfinished_start_dependency(api, &fixture.ticket_id); add_unfinished_start_dependency(api, &fixture.ticket_id);
let token = seed_test_api_token(api.store.as_ref(), "guard-start-owner"); let token = seed_test_workspace_owner_token(api.store.as_ref(), "guard-start-owner").await;
let path = format!( let path = format!(
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker", "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker",
fixture.ticket_id fixture.ticket_id
@@ -544,7 +546,8 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
let runtime = WorkdirlessFixtureRuntime::default(); let runtime = WorkdirlessFixtureRuntime::default();
api.runtime.register_or_replace(runtime.clone()); api.runtime.register_or_replace(runtime.clone());
let source = start_source(&mut api, &runtime).await; let source = start_source(&mut api, &runtime).await;
let token = seed_test_api_token(api.store.as_ref(), "delegation-owner"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "delegation-owner").await;
let ticket = browser_ticket_backend(&api) let ticket = browser_ticket_backend(&api)
.unwrap() .unwrap()
.create(ticket::NewTicket::new("Observed delegation start")) .create(ticket::NewTicket::new("Observed delegation start"))
@@ -665,7 +668,8 @@ async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_del
let runtime = WorkdirlessFixtureRuntime::default(); let runtime = WorkdirlessFixtureRuntime::default();
api.runtime.register_or_replace(runtime.clone()); api.runtime.register_or_replace(runtime.clone());
let source = start_source(&mut api, &runtime).await; let source = start_source(&mut api, &runtime).await;
let token = seed_test_api_token(api.store.as_ref(), "obsolete-worker-owner"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "obsolete-worker-owner").await;
let ticket = browser_ticket_backend(&api) let ticket = browser_ticket_backend(&api)
.unwrap() .unwrap()
.create(ticket::NewTicket::new( .create(ticket::NewTicket::new(
@@ -5,7 +5,8 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
for runtime_source in [true, false] { for runtime_source in [true, false] {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "assign-replay-owner"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "assign-replay-owner").await;
let request = |method: &str, path: &str, body: Vec<u8>| { let request = |method: &str, path: &str, body: Vec<u8>| {
if runtime_source { if runtime_source {
runtime_source_request(&identity, Some(&source.worker_id), method, path, body) runtime_source_request(&identity, Some(&source.worker_id), method, path, body)
@@ -370,7 +371,8 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
{ {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let (api, _, _, _, ticket) = tracking_fixture(dir.path()).await; let (api, _, _, _, ticket) = tracking_fixture(dir.path()).await;
let owner = seed_test_api_token(api.store.as_ref(), "assignment-account-owner"); let owner =
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-account-owner").await;
let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other"); let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other");
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap(); let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap();
@@ -393,7 +395,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
let actor = format!( let actor = format!(
"workspace-user:{}", "workspace-user:{}",
serde_json::to_string(&( serde_json::to_string(&(
"account-assignment-account-owner", &format!("account-{TEST_WORKSPACE_ID}"),
"user-assignment-account-owner" "user-assignment-account-owner"
)) ))
.unwrap() .unwrap()
@@ -411,7 +413,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
tracking_response( tracking_response(
app.clone(), app.clone(),
request(&other, "PUT", &path, body), request(&other, "PUT", &path, body),
StatusCode::CONFLICT, StatusCode::FORBIDDEN,
) )
.await; .await;
let assignment_id = assigned["assignment"]["assignment_id"].as_str().unwrap(); let assignment_id = assigned["assignment"]["assignment_id"].as_str().unwrap();
@@ -434,7 +436,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
tracking_response( tracking_response(
app, app,
request(&other, "DELETE", &clear_path, Vec::new()), request(&other, "DELETE", &clear_path, Vec::new()),
StatusCode::CONFLICT, StatusCode::FORBIDDEN,
) )
.await; .await;
let released = api let released = api
@@ -1105,7 +1107,7 @@ async fn assignment_retry_is_idempotent_but_another_authenticated_actor_cannot_r
let current = assign_tracking(&api, &identity, &source, &ticket).await; let current = assign_tracking(&api, &identity, &source, &ticket).await;
let replay = assign_tracking(&api, &identity, &source, &ticket).await; let replay = assign_tracking(&api, &identity, &source, &ticket).await;
assert_eq!(replay, current); assert_eq!(replay, current);
let token = seed_test_api_token(api.store.as_ref(), "assignment-replay-user"); let token = seed_test_workspace_owner_token(api.store.as_ref(), "assignment-replay-user").await;
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap(); let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
tracking_response( tracking_response(
@@ -1226,7 +1228,8 @@ async fn tracking_model_authorship_and_role_fields_are_rejected_and_human_is_not
) )
.await; .await;
} }
let token = seed_test_api_token(api.store.as_ref(), "human-not-tracking-agent"); let token =
seed_test_workspace_owner_token(api.store.as_ref(), "human-not-tracking-agent").await;
tracking_response( tracking_response(
app, app,
Request::builder() Request::builder()
@@ -1308,7 +1311,7 @@ async fn assignment_clear_receipt_binds_verified_actor_and_runtime_binding() {
.await, .await,
result result
); );
let token = seed_test_api_token(api.store.as_ref(), "another-clear-actor"); let token = seed_test_workspace_owner_token(api.store.as_ref(), "another-clear-actor").await;
tracking_response( tracking_response(
app.clone(), app.clone(),
Request::builder() Request::builder()
@@ -1517,7 +1520,7 @@ async fn public_assignment_answer_authenticates_source_replays_saved_result_and_
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await; let current = assign_tracking(&api, &identity, &source, &ticket).await;
let token = seed_test_api_token(api.store.as_ref(), "answer-owner"); let token = seed_test_workspace_owner_token(api.store.as_ref(), "answer-owner").await;
let app = build_router(api.clone()); let app = build_router(api.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer"); let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"answer-op","body":"Choose the bounded option"}); let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"answer-op","body":"Choose the bounded option"});
@@ -1755,7 +1758,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await; let current = assign_tracking(&api, &identity, &source, &ticket).await;
seed_test_api_token(api.store.as_ref(), "assignment-cookie"); seed_test_workspace_owner_token(api.store.as_ref(), "assignment-cookie").await;
api.store api.store
.create_browser_session(&BrowserSessionRecord { .create_browser_session(&BrowserSessionRecord {
token_hash: crate::auth::token_hash("assignment-browser-session"), token_hash: crate::auth::token_hash("assignment-browser-session"),
@@ -1846,7 +1849,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
.header(ORIGIN, origin) .header(ORIGIN, origin)
.body(Body::from(serde_json::to_vec(&body).unwrap())) .body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(), .unwrap(),
StatusCode::NOT_FOUND, StatusCode::FORBIDDEN,
) )
.await; .await;
} }
@@ -1865,7 +1868,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
.author .author
.as_deref() .as_deref()
.unwrap() .unwrap()
.contains("account-assignment-cookie") .contains(&format!("account-{TEST_WORKSPACE_ID}"))
); );
} }
@@ -577,7 +577,7 @@ async fn typed_and_generic_case_dispatch_keep_the_same_verified_worker_and_recei
async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account_and_user() { async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account_and_user() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), "case-human"); let token = seed_test_workspace_owner_token(api.store.as_ref(), "case-human").await;
let other_token = seed_test_api_token(api.store.as_ref(), "other-human"); let other_token = seed_test_api_token(api.store.as_ref(), "other-human");
let backend = browser_ticket_backend(&api).unwrap(); let backend = browser_ticket_backend(&api).unwrap();
let reference = backend let reference = backend
@@ -613,11 +613,17 @@ async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account
let response = case_response(typed.clone(), human_request(&token, true), StatusCode::OK).await; let response = case_response(typed.clone(), human_request(&token, true), StatusCode::OK).await;
let closed = backend.show(reference.id.clone().into()).unwrap(); let closed = backend.show(reference.id.clone().into()).unwrap();
let event = closed.events.last().unwrap(); let event = closed.events.last().unwrap();
assert_eq!(event.attributes["source_account_id"], "account-case-human"); assert_eq!(
event.attributes["source_account_id"],
format!("account-{TEST_WORKSPACE_ID}")
);
assert_eq!(event.attributes["source_user_id"], "user-case-human"); assert_eq!(event.attributes["source_user_id"], "user-case-human");
assert_eq!( assert_eq!(
event.author.as_deref(), event.author.as_deref(),
Some("workspace-user:[\"account-case-human\",\"user-case-human\"]") Some(
format!("workspace-user:[\"account-{TEST_WORKSPACE_ID}\",\"user-case-human\"]")
.as_str()
)
); );
assert_eq!( assert_eq!(
closed.case_history.last().unwrap().author, closed.case_history.last().unwrap().author,
@@ -630,7 +636,7 @@ async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account
case_response( case_response(
typed, typed,
human_request(&other_token, true), human_request(&other_token, true),
StatusCode::CONFLICT, StatusCode::FORBIDDEN,
) )
.await; .await;
// Generic dispatch receives an explicit untrusted author but must override it. // Generic dispatch receives an explicit untrusted author but must override it.
@@ -858,16 +858,15 @@ async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_
) )
.await; .await;
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID); assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
// Classification follows shared Ticket Workspace write authority, not the // An authenticated foreign account cannot mutate classification or replay
// owner-only repository/secrets-management policy. A distinct authenticated // the owner's receipt; classification does not grant Workspace authority.
// actor may manage tags but cannot replay another actor's receipt.
request_json_authenticated( request_json_authenticated(
app.clone(), app.clone(),
"POST", "POST",
&path, &path,
Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})), Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})),
&collaborator_token, &collaborator_token,
StatusCode::CONFLICT, StatusCode::FORBIDDEN,
) )
.await; .await;
request_json_authenticated( request_json_authenticated(
@@ -876,7 +875,7 @@ async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_
"/api/w/other-workspace/ticket-tags", "/api/w/other-workspace/ticket-tags",
Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})), Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})),
&collaborator_token, &collaborator_token,
StatusCode::NOT_FOUND, StatusCode::FORBIDDEN,
) )
.await; .await;
let response = app let response = app
+560 -2
View File
@@ -15,6 +15,38 @@
//! migration before it starts. Updating only the new schema would diverge from existing DBs; //! migration before it starts. Updating only the new schema would diverge from existing DBs;
//! both creation paths must maintain the same contract. //! both creation paths must maintain the same contract.
//! //!
//! # Hosting administrator setup
//!
//! Fresh and migrated databases have no hosting administrators and disable public
//! self-registration. Users, Workspace owners and holders of the legacy bootstrap token
//! are not promoted automatically. Hosting grants are separate from Workspace permissions.
//!
//! On the trusted Server host, run `yoi-server hosting-admin --database <PATH> --handle
//! <NEW_HANDLE> --create-user` for a fresh installation. This atomically creates a user
//! and explicit hosting grant, and intentionally prints one bearer token, valid for
//! fifteen minutes. Keep stdout private: the token is a full user bearer, not a scoped
//! activation code. Start the Server with its normal Web static assets and configured
//! WebAuthn origin (HTTPS, or localhost). Open `/hosting/setup` on that trusted origin,
//! enter the host-issued token in its password field, and choose Add administrator
//! passkey. The page uses actor-derived addition, not public registration; it never
//! stores the token or puts it in URLs. After enrollment choose Sign in with new
//! passkey, then Open hosting settings. No Workspace or public registration is needed.
//! The initial bearer expires automatically; never put it in URLs, docs or shared logs.
//!
//! For an existing installation, back up the DB and run migrations, then explicitly
//! select a known user with `yoi-server hosting-admin --database <PATH> --handle
//! <EXISTING_HANDLE>` without `--create-user`. Verify the handle belongs to the intended
//! person first. This grants authority without minting or replacing login credentials;
//! that person uses their existing passkey. `bootstrap-auth` retains its old host-user
//! setup behavior but grants no hosting authority.
//!
//! Repeated creation refuses an existing handle and never refreshes a token. If the
//! initial token is lost or expires before enrollment, a trusted host operator can
//! explicitly create a different administrator handle and enroll it, then remove the
//! unused user's grant and tokens through trusted DB maintenance after a backup.
//! Selecting an existing user is not a credential recovery mechanism; no public
//! bootstrap or unauthenticated administrator recovery endpoint exists.
//!
//! # Resource state and operation records //! # Resource state and operation records
//! //!
//! Workspace bootstrap commits resources and creation receipts in one transaction. Jobs and //! Workspace bootstrap commits resources and creation receipts in one transaction. Jobs and
@@ -84,7 +116,7 @@ pub use ticket::{
}; };
const OLDEST_SCHEMA_VERSION: i64 = 50; const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 98; const LATEST_SCHEMA_VERSION: i64 = 99;
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";
@@ -382,6 +414,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Assignment-independent implementation reports", name: "Assignment-independent implementation reports",
apply: migrate_result_reports_v97_to_v98, apply: migrate_result_reports_v97_to_v98,
}, },
Migration {
version: 99,
name: "Explicit hosting administrators and self-registration policy",
apply: migrate_hosting_policy_v98_to_v99,
},
]; ];
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -1222,6 +1259,17 @@ impl WorkspaceResourceKind {
} }
} }
/// Result of an explicitly trusted host setup. Secrets deliberately have no Debug output.
pub struct HostingAdminSetup {
pub user: UserRecord,
pub initial_bearer: Option<HostingAdminInitialBearer>,
}
pub struct HostingAdminInitialBearer {
pub access_token: String,
pub expires_at: String,
}
#[async_trait] #[async_trait]
pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
/// Bounded owner for whole synchronous operations from async callers. Move owned inputs /// Bounded owner for whole synchronous operations from async callers. Move owned inputs
@@ -1615,6 +1663,15 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>; fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>;
fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>; fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>;
fn any_user(&self) -> Result<Option<UserRecord>>; fn any_user(&self) -> Result<Option<UserRecord>>;
/// Checks an explicit grant for a currently existing user, not Workspace ownership.
fn is_hosting_admin(&self, user_id: &str) -> Result<bool>;
fn hosting_self_registration_enabled(&self) -> Result<bool>;
/// Rechecks the actor's live user/account and hosting grant in the write transaction.
fn set_hosting_self_registration_enabled(
&self,
actor: &server_api::RequestActor,
enabled: bool,
) -> Result<()>;
/// Atomically creates a new account/user/credential, or inserts a credential /// Atomically creates a new account/user/credential, or inserts a credential
/// for a still-authenticated existing target. Never overwrites a credential. /// for a still-authenticated existing target. Never overwrites a credential.
fn register_verified_passkey( fn register_verified_passkey(
@@ -2608,6 +2665,61 @@ impl SqliteWorkspaceStore {
}) })
} }
/// Host-only setup; never expose this operation through a public bootstrap route.
/// Creating a user, granting hosting authority and issuing its short-lived initial
/// bearer are atomic. Selecting an existing user never issues credentials. A repeat
/// create is refused, including after output loss; it cannot mint another bearer.
pub fn setup_hosting_admin(
&self,
handle: &str,
create_user: bool,
) -> Result<HostingAdminSetup> {
let handle = crate::auth::normalize_handle(handle)?;
self.with_immediate_transaction(|tx| {
let existing = tx.query_row(
user_select_sql("WHERE handle = ?1").as_str(),
[&handle], read_user_record,
).optional()?;
let now = chrono::Utc::now();
let created_at = now.to_rfc3339();
let (user, initial_bearer) = match (existing, create_user) {
(Some(_), true) => return Err(Error::Store("hosting-admin --create-user requires a new handle; no credentials issued".into())),
(None, false) => return Err(Error::Store("hosting-admin requires an existing user; use --create-user explicitly for a new user".into())),
(Some(user), false) => (user, None),
(None, true) => {
let user = UserRecord {
user_id: crate::auth::new_id("user"),
account_id: crate::auth::new_id("acct-user"),
handle: handle.clone(),
display_name: handle.clone(),
created_at: created_at.clone(),
updated_at: created_at.clone(),
};
tx.execute(
"INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES(?1,'user',?2,?3,?4,?4)",
params![user.account_id, user.handle, user.display_name, created_at],
)?;
tx.execute(
"INSERT INTO users(user_id,account_id,handle,display_name,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?5)",
params![user.user_id, user.account_id, user.handle, user.display_name, created_at],
)?;
let access_token = crate::auth::mint_secret("yoi_api");
let expires_at = (now + chrono::Duration::minutes(15)).to_rfc3339();
tx.execute(
"INSERT INTO api_tokens(token_id,token_hash,user_id,label,created_at,expires_at) VALUES(?1,?2,?3,'initial hosting administrator passkey setup',?4,?5)",
params![crate::auth::new_id("api-token"), crate::auth::token_hash(&access_token), user.user_id, created_at, expires_at],
)?;
(user, Some(HostingAdminInitialBearer { access_token, expires_at }))
}
};
tx.execute(
"INSERT INTO hosting_administrators(user_id,created_at) VALUES(?1,?2) ON CONFLICT(user_id) DO NOTHING",
params![user.user_id, created_at],
)?;
Ok(HostingAdminSetup { user, initial_bearer })
})
}
pub(crate) fn get_internal_worker_restore_intent( pub(crate) fn get_internal_worker_restore_intent(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -6447,6 +6559,51 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn is_hosting_admin(&self, user_id: &str) -> Result<bool> {
self.with_conn(|conn| {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1)",
[user_id], |row| row.get(0),
).map_err(Error::from)
})
}
fn hosting_self_registration_enabled(&self) -> Result<bool> {
self.with_conn(|conn| {
conn.query_row(
"SELECT self_registration_enabled FROM hosting_settings WHERE singleton = 1",
[],
|row| row.get(0),
)
.map_err(Error::from)
})
}
fn set_hosting_self_registration_enabled(
&self,
actor: &server_api::RequestActor,
enabled: bool,
) -> Result<()> {
self.with_immediate_transaction(|tx| {
let allowed: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1 AND u.account_id = ?2)",
params![actor.user_id, actor.account_id], |row| row.get(0),
)?;
if !allowed {
return Err(Error::WorkspacePermissionDenied(
"hosting_admin_required: a live hosting administrator is required".into(),
));
}
if tx.execute(
"UPDATE hosting_settings SET self_registration_enabled = ?1 WHERE singleton = 1",
[enabled],
)? != 1 {
return Err(Error::Store("hosting settings singleton is missing".into()));
}
Ok(())
})
}
fn register_verified_passkey( fn register_verified_passkey(
&self, &self,
target: &PasskeyRegistrationTarget, target: &PasskeyRegistrationTarget,
@@ -6455,6 +6612,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
self.with_immediate_transaction(|tx| { self.with_immediate_transaction(|tx| {
let user = match target { let user = match target {
PasskeyRegistrationTarget::NewAccount { user } => { PasskeyRegistrationTarget::NewAccount { user } => {
let enabled: bool = tx.query_row(
"SELECT self_registration_enabled FROM hosting_settings WHERE singleton = 1",
[], |row| row.get(0),
)?;
if !enabled {
return Err(Error::WorkspacePermissionDenied(
"self_registration_disabled: hosting self-registration is disabled".into(),
));
}
let occupied: bool = tx.query_row( let occupied: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM accounts WHERE kind = 'user' AND handle = ?1) OR EXISTS(SELECT 1 FROM users WHERE handle = ?1)", "SELECT EXISTS(SELECT 1 FROM accounts WHERE kind = 'user' AND handle = ?1) OR EXISTS(SELECT 1 FROM users WHERE handle = ?1)",
params![user.handle], |row| row.get(0), params![user.handle], |row| row.get(0),
@@ -14597,6 +14763,33 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 98 {
return Err(Error::Store(
"Hosting policy migration requires schema 98".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(
"CREATE TABLE hosting_administrators (
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE TABLE hosting_settings (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1))
);
INSERT INTO hosting_settings(singleton, self_registration_enabled) VALUES(1, 0);",
)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(99,?1)",
["Explicit hosting administrators and self-registration policy"],
)?;
verify_workspace_resource_constraints(&tx)?;
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?; create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?; assignment_delivery::install(conn)?;
@@ -17500,6 +17693,368 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
Ok(()) Ok(())
} }
fn hosting_user(handle: &str) -> UserRecord {
UserRecord {
user_id: format!("user-{handle}"),
account_id: format!("account-{handle}"),
handle: handle.into(),
display_name: handle.into(),
created_at: "1".into(),
updated_at: "1".into(),
}
}
fn hosting_seed_user(store: &SqliteWorkspaceStore, handle: &str) -> UserRecord {
let user = hosting_user(handle);
store
.upsert_account(&AccountRecord {
account_id: user.account_id.clone(),
kind: "user".into(),
handle: user.handle.clone(),
display_name: user.display_name.clone(),
created_at: "1".into(),
updated_at: "1".into(),
})
.unwrap();
store.upsert_user(&user).unwrap();
user
}
fn hosting_actor(user: &UserRecord) -> server_api::RequestActor {
server_api::RequestActor {
user_id: user.user_id.clone(),
account_id: user.account_id.clone(),
handle: user.handle.clone(),
display_name: user.display_name.clone(),
auth_method: server_api::ActorAuthMethod::ApiToken,
}
}
fn hosting_credential(user: &UserRecord, id: &str) -> PasskeyCredentialRecord {
PasskeyCredentialRecord {
credential_id: id.into(),
user_id: user.user_id.clone(),
public_key_cose: "verified-key".into(),
transports_json: None,
sign_count: 0,
created_at: "1".into(),
last_used_at: None,
}
}
#[test]
fn hosting_policy_migrates_oldest_and_previous_databases_without_promoting_users() {
for baseline in [50, 98] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
if baseline == 50 {
prepare_schema_v50(&path, None);
} else {
prepare_retained_schema(&path, baseline);
}
let conn = Connection::open(&path).unwrap();
conn.execute_batch("INSERT OR IGNORE INTO accounts VALUES('old-account','user','old-user','Old','1','1');
INSERT INTO users VALUES('old-user','old-account','old-user','Old','1','1');").unwrap();
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert!(!store.hosting_self_registration_enabled().unwrap());
assert!(!store.is_hosting_admin("old-user").unwrap());
assert_eq!(
store.get_user("old-user").unwrap().unwrap().display_name,
"Old"
);
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
for table in ["hosting_settings", "hosting_administrators"] {
let schema = |store: &SqliteWorkspaceStore| {
store
.with_conn(|conn| {
let ddl: String = conn.query_row(
"SELECT sql FROM sqlite_schema WHERE name=?1",
[table],
|r| r.get(0),
)?;
Ok(ddl
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>())
})
.unwrap()
};
assert_eq!(schema(&store), schema(&fresh));
}
store
.with_conn(|conn| {
assert!(
conn.execute(
"INSERT INTO hosting_administrators VALUES('missing','1')",
[]
)
.is_err()
);
assert!(
conn.execute(
"UPDATE hosting_settings SET self_registration_enabled=2",
[]
)
.is_err()
);
assert!(
conn.execute("INSERT INTO hosting_settings VALUES(2,0)", [])
.is_err()
);
assert_eq!(
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(0))?,
0
);
Ok(())
})
.unwrap();
}
}
#[test]
fn hosting_policy_migration_failure_rolls_back_new_tables_and_history() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
prepare_retained_schema(&path, 98);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
// A conflicting second DDL statement fails after the first table was created.
conn.execute_batch("CREATE TABLE hosting_settings(original TEXT); INSERT INTO hosting_settings VALUES('retained');").unwrap();
assert!(migrate_hosting_policy_v98_to_v99(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 98);
assert!(!table_exists(&conn, "hosting_administrators").unwrap());
assert_eq!(
conn.query_row("SELECT original FROM hosting_settings", [], |r| r
.get::<_, String>(0))
.unwrap(),
"retained"
);
conn.execute_batch("DROP TABLE hosting_settings").unwrap();
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(path).unwrap();
assert!(!store.hosting_self_registration_enabled().unwrap());
}
#[tokio::test]
async fn hosting_policy_requires_explicit_live_admin_and_persists_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let owner = hosting_seed_user(&store, "owner");
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace".into(),
owner_account_id: owner.account_id.clone(),
display_name: "Workspace".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
assert!(!store.is_hosting_admin(&owner.user_id).unwrap());
assert!(matches!(
store.set_hosting_self_registration_enabled(&hosting_actor(&owner), true),
Err(Error::WorkspacePermissionDenied(_))
));
assert!(!store.hosting_self_registration_enabled().unwrap());
let setup = store.setup_hosting_admin("owner", false).unwrap();
assert!(setup.initial_bearer.is_none());
let mut forged = hosting_actor(&owner);
forged.account_id = "another-account".into();
assert!(matches!(
store.set_hosting_self_registration_enabled(&forged, true),
Err(Error::WorkspacePermissionDenied(_))
));
store
.set_hosting_self_registration_enabled(&hosting_actor(&owner), true)
.unwrap();
drop(store);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert!(store.hosting_self_registration_enabled().unwrap());
store
.with_conn(|conn| {
conn.execute("DELETE FROM hosting_administrators", [])?;
Ok(())
})
.unwrap();
assert!(matches!(
store.set_hosting_self_registration_enabled(&hosting_actor(&owner), false),
Err(Error::WorkspacePermissionDenied(_))
));
assert!(store.hosting_self_registration_enabled().unwrap());
}
#[test]
fn hosting_setup_creates_finite_bearer_once_and_existing_selection_mints_nothing() {
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
assert!(!store.hosting_self_registration_enabled().unwrap());
assert!(store.setup_hosting_admin("unknown", false).is_err());
let setup = store.setup_hosting_admin("new-admin", true).unwrap();
assert!(store.is_hosting_admin(&setup.user.user_id).unwrap());
let bearer = setup.initial_bearer.unwrap();
let token = store
.resolve_api_token(&crate::auth::token_hash(&bearer.access_token))
.unwrap()
.unwrap();
assert_eq!(token.user_id, setup.user.user_id);
assert_eq!(
token.expires_at.as_deref(),
Some(bearer.expires_at.as_str())
);
let lifetime = chrono::DateTime::parse_from_rfc3339(&bearer.expires_at).unwrap()
- chrono::DateTime::parse_from_rfc3339(&token.created_at).unwrap();
assert_eq!(lifetime.num_seconds(), 15 * 60);
assert!(store.setup_hosting_admin("new-admin", true).is_err());
assert!(
store
.setup_hosting_admin("new-admin", false)
.unwrap()
.initial_bearer
.is_none()
);
let existing = hosting_seed_user(&store, "existing");
assert!(!store.is_hosting_admin(&existing.user_id).unwrap());
assert!(
store
.setup_hosting_admin("existing", false)
.unwrap()
.initial_bearer
.is_none()
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row("SELECT count(*) FROM api_tokens", [], |r| r
.get::<_, i64>(0))?,
1
);
conn.execute("DELETE FROM users WHERE user_id=?1", [&setup.user.user_id])?;
Ok(())
})
.unwrap();
assert!(!store.is_hosting_admin(&setup.user.user_id).unwrap());
assert!(matches!(
store.set_hosting_self_registration_enabled(&hosting_actor(&setup.user), true),
Err(Error::WorkspacePermissionDenied(_))
));
assert!(!store.hosting_self_registration_enabled().unwrap());
}
#[test]
fn hosting_setup_failure_rolls_back_account_user_token_and_grant() {
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_admin BEFORE INSERT ON hosting_administrators BEGIN SELECT RAISE(ABORT,'injected grant failure'); END;")?; Ok(()) }).unwrap();
assert!(store.setup_hosting_admin("admin", true).is_err());
store
.with_conn(|conn| {
for table in ["accounts", "users", "api_tokens", "hosting_administrators"] {
assert_eq!(
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r
.get::<_, i64>(0))?,
0
);
}
Ok(())
})
.unwrap();
assert!(!store.hosting_self_registration_enabled().unwrap());
}
#[test]
fn hosting_registration_rechecks_policy_but_allows_authenticated_addition() {
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
let admin = store.setup_hosting_admin("admin", true).unwrap();
let new_user = hosting_user("visitor");
let target = PasskeyRegistrationTarget::NewAccount {
user: new_user.clone(),
};
let credential = hosting_credential(&new_user, "visitor-key");
let assert_denied = || {
let error = store
.register_verified_passkey(&target, &credential)
.unwrap_err();
assert!(
matches!(error, Error::WorkspacePermissionDenied(ref message) if message.contains("self_registration_disabled"))
);
assert!(store.get_user(&new_user.user_id).unwrap().is_none());
assert!(store.get_account(&new_user.account_id).unwrap().is_none());
assert!(
store
.get_passkey_credential("visitor-key")
.unwrap()
.is_none()
);
};
assert_denied();
store
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), true)
.unwrap();
// Simulate an enrollment allocated while enabled, then stopped before completion.
store
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), false)
.unwrap();
assert_denied();
let token_hash = crate::auth::token_hash(&admin.initial_bearer.unwrap().access_token);
let addition = PasskeyRegistrationTarget::Addition {
user: admin.user.clone(),
authentication: PasskeyAuthenticationBinding::ApiToken(token_hash.clone()),
};
store
.register_verified_passkey(&addition, &hosting_credential(&admin.user, "admin-key"))
.unwrap();
assert!(store.get_passkey_credential("admin-key").unwrap().is_some());
store
.with_conn(|conn| {
conn.execute(
"UPDATE api_tokens SET revoked_at=?1 WHERE token_hash=?2",
params![crate::auth::now_rfc3339(), token_hash],
)?;
Ok(())
})
.unwrap();
assert!(
store
.register_verified_passkey(
&addition,
&hosting_credential(&admin.user, "revoked-key")
)
.is_err()
);
store
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), true)
.unwrap();
store
.register_verified_passkey(&target, &credential)
.unwrap();
assert_eq!(
store.get_user(&new_user.user_id).unwrap(),
Some(new_user.clone())
);
assert!(!store.is_hosting_admin(&new_user.user_id).unwrap());
// A duplicate credential fails after account/user insertion and must roll both back.
let failed_user = hosting_user("failed");
assert!(
store
.register_verified_passkey(
&PasskeyRegistrationTarget::NewAccount {
user: failed_user.clone()
},
&hosting_credential(&failed_user, "visitor-key")
)
.is_err()
);
assert!(store.get_user(&failed_user.user_id).unwrap().is_none());
assert!(
store
.get_account(&failed_user.account_id)
.unwrap()
.is_none()
);
}
include!("schema_equivalence_tests.rs"); include!("schema_equivalence_tests.rs");
include!("ticket_tag_upgrade_tests.rs"); include!("ticket_tag_upgrade_tests.rs");
include!("ticket_post_upgrade_tests.rs"); include!("ticket_post_upgrade_tests.rs");
@@ -17840,6 +18395,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:96, name:"Backend Job common notification routing".into()}, WorkspaceSchemaMigrationStep {version:96, name:"Backend Job common notification routing".into()},
WorkspaceSchemaMigrationStep {version:97, name:"MR notifications and authenticated implementation results".into()}, WorkspaceSchemaMigrationStep {version:97, name:"MR notifications and authenticated implementation results".into()},
WorkspaceSchemaMigrationStep {version:98, name:"Assignment-independent implementation reports".into()}, WorkspaceSchemaMigrationStep {version:98, name:"Assignment-independent implementation reports".into()},
WorkspaceSchemaMigrationStep {version:99, name:"Explicit hosting administrators and self-registration policy".into()},
] ]
); );
@@ -17948,6 +18504,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(96, "Backend Job common notification routing".to_string()), (96, "Backend Job common notification routing".to_string()),
(97, "MR notifications and authenticated implementation results".to_string()), (97, "MR notifications and authenticated implementation results".to_string()),
(98, "Assignment-independent implementation reports".to_string()), (98, "Assignment-independent implementation reports".to_string()),
(99, "Explicit hosting administrators and self-registration policy".to_string()),
] ]
); );
assert!(!table_exists(conn, "trusted_runtime_records")?); assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -20570,7 +21127,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(95, "Common notification routing and durable Worker delivery".into()), (95, "Common notification routing and durable Worker delivery".into()),
(96, "Backend Job common notification routing".into()), (96, "Backend Job common notification routing".into()),
(97, "MR notifications and authenticated implementation results".into()), (97, "MR notifications and authenticated implementation results".into()),
(98, "Assignment-independent implementation reports".into())]); (98, "Assignment-independent implementation reports".into()),
(99, "Explicit hosting administrators and self-registration policy".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}");
} }
+227 -1
View File
@@ -3416,6 +3416,32 @@
], ],
"type": "object" "type": "object"
}, },
"HostingAccess": {
"additionalProperties": false,
"description": "Hosting authority for the current authenticated human, not Workspace permissions.",
"properties": {
"is_admin": {
"type": "boolean"
}
},
"required": [
"is_admin"
],
"type": "object"
},
"HostingSettings": {
"additionalProperties": false,
"description": "The only public registration policy. No administrator identities or host secrets are exposed.",
"properties": {
"self_registration_enabled": {
"type": "boolean"
}
},
"required": [
"self_registration_enabled"
],
"type": "object"
},
"InitialRepositoryIntent": { "InitialRepositoryIntent": {
"additionalProperties": false, "additionalProperties": false,
"description": "Initial Repository registration intent for Workspace creation.", "description": "Initial Repository registration intent for Workspace creation.",
@@ -17748,7 +17774,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720" "x-yoi-source-digest": "sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -18450,6 +18476,33 @@
} }
} }
}, },
"/api/auth/registration-policy": {
"get": {
"operationId": "auth_registration_policy",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HostingSettings"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
}
}
},
"/api/auth/whoami": { "/api/auth/whoami": {
"get": { "get": {
"operationId": "auth_whoami", "operationId": "auth_whoami",
@@ -18769,6 +18822,179 @@
] ]
} }
}, },
"/api/hosting/access": {
"get": {
"operationId": "hosting_access",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HostingAccess"
}
}
},
"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/hosting/settings": {
"get": {
"operationId": "hosting_settings",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HostingSettings"
}
}
},
"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": []
}
]
},
"put": {
"operationId": "hosting_settings_update",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HostingSettings"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HostingSettings"
}
}
},
"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/hosts": { "/api/hosts": {
"get": { "get": {
"operationId": "host_list_alias", "operationId": "host_list_alias",
+41
View File
@@ -309,6 +309,47 @@ impl WorkerE2eEnvironment {
pub fn start_empty_backend(&mut self) -> Result<()> { pub fn start_empty_backend(&mut self) -> Result<()> {
assert_absent_or_empty(&self.server_data_dir, "Server data directory")?; assert_absent_or_empty(&self.server_data_dir, "Server data directory")?;
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?; assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
self.start_server_process()
}
/// 同じ隔離DB・設定でBackendを再起動する。Runtime付きfixtureには使わない。
pub fn restart_backend(&mut self) -> Result<()> {
if self.runtime.is_some() || self.server.is_none() {
return Err(HarnessError::Protocol(
"Backend-only restart requires a running Server and no Runtime".to_string(),
));
}
if let Some(mut server) = self.server.take() {
server.shutdown(EXIT_WAIT)?;
}
self.server_url = None;
self.start_server_process()
}
/// host CLIだけで管理者を明示設定する。初回bearerはartifactへ保存しない。
pub fn setup_hosting_admin(&self, handle: &str, create_user: bool) -> Result<Value> {
let binary = self.binaries.binary("yoi-server")?;
let mut args = vec!["hosting-admin", "--handle", handle];
if create_user {
args.push("--create-user");
}
let output = self.run_command(&binary, &args, true)?;
Ok(serde_json::from_slice(&output.stdout)?)
}
/// 旧host bootstrap CLIは一般userを作る。認証情報はartifactへ保存しない。
pub fn bootstrap_auth_user(&self, handle: &str) -> Result<Value> {
let binary = self.binaries.binary("yoi-server")?;
let output = self.run_command(&binary, &["bootstrap-auth", "--handle", handle], true)?;
Ok(serde_json::from_slice(&output.stdout)?)
}
fn start_server_process(&mut self) -> Result<()> {
if self.server.is_some() {
return Err(HarnessError::Protocol(
"Server is already running".to_string(),
));
}
let server_binary = self.binaries.binary("yoi-server")?; let server_binary = self.binaries.binary("yoi-server")?;
let mut command = self.isolated_command(&server_binary); let mut command = self.isolated_command(&server_binary);
command.args([ command.args([
+608 -9
View File
@@ -1,5 +1,5 @@
//! 隔離した実yoi-serverのHTTP APIで、新規登録→明示handle login→本人の鍵追加→ //! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、
//! owner-first Workspace作成を検証する。DB、認証、routerは本番実装を使い、 //! 明示handle login、本人の鍵追加、Workspace所有境界を検証する。DB・認証・routerは本番実装を使い、
//! authenticatorだけをSoftPasskeyに置換する(browser UI/OS authenticatorは対象外)。 //! authenticatorだけをSoftPasskeyに置換する(browser UI/OS authenticatorは対象外)。
//! `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が所有する。
@@ -151,8 +151,153 @@ fn login(
)?) )?)
} }
fn http_client() -> Result<Client> {
Ok(Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(1))
.timeout(Duration::from_secs(5))
.build()?)
}
fn policy(client: &Client, base: &str, artifacts: &Path, enabled: bool) -> Result {
let public: Value = decode(send(
client.get(format!("{base}/api/auth/registration-policy")),
"anonymous public registration policy",
artifacts,
)?)?;
assert_eq!(public, json!({"self_registration_enabled": enabled}));
Ok(())
}
fn set_policy(
client: &Client,
base: &str,
artifacts: &Path,
cookie: &str,
enabled: bool,
) -> Result {
let updated: Value = decode(send(
client
.put(format!("{base}/api/hosting/settings"))
.header(COOKIE, cookie)
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"self_registration_enabled": enabled})),
"administrator updates registration policy",
artifacts,
)?)?;
assert_eq!(updated, json!({"self_registration_enabled": enabled}));
let stored: Value = decode(send(
client
.get(format!("{base}/api/hosting/settings"))
.header(COOKIE, cookie),
"administrator rereads stored hosting settings",
artifacts,
)?)?;
assert_eq!(stored, updated);
policy(client, base, artifacts, enabled)
}
fn access(client: &Client, base: &str, artifacts: &Path, cookie: &str, is_admin: bool) -> Result {
let value: Value = decode(send(
client
.get(format!("{base}/api/hosting/access"))
.header(COOKIE, cookie),
"authenticated hosting access",
artifacts,
)?)?;
assert_eq!(value, json!({"is_admin": is_admin}));
Ok(())
}
fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result {
let response = success(send(
client
.post(format!("{base}/api/auth/logout"))
.header(COOKIE, cookie)
.header(ORIGIN, BROWSER_ORIGIN),
"revoke real browser session",
artifacts,
)?)?;
assert!(
response
.headers()
.get(SET_COOKIE)
.ok_or("missing logout cookie")?
.to_str()?
.contains("Max-Age=0")
);
let whoami: Value = decode(send(
client
.get(format!("{base}/api/auth/whoami"))
.header(COOKIE, cookie),
"revoked cookie is no longer authenticated",
artifacts,
)?)?;
assert!(whoami["actor"].is_null());
Ok(())
}
fn host_admin_login(
environment: &WorkerE2eEnvironment,
client: &Client,
key: &mut WebauthnAuthenticator<SoftPasskey>,
) -> Result<(AuthUserResponse, String)> {
let base = environment
.server_url
.as_deref()
.ok_or("missing server URL")?;
let artifacts = &environment.artifacts_dir;
let handle = format!("{}-admin", environment.account_handle);
let setup = environment.setup_hosting_admin(&handle, true)?;
assert_eq!(setup["hosting_admin"], true);
assert_eq!(setup["token_type"], "bearer");
assert!(
setup["expires_at"].as_str().is_some(),
"initial bearer must have finite expiry"
);
let bearer = setup["access_token"]
.as_str()
.ok_or("host setup omitted initial bearer")?;
let options: PasskeyRegistrationOptionsResponse = decode(send(
client
.post(format!("{base}/api/auth/passkeys/addition/options"))
.bearer_auth(bearer)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
"host-created administrator bearer starts actor-derived passkey addition",
artifacts,
)?)?;
let credential = key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?;
let (activated, cookie) = session(send(
client
.post(format!("{base}/api/auth/passkeys/addition/complete"))
.bearer_auth(bearer)
.json(&PasskeyRegistrationCompleteRequest {
challenge_id: options.challenge_id,
credential,
}),
"host-created administrator verifies initial passkey",
artifacts,
)?)?;
assert_eq!(activated.user.account_id, setup["user"]["account_id"]);
logout(client, base, artifacts, &cookie)?;
let (logged_in, cookie) = login(client, base, artifacts, &handle, key)?;
assert_eq!(logged_in.user, activated.user);
access(client, base, artifacts, &cookie, true)?;
let workspaces: Value = decode(send(
client
.get(format!("{base}/api/workspaces"))
.header(COOKIE, &cookie),
"host administrator setup and login do not create a Workspace",
artifacts,
)?)?;
assert_eq!(workspaces, json!([]));
Ok((logged_in, cookie))
}
#[test] #[test]
fn fresh_account_adds_own_passkey_and_creates_owned_workspace() -> Result { fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ownership() -> Result
{
let mut environment = WorkerE2eEnvironment::new()?; let mut environment = WorkerE2eEnvironment::new()?;
eprintln!( eprintln!(
"passkey E2E artifacts: {}", "passkey E2E artifacts: {}",
@@ -167,12 +312,12 @@ fn fresh_account_adds_own_passkey_and_creates_owned_workspace() -> Result {
.as_deref() .as_deref()
.ok_or("missing server URL")?; .ok_or("missing server URL")?;
let artifacts = &environment.artifacts_dir; let artifacts = &environment.artifacts_dir;
let client = Client::builder() let client = http_client()?;
.no_proxy() policy(&client, base, artifacts, false)?;
.redirect(reqwest::redirect::Policy::none()) let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
.connect_timeout(Duration::from_secs(1)) let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
.timeout(Duration::from_secs(5)) policy(&client, base, artifacts, false)?;
.build()?; set_policy(&client, base, artifacts, &admin_cookie, true)?;
let whoami: Value = decode(send( let whoami: Value = decode(send(
client.get(format!("{base}/api/auth/whoami")), client.get(format!("{base}/api/auth/whoami")),
"fresh whoami", "fresh whoami",
@@ -261,6 +406,9 @@ fn fresh_account_adds_own_passkey_and_creates_owned_workspace() -> Result {
register(&client, base, artifacts, &other_handle, &mut other_key)?; register(&client, base, artifacts, &other_handle, &mut other_key)?;
assert_ne!(other.user.account_id, registered.user.account_id); assert_ne!(other.user.account_id, registered.user.account_id);
assert_ne!(other_user_handle, user_handle); assert_ne!(other_user_handle, user_handle);
access(&client, base, artifacts, &cookie, false)?;
set_policy(&client, base, artifacts, &admin_cookie, false)?;
// 以下の本人鍵追加と両方の鍵によるloginは、自己登録停止中にも必要。
rejected( rejected(
send( send(
client client
@@ -458,6 +606,457 @@ fn fresh_account_adds_own_passkey_and_creates_owned_workspace() -> Result {
assert_eq!(items[0]["workspace_id"], workspace_id); assert_eq!(items[0]["workspace_id"], workspace_id);
assert_eq!(items[0]["owner_account_id"], registered.user.account_id); assert_eq!(items[0]["owner_account_id"], registered.user.account_id);
access(&client, base, artifacts, &new_cookie, false)?;
rejected(
send(
client
.get(format!("{base}/api/hosting/settings"))
.header(COOKIE, &new_cookie),
"Workspace ownership does not grant hosting administrator access",
artifacts,
)?,
StatusCode::FORBIDDEN,
"hosting administrator required",
)?;
let admin_workspaces: Value = decode(send(
client
.get(format!("{base}/api/workspaces"))
.header(COOKIE, &admin_cookie),
"hosting administrator cannot list another owner's Workspace",
artifacts,
)?)?;
assert_eq!(admin_workspaces, json!([]));
assert_ne!(admin.user.account_id, registered.user.account_id);
rejected(
send(
client
.get(format!("{base}/api/workspaces/{workspace_id}/deletion"))
.header(COOKIE, &admin_cookie),
"hosting administrator cannot manage another owner's Workspace deletion",
artifacts,
)?,
StatusCode::FORBIDDEN,
"owner",
)?;
let report = environment.cleanup()?;
assert!(report.cleanup_success);
assert!(!report.fixture_root_exists_after);
Ok(())
}
fn ordinary_device_login_does_not_grant_hosting_authority(
client: &Client,
base: &str,
artifacts: &Path,
user: &AuthUserResponse,
cookie: &str,
) -> Result {
let flow: Value = decode(send(
client
.post(format!("{base}/api/auth/device-login/start"))
.json(&json!({"client_name": "isolated policy E2E"})),
"start device login while registration is disabled",
artifacts,
)?)?;
let approval = json!({"user_code": flow["user_code"]});
rejected(
send(
client
.post(format!("{base}/api/auth/device-login/approve"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&approval),
"anonymous device approval cannot authenticate",
artifacts,
)?,
StatusCode::UNAUTHORIZED,
"request requires",
)?;
for origin in [None, Some("http://localhost:5174")] {
let mut request = client
.post(format!("{base}/api/auth/device-login/approve"))
.header(COOKIE, cookie)
.json(&approval);
if let Some(origin) = origin {
request = request.header(ORIGIN, origin);
}
rejected(
send(
request,
"device approval cookie retains Origin boundary",
artifacts,
)?,
StatusCode::FORBIDDEN,
"origin",
)?;
}
let approved: Value = decode(send(
client
.post(format!("{base}/api/auth/device-login/approve"))
.header(COOKIE, cookie)
.header(ORIGIN, BROWSER_ORIGIN)
.json(&approval),
"existing ordinary user approves device login while registration disabled",
artifacts,
)?)?;
assert_eq!(approved["status"], "approved");
assert_eq!(approved["user"]["account_id"], user.user.account_id);
let issued: Value = decode(send(
client
.post(format!("{base}/api/auth/device-login/poll"))
.json(&json!({"device_code": flow["device_code"]})),
"device poll returns approved ordinary user's bearer",
artifacts,
)?)?;
assert_eq!(issued["status"], "approved");
let token = issued["access_token"]
.as_str()
.ok_or("device poll omitted bearer")?;
let access: Value = decode(send(
client
.get(format!("{base}/api/hosting/access"))
.bearer_auth(token),
"device bearer retains ordinary hosting access",
artifacts,
)?)?;
assert_eq!(access, json!({"is_admin": false}));
rejected(
send(
client
.put(format!("{base}/api/hosting/settings"))
.bearer_auth(token)
.header("x-hosting-admin", "true")
.json(&json!({"self_registration_enabled": true})),
"device bearer cannot elevate ordinary user to hosting administrator",
artifacts,
)?,
StatusCode::FORBIDDEN,
"hosting_admin_required",
)?;
let consumed: Value = decode(send(
client
.post(format!("{base}/api/auth/device-login/poll"))
.json(&json!({"device_code": flow["device_code"]})),
"device credential delivery remains one-use",
artifacts,
)?)?;
assert_eq!(consumed["status"], "consumed");
assert!(consumed.get("access_token").is_none_or(Value::is_null));
policy(client, base, artifacts, false)
}
#[test]
fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart() -> Result {
let mut environment = WorkerE2eEnvironment::new()?;
eprintln!(
"hosting policy E2E artifacts: {}",
environment.artifacts_dir.display()
);
environment.start_empty_backend()?;
let client = http_client()?;
let mut base = environment.server_url.clone().ok_or("missing server URL")?;
let artifacts = environment.artifacts_dir.clone();
policy(&client, &base, &artifacts, false)?;
let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
policy(&client, &base, &artifacts, false)?;
for path in ["/api/hosting/access", "/api/hosting/settings"] {
rejected(
send(
client
.get(format!("{base}{path}"))
.header("x-yoi-user-id", &admin.user.user_id)
.header("x-yoi-account-id", &admin.user.account_id)
.header("x-hosting-admin", "true"),
"anonymous identity headers cannot authenticate hosting access",
&artifacts,
)?,
StatusCode::FORBIDDEN,
"auth_required",
)?;
}
rejected(
send(
client
.put(format!("{base}/api/hosting/settings"))
.header(ORIGIN, BROWSER_ORIGIN)
.header("x-hosting-admin", "true")
.json(&json!({"self_registration_enabled": true})),
"anonymous spoof cannot enable registration",
&artifacts,
)?,
StatusCode::FORBIDDEN,
"auth_required",
)?;
policy(&client, &base, &artifacts, false)?;
let denied_handle = format!("{}-denied", environment.account_handle);
rejected(
send(
client
.post(format!("{base}/api/auth/passkeys/registration/options"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"handle": denied_handle, "browser_origin": BROWSER_ORIGIN})),
"disabled public registration rejects direct options",
&artifacts,
)?,
StatusCode::FORBIDDEN,
"self_registration_disabled",
)?;
rejected(
send(
client
.post(format!("{base}/api/auth/bootstrap-user"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"handle": denied_handle})),
"disabled policy cannot be bypassed through legacy bootstrap",
&artifacts,
)?,
StatusCode::BAD_REQUEST,
"verified_registration_required",
)?;
for origin in [None, Some("http://localhost:5174")] {
let mut request = client
.put(format!("{base}/api/hosting/settings"))
.header(COOKIE, &admin_cookie)
.json(&json!({"self_registration_enabled": true}));
if let Some(origin) = origin {
request = request.header(ORIGIN, origin);
}
rejected(
send(
request,
"admin cookie settings require configured Origin",
&artifacts,
)?,
StatusCode::FORBIDDEN,
"origin",
)?;
}
policy(&client, &base, &artifacts, false)?;
set_policy(&client, &base, &artifacts, &admin_cookie, true)?;
environment.restart_backend()?;
base = environment
.server_url
.clone()
.ok_or("missing restarted server URL")?;
policy(&client, &base, &artifacts, true)?;
let (_, admin_cookie) = login(
&client,
&base,
&artifacts,
&admin.user.handle,
&mut admin_key,
)?;
access(&client, &base, &artifacts, &admin_cookie, true)?;
// 拒否されたhandleも予約されておらず、許可後には通常の新規登録ができる。
let mut ordinary_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let (ordinary, ordinary_cookie, _) = register(
&client,
&base,
&artifacts,
&denied_handle,
&mut ordinary_key,
)?;
access(&client, &base, &artifacts, &ordinary_cookie, false)?;
rejected(
send(
client
.get(format!("{base}/api/hosting/settings"))
.header(COOKIE, &ordinary_cookie)
.header("x-hosting-admin", "true")
.header("x-yoi-user-id", &admin.user.user_id)
.header("x-yoi-account-id", &admin.user.account_id),
"ordinary user cannot read settings by impersonating administrator headers",
&artifacts,
)?,
StatusCode::FORBIDDEN,
"hosting administrator required",
)?;
rejected(
send(
client
.put(format!("{base}/api/hosting/settings"))
.header(COOKIE, &ordinary_cookie)
.header(ORIGIN, BROWSER_ORIGIN)
.header("x-hosting-admin", "true")
.header("x-yoi-user-id", &admin.user.user_id)
.json(&json!({"self_registration_enabled": false})),
"ordinary user cannot change policy with administrator headers",
&artifacts,
)?,
StatusCode::FORBIDDEN,
"hosting_admin_required",
)?;
for field in ["is_admin", "user_id", "account_id"] {
let mut body = json!({"self_registration_enabled": false});
body[field] = match field {
"is_admin" => json!(true),
"user_id" => json!(admin.user.user_id),
_ => json!(admin.user.account_id),
};
rejected(
send(
client
.put(format!("{base}/api/hosting/settings"))
.header(COOKIE, &ordinary_cookie)
.header(ORIGIN, BROWSER_ORIGIN)
.json(&body),
"hosting settings reject actor and role fields in JSON",
&artifacts,
)?,
StatusCode::UNPROCESSABLE_ENTITY,
"unknown field",
)?;
}
policy(&client, &base, &artifacts, true)?;
let pending_handle = format!("{}-inflight", environment.account_handle);
let options: PasskeyRegistrationOptionsResponse = decode(send(
client
.post(format!("{base}/api/auth/passkeys/registration/options"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"handle": pending_handle, "browser_origin": BROWSER_ORIGIN})),
"registration starts while enabled",
&artifacts,
)?)?;
let mut pending_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let credential = pending_key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?;
let complete = PasskeyRegistrationCompleteRequest {
challenge_id: options.challenge_id,
credential,
};
set_policy(&client, &base, &artifacts, &admin_cookie, false)?;
// challenge自体も実DBから再読させ、停止後の完了で現在の設定を使うことを確かめる。
environment.restart_backend()?;
base = environment
.server_url
.clone()
.ok_or("missing restarted server URL")?;
policy(&client, &base, &artifacts, false)?;
rejected(
send(
client
.post(format!("{base}/api/auth/passkeys/registration/complete"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&complete),
"registration completion rechecks disabled policy after restart",
&artifacts,
)?,
StatusCode::FORBIDDEN,
"self_registration_disabled",
)?;
rejected(
send(
client
.post(format!("{base}/api/auth/passkeys/login/options"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"handle": pending_handle, "browser_origin": BROWSER_ORIGIN})),
"rejected in-flight registration did not create an account",
&artifacts,
)?,
StatusCode::BAD_REQUEST,
"unknown_auth_user",
)?;
rejected(
send(
client
.post(format!("{base}/api/auth/passkeys/registration/options"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"handle": pending_handle, "browser_origin": BROWSER_ORIGIN})),
"registration remains disabled on reopened DB",
&artifacts,
)?,
StatusCode::FORBIDDEN,
"self_registration_disabled",
)?;
let (logged_in, cookie) = login(
&client,
&base,
&artifacts,
&ordinary.user.handle,
&mut ordinary_key,
)?;
assert_eq!(logged_in.user, ordinary.user);
access(&client, &base, &artifacts, &cookie, false)?;
ordinary_device_login_does_not_grant_hosting_authority(
&client, &base, &artifacts, &logged_in, &cookie,
)?;
let (_, cookie) = login(
&client,
&base,
&artifacts,
&admin.user.handle,
&mut admin_key,
)?;
access(&client, &base, &artifacts, &cookie, true)?;
let stored: Value = decode(send(
client
.get(format!("{base}/api/hosting/settings"))
.header(COOKIE, &cookie),
"administrator settings access persists after reopen",
&artifacts,
)?)?;
assert_eq!(stored, json!({"self_registration_enabled": false}));
let report = environment.cleanup()?;
assert!(report.cleanup_success);
assert!(!report.fixture_root_exists_after);
Ok(())
}
#[test]
fn legacy_host_bootstrap_is_ordinary_until_explicit_host_selection() -> Result {
let mut environment = WorkerE2eEnvironment::new()?;
environment.start_empty_backend()?;
let client = http_client()?;
let base = environment
.server_url
.as_deref()
.ok_or("missing server URL")?;
let artifacts = &environment.artifacts_dir;
let setup = environment.bootstrap_auth_user(&environment.account_handle)?;
let bearer = setup["access_token"]
.as_str()
.ok_or("bootstrap omitted bearer")?;
policy(&client, base, artifacts, false)?;
let ordinary: Value = decode(send(
client
.get(format!("{base}/api/hosting/access"))
.bearer_auth(bearer),
"legacy host bootstrap does not implicitly grant administrator",
artifacts,
)?)?;
assert_eq!(ordinary, json!({"is_admin": false}));
rejected(
send(
client
.put(format!("{base}/api/hosting/settings"))
.bearer_auth(bearer)
.json(&json!({"self_registration_enabled": true})),
"legacy bootstrap bearer cannot change hosting settings",
artifacts,
)?,
StatusCode::FORBIDDEN,
"hosting_admin_required",
)?;
let selected = environment.setup_hosting_admin(&environment.account_handle, false)?;
assert_eq!(selected["hosting_admin"], true);
assert!(
selected.get("access_token").is_none(),
"selection must not mint credentials"
);
let admin: Value = decode(send(
client
.get(format!("{base}/api/hosting/access"))
.bearer_auth(bearer),
"explicit host selection grants existing user administrator",
artifacts,
)?)?;
assert_eq!(admin, json!({"is_admin": true}));
policy(&client, base, artifacts, false)?;
let report = environment.cleanup()?; let report = environment.cleanup()?;
assert!(report.cleanup_success); assert!(report.cleanup_success);
assert!(!report.fixture_root_exists_after); assert!(!report.fixture_root_exists_after);
@@ -0,0 +1,404 @@
{
"schemaVersion": 1,
"id": "hosting-settings-dark",
"title": "Hosting settings: synthetic UI conditions on production shell",
"baseUrl": "http://127.0.0.1:15378",
"colorScheme": "dark",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Synthetic API projection; no real authentication",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "admin-on",
"label": "admin-on",
"path": "/hosting/settings?fixture=admin-on",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Synthetic admin; policy on.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Edit registration policy\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Loaded hosting-wide policy"
},
{
"id": "edit",
"label": "Keyboard edit focus",
"interaction": [
{
"action": "press",
"selector": "button:has-text(\"Edit registration policy\")",
"key": "Enter"
}
],
"ready": {
"kind": "selector",
"selector": "input[type=\"checkbox\"]:focus"
}
},
{
"id": "saved",
"label": "Confirmed save",
"interaction": [
{
"action": "press",
"selector": "input[type=\"checkbox\"]",
"key": "Space"
},
{
"action": "click",
"selector": "button:has-text(\"Save settings\")"
}
],
"ready": {
"kind": "selector",
"selector": "[role=\"status\"]:has-text(\"Hosting settings saved.\")"
}
}
]
},
{
"id": "admin-off",
"label": "admin-off",
"path": "/hosting/settings?fixture=admin-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Synthetic admin; policy off.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Edit registration policy\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Loaded hosting-wide policy"
},
{
"id": "edit",
"label": "Keyboard edit focus",
"interaction": [
{
"action": "press",
"selector": "button:has-text(\"Edit registration policy\")",
"key": "Enter"
}
],
"ready": {
"kind": "selector",
"selector": "input[type=\"checkbox\"]:focus"
}
},
{
"id": "saved",
"label": "Confirmed save",
"interaction": [
{
"action": "press",
"selector": "input[type=\"checkbox\"]",
"key": "Space"
},
{
"action": "click",
"selector": "button:has-text(\"Save settings\")"
}
],
"ready": {
"kind": "selector",
"selector": "[role=\"status\"]:has-text(\"Hosting settings saved.\")"
}
}
]
},
{
"id": "ordinary",
"label": "ordinary",
"path": "/hosting/settings?fixture=ordinary",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Synthetic authenticated ordinary user, no admin controls.",
"ready": {
"kind": "selector",
"selector": "p:has-text(\"Hosting administrator access is required.\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state"
}
]
},
{
"id": "anonymous",
"label": "anonymous",
"path": "/hosting/settings?fixture=anonymous",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Synthetic anonymous visitor; sign-in guidance, no controls.",
"ready": {
"kind": "selector",
"selector": "a:has-text(\"Open Account\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state"
}
]
},
{
"id": "admin-loading",
"label": "admin-loading",
"path": "/hosting/settings?fixture=admin-loading",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Settings GET intentionally held pending; shell remains usable.",
"ready": {
"kind": "selector",
"selector": "[role=\"status\"]:has-text(\"Loading hosting settings\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state"
}
]
},
{
"id": "admin-load-error",
"label": "admin-load-error",
"path": "/hosting/settings?fixture=admin-load-error",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Intentional HTTP503; expected console resource error and visible alert.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Retry loading settings\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state"
}
]
},
{
"id": "admin-saving",
"label": "admin-saving",
"path": "/hosting/settings?fixture=admin-saving",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "PUT held pending; last loaded policy retained, edit controls disabled.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Edit registration policy\")"
},
"capturePoints": [
{
"id": "saving",
"label": "saving",
"interaction": [
{
"action": "click",
"selector": "button:has-text(\"Edit registration policy\")"
},
{
"action": "press",
"selector": "input[type=\"checkbox\"]",
"key": "Space"
},
{
"action": "click",
"selector": "button:has-text(\"Save settings\")"
}
],
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Saving…\")"
}
}
]
},
{
"id": "admin-save-error",
"label": "admin-save-error",
"path": "/hosting/settings?fixture=admin-save-error",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Intentional PUT HTTP503; outcome unknown, retry blocked until reload.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Edit registration policy\")"
},
"capturePoints": [
{
"id": "save-error",
"label": "save-error",
"interaction": [
{
"action": "click",
"selector": "button:has-text(\"Edit registration policy\")"
},
{
"action": "press",
"selector": "input[type=\"checkbox\"]",
"key": "Space"
},
{
"action": "click",
"selector": "button:has-text(\"Save settings\")"
}
],
"ready": {
"kind": "selector",
"selector": "[role=\"alert\"]"
}
}
]
},
{
"id": "account-on",
"label": "account-on",
"path": "/account?fixture=account-on",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Anonymous policy on; registration and login available.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Register new account\"):enabled"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "account-off",
"label": "account-off",
"path": "/account?fixture=account-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Anonymous policy off; no registration action, login available.",
"ready": {
"kind": "selector",
"selector": "p:has-text(\"New account registration is disabled.\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "admin-account",
"label": "admin-account",
"path": "/account?fixture=admin-account",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Authenticated admin; own passkey addition and global settings link.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add passkey to alice\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "ordinary-account-off",
"label": "ordinary-account-off",
"path": "/account?fixture=ordinary-account-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Authenticated ordinary user with policy off; own passkey addition available without admin nav.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add passkey to alice\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "account-error",
"label": "account-error",
"path": "/account?fixture=account-error",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Retry registration availability\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "account-loading",
"label": "account-loading",
"path": "/account?fixture=account-loading",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Policy request held pending; login remains available after session loads.",
"ready": {
"kind": "selector",
"selector": "[role=\"status\"]:has-text(\"Checking registration availability\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
}
],
"processes": [
{
"id": "hosting-ui-fixture",
"command": "deno",
"cwd": "../../..",
"args": [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15378",
"web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15378/health"
}
]
}
@@ -0,0 +1,404 @@
{
"schemaVersion": 1,
"id": "hosting-settings-light",
"title": "Hosting settings: synthetic UI conditions on production shell",
"baseUrl": "http://127.0.0.1:15378",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Synthetic API projection; no real authentication",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "admin-on",
"label": "admin-on",
"path": "/hosting/settings?fixture=admin-on",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Synthetic admin; policy on.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Edit registration policy\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Loaded hosting-wide policy"
},
{
"id": "edit",
"label": "Keyboard edit focus",
"interaction": [
{
"action": "press",
"selector": "button:has-text(\"Edit registration policy\")",
"key": "Enter"
}
],
"ready": {
"kind": "selector",
"selector": "input[type=\"checkbox\"]:focus"
}
},
{
"id": "saved",
"label": "Confirmed save",
"interaction": [
{
"action": "press",
"selector": "input[type=\"checkbox\"]",
"key": "Space"
},
{
"action": "click",
"selector": "button:has-text(\"Save settings\")"
}
],
"ready": {
"kind": "selector",
"selector": "[role=\"status\"]:has-text(\"Hosting settings saved.\")"
}
}
]
},
{
"id": "admin-off",
"label": "admin-off",
"path": "/hosting/settings?fixture=admin-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Synthetic admin; policy off.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Edit registration policy\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Loaded hosting-wide policy"
},
{
"id": "edit",
"label": "Keyboard edit focus",
"interaction": [
{
"action": "press",
"selector": "button:has-text(\"Edit registration policy\")",
"key": "Enter"
}
],
"ready": {
"kind": "selector",
"selector": "input[type=\"checkbox\"]:focus"
}
},
{
"id": "saved",
"label": "Confirmed save",
"interaction": [
{
"action": "press",
"selector": "input[type=\"checkbox\"]",
"key": "Space"
},
{
"action": "click",
"selector": "button:has-text(\"Save settings\")"
}
],
"ready": {
"kind": "selector",
"selector": "[role=\"status\"]:has-text(\"Hosting settings saved.\")"
}
}
]
},
{
"id": "ordinary",
"label": "ordinary",
"path": "/hosting/settings?fixture=ordinary",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Synthetic authenticated ordinary user, no admin controls.",
"ready": {
"kind": "selector",
"selector": "p:has-text(\"Hosting administrator access is required.\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state"
}
]
},
{
"id": "anonymous",
"label": "anonymous",
"path": "/hosting/settings?fixture=anonymous",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Synthetic anonymous visitor; sign-in guidance, no controls.",
"ready": {
"kind": "selector",
"selector": "a:has-text(\"Open Account\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state"
}
]
},
{
"id": "admin-loading",
"label": "admin-loading",
"path": "/hosting/settings?fixture=admin-loading",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Settings GET intentionally held pending; shell remains usable.",
"ready": {
"kind": "selector",
"selector": "[role=\"status\"]:has-text(\"Loading hosting settings\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state"
}
]
},
{
"id": "admin-load-error",
"label": "admin-load-error",
"path": "/hosting/settings?fixture=admin-load-error",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Intentional HTTP503; expected console resource error and visible alert.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Retry loading settings\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state"
}
]
},
{
"id": "admin-saving",
"label": "admin-saving",
"path": "/hosting/settings?fixture=admin-saving",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "PUT held pending; last loaded policy retained, edit controls disabled.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Edit registration policy\")"
},
"capturePoints": [
{
"id": "saving",
"label": "saving",
"interaction": [
{
"action": "click",
"selector": "button:has-text(\"Edit registration policy\")"
},
{
"action": "press",
"selector": "input[type=\"checkbox\"]",
"key": "Space"
},
{
"action": "click",
"selector": "button:has-text(\"Save settings\")"
}
],
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Saving…\")"
}
}
]
},
{
"id": "admin-save-error",
"label": "admin-save-error",
"path": "/hosting/settings?fixture=admin-save-error",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Intentional PUT HTTP503; outcome unknown, retry blocked until reload.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Edit registration policy\")"
},
"capturePoints": [
{
"id": "save-error",
"label": "save-error",
"interaction": [
{
"action": "click",
"selector": "button:has-text(\"Edit registration policy\")"
},
{
"action": "press",
"selector": "input[type=\"checkbox\"]",
"key": "Space"
},
{
"action": "click",
"selector": "button:has-text(\"Save settings\")"
}
],
"ready": {
"kind": "selector",
"selector": "[role=\"alert\"]"
}
}
]
},
{
"id": "account-on",
"label": "account-on",
"path": "/account?fixture=account-on",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Anonymous policy on; registration and login available.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Register new account\"):enabled"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "account-off",
"label": "account-off",
"path": "/account?fixture=account-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Anonymous policy off; no registration action, login available.",
"ready": {
"kind": "selector",
"selector": "p:has-text(\"New account registration is disabled.\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "admin-account",
"label": "admin-account",
"path": "/account?fixture=admin-account",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Authenticated admin; own passkey addition and global settings link.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add passkey to alice\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "ordinary-account-off",
"label": "ordinary-account-off",
"path": "/account?fixture=ordinary-account-off",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Authenticated ordinary user with policy off; own passkey addition available without admin nav.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add passkey to alice\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "account-error",
"label": "account-error",
"path": "/account?fixture=account-error",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Retry registration availability\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
},
{
"id": "account-loading",
"label": "account-loading",
"path": "/account?fixture=account-loading",
"goal": "Verify production shell UI projection, not server authorization.",
"dataState": "Policy request held pending; login remains available after session loads.",
"ready": {
"kind": "selector",
"selector": "[role=\"status\"]:has-text(\"Checking registration availability\")"
},
"capturePoints": [
{
"id": "initial",
"label": "Initial state",
"fullPage": true
}
]
}
],
"processes": [
{
"id": "hosting-ui-fixture",
"command": "deno",
"cwd": "../../..",
"args": [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15378",
"web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15378/health"
}
]
}
@@ -0,0 +1,178 @@
{
"schemaVersion": 1,
"id": "hosting-setup-dark",
"title": "Hosting setup production UI: synthetic HTTP and navigator.credentials boundary only",
"baseUrl": "http://127.0.0.1:15378",
"colorScheme": "dark",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Synthetic API projection; no real authentication",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "setup-flow",
"label": "Setup enrollment and login UI",
"path": "/hosting/setup?fixture=setup-flow",
"goal": "Separate host-token enrollment from explicit passkey login and verified settings access.",
"dataState": "Synthetic actor, API and non-cryptographic navigator.credentials; no real token/authentication. Test-only bootstrap changes browser API boundary, not production UI modules/styles.",
"ready": {
"kind": "selector",
"selector": "#host-setup-token:enabled"
},
"capturePoints": [
{
"id": "initial",
"label": "Empty password field; enrollment disabled",
"fullPage": true
},
{
"id": "host-help",
"label": "Trusted-host instructions expanded",
"interaction": [
{
"action": "press",
"selector": "summary",
"key": "Enter"
}
],
"ready": {
"kind": "selector",
"selector": "details[open]"
},
"fullPage": true
},
{
"id": "masked-entry",
"label": "Synthetic input password-masked; action enabled",
"interaction": [
{
"action": "press",
"selector": "summary",
"key": "Enter"
},
{
"action": "fill",
"selector": "#host-setup-token",
"value": "${WEB_UX_SYNTHETIC_SETUP_INPUT}"
}
],
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add administrator passkey\"):enabled"
},
"fullPage": true
},
{
"id": "enrolled",
"label": "Enrollment is not login; explicit login handoff",
"interaction": [
{
"action": "click",
"selector": "button:has-text(\"Add administrator passkey\")"
}
],
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Sign in with new passkey\"):enabled"
},
"fullPage": true
},
{
"id": "verified",
"label": "Matching actor/session/access projected verified; settings link",
"interaction": [
{
"action": "press",
"selector": "button:has-text(\"Sign in with new passkey\")",
"key": "Enter"
}
],
"ready": {
"kind": "selector",
"selector": "a:has-text(\"Open hosting settings\")"
},
"fullPage": true
}
]
},
{
"id": "setup-reject",
"label": "Rejected setup enrollment",
"path": "/hosting/setup?fixture=setup-reject",
"goal": "Show safe unconfirmed-enrollment guidance with empty cleared token and no settings action.",
"dataState": "Intentional addition/options HTTP403. Expected console/resource diagnostics; no real authorization exercised.",
"ready": {
"kind": "selector",
"selector": "#host-setup-token:enabled"
},
"capturePoints": [
{
"id": "rejected",
"label": "Rejected attempt; token cleared",
"interaction": [
{
"action": "fill",
"selector": "#host-setup-token",
"value": "${WEB_UX_SYNTHETIC_SETUP_INPUT}"
},
{
"action": "click",
"selector": "button:has-text(\"Add administrator passkey\")"
}
],
"ready": {
"kind": "selector",
"selector": "[role=\"alert\"]"
},
"fullPage": true
}
]
}
],
"processes": [
{
"id": "hosting-ui-fixture",
"command": "deno",
"cwd": "../../..",
"args": [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15378",
"web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15378/health"
}
],
"redact": {
"text": [
"${WEB_UX_SYNTHETIC_SETUP_INPUT}"
]
}
}
@@ -0,0 +1,178 @@
{
"schemaVersion": 1,
"id": "hosting-setup-light",
"title": "Hosting setup production UI: synthetic HTTP and navigator.credentials boundary only",
"baseUrl": "http://127.0.0.1:15378",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Synthetic API projection; no real authentication",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "setup-flow",
"label": "Setup enrollment and login UI",
"path": "/hosting/setup?fixture=setup-flow",
"goal": "Separate host-token enrollment from explicit passkey login and verified settings access.",
"dataState": "Synthetic actor, API and non-cryptographic navigator.credentials; no real token/authentication. Test-only bootstrap changes browser API boundary, not production UI modules/styles.",
"ready": {
"kind": "selector",
"selector": "#host-setup-token:enabled"
},
"capturePoints": [
{
"id": "initial",
"label": "Empty password field; enrollment disabled",
"fullPage": true
},
{
"id": "host-help",
"label": "Trusted-host instructions expanded",
"interaction": [
{
"action": "press",
"selector": "summary",
"key": "Enter"
}
],
"ready": {
"kind": "selector",
"selector": "details[open]"
},
"fullPage": true
},
{
"id": "masked-entry",
"label": "Synthetic input password-masked; action enabled",
"interaction": [
{
"action": "press",
"selector": "summary",
"key": "Enter"
},
{
"action": "fill",
"selector": "#host-setup-token",
"value": "${WEB_UX_SYNTHETIC_SETUP_INPUT}"
}
],
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Add administrator passkey\"):enabled"
},
"fullPage": true
},
{
"id": "enrolled",
"label": "Enrollment is not login; explicit login handoff",
"interaction": [
{
"action": "click",
"selector": "button:has-text(\"Add administrator passkey\")"
}
],
"ready": {
"kind": "selector",
"selector": "button:has-text(\"Sign in with new passkey\"):enabled"
},
"fullPage": true
},
{
"id": "verified",
"label": "Matching actor/session/access projected verified; settings link",
"interaction": [
{
"action": "press",
"selector": "button:has-text(\"Sign in with new passkey\")",
"key": "Enter"
}
],
"ready": {
"kind": "selector",
"selector": "a:has-text(\"Open hosting settings\")"
},
"fullPage": true
}
]
},
{
"id": "setup-reject",
"label": "Rejected setup enrollment",
"path": "/hosting/setup?fixture=setup-reject",
"goal": "Show safe unconfirmed-enrollment guidance with empty cleared token and no settings action.",
"dataState": "Intentional addition/options HTTP403. Expected console/resource diagnostics; no real authorization exercised.",
"ready": {
"kind": "selector",
"selector": "#host-setup-token:enabled"
},
"capturePoints": [
{
"id": "rejected",
"label": "Rejected attempt; token cleared",
"interaction": [
{
"action": "fill",
"selector": "#host-setup-token",
"value": "${WEB_UX_SYNTHETIC_SETUP_INPUT}"
},
{
"action": "click",
"selector": "button:has-text(\"Add administrator passkey\")"
}
],
"ready": {
"kind": "selector",
"selector": "[role=\"alert\"]"
},
"fullPage": true
}
]
}
],
"processes": [
{
"id": "hosting-ui-fixture",
"command": "deno",
"cwd": "../../..",
"args": [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15378",
"web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15378/health"
}
],
"redact": {
"text": [
"${WEB_UX_SYNTHETIC_SETUP_INPUT}"
]
}
}
+2 -1
View File
@@ -6,8 +6,9 @@
"dev": "deno run -A npm:vite@8.3.2 dev", "dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:hosting": "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/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts",
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
"test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored", "test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored",
@@ -3,6 +3,10 @@
export type AuthPublicConfig = { rp_id: string, origin: string, public_base_url: string, cookie_name: string, }; export type AuthPublicConfig = { rp_id: string, origin: string, public_base_url: string, cookie_name: string, };
export type HostingSettings = { self_registration_enabled: boolean, };
export type HostingAccess = { is_admin: boolean, };
export type ActorAuthMethod = "browser_session" | "api_token"; export type ActorAuthMethod = "browser_session" | "api_token";
export type AuthenticatedUser = { user_id: string, account_id: string, handle: string, display_name: string, }; export type AuthenticatedUser = { user_id: string, account_id: string, handle: string, display_name: string, };
@@ -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:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720) // Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
// 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:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720) // Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
// 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:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720) // Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
// 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:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720) // Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
// 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-workdir-openapi-typescript@1. Do not edit. // @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720) // Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts // Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_workdir_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:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720) // Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
// 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
@@ -0,0 +1,221 @@
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
import { enrollHostingPasskey } from "./hosting-enrollment.ts";
import {
authUser,
credentialFixture,
registrationOptions,
} from "./api.test-fixtures.ts";
function equal(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Values differ");
}
}
async function rejects(run: Promise<unknown>): Promise<void> {
try {
await run;
} catch (error) {
equal(
error instanceof Error && error.message,
"Passkey enrollment could not be confirmed.",
);
return;
}
throw new Error("Expected rejection");
}
// 本番clientとWebAuthn変換を接続し、fetchとbrowser credential境界だけを代替する。
// ServerのBearer認証・署名・cookie発行を保証する試験ではない。
async function boundary(
fetcher: typeof fetch,
run: () => Promise<void>,
create: (options: CredentialCreationOptions) => Promise<Credential | null> =
async () => credentialFixture(),
) {
const descriptors = new Map(
["fetch", "window", "navigator"].map((
key,
) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
);
Object.defineProperties(globalThis, {
fetch: { configurable: true, writable: true, value: fetcher },
window: {
configurable: true,
value: {
isSecureContext: true,
location: { origin: "https://yoi.example" },
},
},
navigator: { configurable: true, value: { credentials: { create } } },
});
try {
await run();
} finally {
for (const [key, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
}
Deno.test("host enrollment sends bearer only to same-origin addition endpoints, never registration", async () => {
const requests: { path: string; init: RequestInit }[] = [];
const controller = new AbortController();
await boundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
String(input).endsWith("/options")
? registrationOptions
: { user: authUser },
);
}, async () => {
equal(
await enrollHostingPasskey("host-test-token", controller.signal),
authUser,
);
equal(requests.map((request) => request.path), [
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
for (const { init } of requests) {
equal(init.method, "POST");
equal(init.credentials, "omit");
equal(init.mode, "same-origin");
equal(init.redirect, "error");
equal(init.cache, "no-store");
equal(init.referrerPolicy, "no-referrer");
equal(
new Headers(init.headers).get("authorization"),
"Bearer host-test-token",
);
equal(String(init.body).includes("host-test-token"), false);
}
equal(JSON.parse(String(requests[0].init.body)), {
browser_origin: "https://yoi.example",
});
const complete = JSON.parse(String(requests[1].init.body));
equal(Object.keys(complete).sort(), ["challenge_id", "credential"]);
equal(complete.challenge_id, registrationOptions.challenge_id);
equal(complete.credential.response.attestationObject, "AQID");
}, async (options) => {
equal([...new Uint8Array(options.publicKey!.challenge as ArrayBuffer)], [
1,
2,
3,
]);
equal([...new Uint8Array(options.publicKey!.user.id as ArrayBuffer)], [
4,
5,
6,
]);
equal(options.signal === controller.signal, true);
return credentialFixture();
});
});
Deno.test("host enrollment refuses missing, oversized and header-injecting tokens before network", async () => {
let calls = 0;
await boundary(async () => {
calls++;
return Response.json({});
}, async () => {
for (const token of ["", " ", "x\r\ny", "x".repeat(4097)]) {
await rejects(enrollHostingPasskey(token, new AbortController().signal));
}
equal(calls, 0);
});
});
Deno.test("host enrollment sanitizes rejection and thrown diagnostics without retry", async () => {
for (const failure of [401, 403, 503, "throw"] as const) {
let calls = 0;
await boundary(async () => {
calls++;
if (failure === "throw") throw new Error("host-test-token in diagnostic");
return new Response("host-test-token", { status: failure });
}, async () => {
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(calls, 1);
});
}
});
Deno.test("host enrollment validates options before opening credential prompt", async () => {
for (
const payload of [{}, { ...registrationOptions, challenge_id: "" }, {
...registrationOptions,
unexpected: true,
}]
) {
let calls = 0;
await boundary(async () => Response.json(payload), async () => {
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(calls, 0);
}, async () => {
calls++;
return credentialFixture();
});
}
});
Deno.test("host enrollment cancellation and teardown prevent completion", async () => {
for (const abort of [false, true]) {
const controller = new AbortController();
let requests = 0;
await boundary(async () => {
requests++;
return Response.json(registrationOptions);
}, async () => {
await rejects(enrollHostingPasskey("host-test-token", controller.signal));
equal(requests, 1);
}, async () => {
if (abort) {
controller.abort();
return credentialFixture();
}
return null;
});
}
});
Deno.test("host enrollment cannot confirm a rejected, lost or malformed completion and never retries", async () => {
for (const mode of ["rejected", "lost", "malformed", "oversized"] as const) {
let calls = 0;
await boundary(async (input) => {
calls++;
if (String(input).endsWith("/options")) {
return Response.json(registrationOptions);
}
if (mode === "lost") throw new Error("host-test-token");
if (mode === "rejected") return new Response(null, { status: 401 });
if (mode === "oversized") {
return new Response("{}", { headers: { "content-length": "262145" } });
}
return Response.json({ user: { handle: "host-test-token" } });
}, async () => {
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(calls, 2);
});
}
});
Deno.test("host enrollment sends no token on an insecure browser origin", async () => {
let requests = 0;
await boundary(async () => {
requests++;
return Response.json({});
}, async () => {
Object.defineProperty(window, "isSecureContext", { value: false });
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(requests, 0);
});
});
@@ -0,0 +1,80 @@
import type {
PasskeyAdditionOptionsRequest,
PasskeyRegistrationCompleteRequest,
} from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts";
import {
type AuthUser,
parseAuthUserResponse,
prepareRegistrationOptions,
registrationCredentialToJson,
} from "./model.ts";
// Host発行secretはこの操作のmemoryだけに置く。汎用API clientや永続sessionへ渡さない。
// Cookieが既にあっても、本人の選択はServerがBearerから導出する。
export async function enrollHostingPasskey(
token: string,
signal: AbortSignal,
): Promise<AuthUser> {
try {
// WebAuthnが使えない非secure contextへsecretを送る前に拒否する。
if (window.isSecureContext !== true) {
throw new Error("Secure context required");
}
if (!/^[A-Za-z0-9._~-]{1,4096}$/.test(token)) {
throw new Error("Invalid setup token");
}
const request = async (
path:
| "/api/auth/passkeys/addition/options"
| "/api/auth/passkeys/addition/complete",
body: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
): Promise<unknown> => {
signal.throwIfAborted();
const response = await fetch(path, {
method: "POST",
// Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、
// completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。
credentials: "omit",
mode: "same-origin",
redirect: "error",
cache: "no-store",
referrerPolicy: "no-referrer",
signal,
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error("Setup request rejected");
}
return await readBoundedAuthResponseJson(response);
};
const options = await request("/api/auth/passkeys/addition/options", {
browser_origin: window.location.origin,
});
const publicKey = prepareRegistrationOptions(options);
// prepareRegistrationOptionsでchallenge_idも含めて検証済み。
const challengeId = (options as { challenge_id: string }).challenge_id;
signal.throwIfAborted();
const credential = await navigator.credentials.create({
publicKey,
signal,
});
if (!credential) throw new Error("Credential creation cancelled");
signal.throwIfAborted();
const result = await request("/api/auth/passkeys/addition/complete", {
challenge_id: challengeId,
credential: registrationCredentialToJson(credential),
});
return parseAuthUserResponse(result).user;
} catch {
// fetch/WebAuthn/responseの例外にはsecretを含む可能性があるため転載しない。
throw new Error("Passkey enrollment could not be confirmed.");
} finally {
token = "";
}
}
@@ -0,0 +1,149 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
function assertThrows(fn: () => unknown): void {
try {
fn();
} catch {
return;
}
throw new Error("Expected rejection");
}
async function assertRejects(
fn: () => Promise<unknown>,
message: string,
): Promise<Error> {
try {
await fn();
} catch (error) {
if (error instanceof Error && error.message.includes(message)) return error;
throw new Error("Unexpected rejection");
}
throw new Error("Expected rejection");
}
import {
HostingRequestError,
loadHostingAccess,
loadHostingSettings,
loadRegistrationPolicy,
parseHostingAccess,
parseHostingSettings,
saveHostingSettings,
} from "./hosting.ts";
Deno.test("hosting projections accept only an exact boolean field", () => {
for (const enabled of [true, false]) {
assertEquals(parseHostingSettings({ self_registration_enabled: enabled }), {
self_registration_enabled: enabled,
});
assertEquals(parseHostingAccess({ is_admin: enabled }), {
is_admin: enabled,
});
}
for (
const value of [
null,
[],
{},
true,
{ self_registration_enabled: "true" },
{ self_registration_enabled: 1 },
{ self_registration_enabled: true, secret: "unexpected" },
{ is_admin: true },
]
) {
assertThrows(() => parseHostingSettings(value));
}
for (
const value of [null, [], {}, { is_admin: "true" }, {
is_admin: true,
user: "admin",
}, { self_registration_enabled: true }]
) {
assertThrows(() => parseHostingAccess(value));
}
});
Deno.test("hosting adapters use distinct public/access/admin endpoints and same-origin credentials", async () => {
const original = globalThis.fetch;
const requests: { path: string; init?: RequestInit }[] = [];
globalThis.fetch = ((input, init) => {
const path = String(input);
requests.push({ path, init });
return Promise.resolve(
Response.json(
path.endsWith("/access")
? { is_admin: false }
: { self_registration_enabled: false },
),
);
}) as typeof fetch;
try {
assertEquals(await loadRegistrationPolicy(), {
self_registration_enabled: false,
});
assertEquals(await loadHostingAccess(), { is_admin: false });
assertEquals(await loadHostingSettings(), {
self_registration_enabled: false,
});
assertEquals(
await saveHostingSettings({ self_registration_enabled: false }),
{ self_registration_enabled: false },
);
assertEquals(requests.map((item) => item.path), [
"/api/auth/registration-policy",
"/api/hosting/access",
"/api/hosting/settings",
"/api/hosting/settings",
]);
assertEquals(
requests.map((item) => item.init?.credentials),
Array(4).fill("same-origin"),
);
assertEquals(
requests.map((item) => item.init?.cache),
Array(4).fill("no-store"),
);
assertEquals(requests[3].init?.method, "PUT");
assertEquals(JSON.parse(String(requests[3].init?.body)), {
self_registration_enabled: false,
});
} finally {
globalThis.fetch = original;
}
});
Deno.test("hosting failures expose status without response bodies and reject malformed success", async () => {
const original = globalThis.fetch;
try {
for (const status of [401, 403, 503]) {
globalThis.fetch = () =>
Promise.resolve(new Response("private diagnostic", { status }));
const error = await assertRejects(
loadHostingSettings,
`Hosting request failed (${status}).`,
);
if (!(error instanceof HostingRequestError)) {
throw new Error("Expected HTTP status error");
}
assertEquals(error.status, status);
}
globalThis.fetch = () =>
Promise.resolve(Response.json({ is_admin: "true" }));
await assertRejects(loadHostingAccess, "Invalid hosting response");
globalThis.fetch = () => Promise.resolve(new Response("invalid JSON"));
await assertRejects(loadRegistrationPolicy, "not valid JSON");
} finally {
globalThis.fetch = original;
}
});
@@ -0,0 +1,81 @@
import type {
HostingAccess,
HostingSettings,
} from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts";
export class HostingRequestError extends Error {
constructor(readonly status: number) {
super(`Hosting request failed (${status}).`);
}
}
function booleanRecord(value: unknown, key: string): Record<string, boolean> {
if (
value === null || typeof value !== "object" || Array.isArray(value) ||
Object.keys(value).length !== 1 ||
!Object.hasOwn(value, key) ||
typeof (value as Record<string, unknown>)[key] !== "boolean"
) {
throw new Error(
`Invalid hosting response: expected only ${key} as a boolean.`,
);
}
return value as Record<string, boolean>;
}
export function parseHostingSettings(value: unknown): HostingSettings {
return {
self_registration_enabled: booleanRecord(value, "self_registration_enabled")
.self_registration_enabled,
};
}
export function parseHostingAccess(value: unknown): HostingAccess {
return { is_admin: booleanRecord(value, "is_admin").is_admin };
}
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(path, {
credentials: "same-origin",
cache: "no-store",
...init,
headers: { "content-type": "application/json", ...init?.headers },
});
if (!response.ok) {
await response.body?.cancel();
throw new HostingRequestError(response.status);
}
return await readBoundedAuthResponseJson(response);
}
export async function loadRegistrationPolicy(): Promise<HostingSettings> {
return parseHostingSettings(
await requestJson("/api/auth/registration-policy"),
);
}
export async function loadHostingAccess(): Promise<HostingAccess> {
return parseHostingAccess(await requestJson("/api/hosting/access"));
}
export async function loadHostingSettings(): Promise<HostingSettings> {
return parseHostingSettings(await requestJson("/api/hosting/settings"));
}
export async function saveHostingSettings(
settings: HostingSettings,
): Promise<HostingSettings> {
return parseHostingSettings(
await requestJson("/api/hosting/settings", {
method: "PUT",
body: JSON.stringify(parseHostingSettings(settings)),
}),
);
}
// Session変更は再取得の契機だけに使い、権限そのものはaccess endpointで確認する。
export const SESSION_CHANGED_EVENT = "yoi:browser-session-changed";
export function notifySessionChanged(): void {
window.dispatchEvent(new Event(SESSION_CHANGED_EVENT));
}
@@ -1,5 +1,42 @@
<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 { loadWhoami } from "#lib/workspace/auth/api.ts";
import { loadHostingAccess, SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
let mounted = $state(false);
let isAdmin = $state(false);
let accessRequest = 0;
async function refreshAccess() {
const request = ++accessRequest;
isAdmin = false;
try {
const whoami = await loadWhoami();
if (request !== accessRequest || !whoami.actor) return;
const access = await loadHostingAccess();
if (request === accessRequest) isAdmin = access.is_admin;
} catch {
// 未確認の権限では管理導線を表示しない。直接screenでも独立して検証する。
}
}
onMount(() => {
mounted = true;
const refresh = () => { void refreshAccess(); };
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
window.addEventListener("focus", refresh);
return () => {
++accessRequest;
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
window.removeEventListener("focus", refresh);
};
});
$effect(() => {
currentPath;
if (mounted) untrack(() => { void refreshAccess(); });
});
type Props = { type Props = {
currentPath: string; currentPath: string;
@@ -37,6 +74,14 @@
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
{/each} {/each}
{#if isAdmin}
<a
class="sidebar-link"
class:active={currentPath === "/hosting/settings"}
href="/hosting/settings"
aria-current={currentPath === "/hosting/settings" ? "page" : undefined}
>Hosting settings</a>
{/if}
</div> </div>
</section> </section>
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import GlobalNavSections from "./GlobalNavSections.svelte";
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
import { notifySessionChanged } from "#lib/workspace/auth/hosting.ts";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
for (
const persona of ["anonymous", "ordinary", "admin", "unavailable"] as const
) {
test(`global navigation exposes hosting settings only for confirmed admin, not ${persona}`, async () => {
let actor = persona !== "anonymous";
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
if (String(input) === "/api/auth/whoami") {
return Response.json({
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
});
}
if (String(input) === "/api/hosting/access") {
if (persona === "unavailable") {
return new Response(null, { status: 503 });
}
return Response.json({ is_admin: persona === "admin" });
}
throw new Error(`Unexpected API ${input}`);
});
vi.stubGlobal("fetch", fetcher);
render(GlobalNavSections, {
currentPath: "/hosting/settings",
workspaces: [],
});
await waitFor(() => expect(fetcher).toHaveBeenCalled());
if (persona === "admin") {
const link = await screen.findByRole("link", {
name: "Hosting settings",
});
expect(link.getAttribute("href")).toBe("/hosting/settings");
expect(link.getAttribute("aria-current")).toBe("page");
actor = false;
notifySessionChanged();
await waitFor(() =>
expect(screen.queryByRole("link", { name: "Hosting settings" }))
.toBeNull()
);
} else {
if (persona !== "anonymous") {
await waitFor(() =>
expect(
fetcher.mock.calls.some(([path]) => path === "/api/hosting/access"),
).toBe(true)
);
}
expect(screen.queryByRole("link", { name: "Hosting settings" }))
.toBeNull();
}
});
}
+45 -3
View File
@@ -9,6 +9,7 @@
} from '#lib/workspace/auth/api.ts'; } from '#lib/workspace/auth/api.ts';
import '#lib/workspace/styles/settings.css'; import '#lib/workspace/styles/settings.css';
import type { RequestActor } from '#lib/workspace/auth/model.ts'; import type { RequestActor } from '#lib/workspace/auth/model.ts';
import { loadRegistrationPolicy, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
let actor = $state<RequestActor | null>(null); let actor = $state<RequestActor | null>(null);
let handle = $state('local'); let handle = $state('local');
@@ -18,6 +19,25 @@
let busy = $state(false); let busy = $state(false);
let status = $state(''); let status = $state('');
let error = $state(''); let error = $state('');
let policy = $state<boolean | null>(null);
let policyLoading = $state(true);
let policyError = $state('');
let policyRequest = 0;
async function refreshPolicy() {
const request = ++policyRequest;
policyLoading = true;
policy = null;
policyError = '';
try {
const settings = await loadRegistrationPolicy();
if (request === policyRequest) policy = settings.self_registration_enabled;
} catch {
if (request === policyRequest) policyError = 'Registration availability could not be loaded.';
} finally {
if (request === policyRequest) policyLoading = false;
}
}
async function refreshWhoami() { async function refreshWhoami() {
loading = true; loading = true;
@@ -27,6 +47,7 @@
const whoami = await loadWhoami(); const whoami = await loadWhoami();
actor = whoami.actor; actor = whoami.actor;
sessionReady = true; sessionReady = true;
notifySessionChanged();
if (actor?.handle) { if (actor?.handle) {
handle = actor.handle; handle = actor.handle;
displayName = actor.display_name; displayName = actor.display_name;
@@ -39,7 +60,7 @@
} }
async function register() { async function register() {
if (busy || !sessionReady) return; if (busy || !sessionReady || (!actor && policy !== true)) return;
const adding = actor !== null; const adding = actor !== null;
busy = true; busy = true;
status = adding ? 'Waiting for passkey addition…' : 'Waiting for new account registration…'; status = adding ? 'Waiting for passkey addition…' : 'Waiting for new account registration…';
@@ -55,6 +76,7 @@
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);
status = ''; status = '';
if (!adding) void refreshPolicy();
} finally { } finally {
busy = false; busy = false;
} }
@@ -82,6 +104,8 @@
try { try {
await logout(); await logout();
actor = null; actor = null;
notifySessionChanged();
void refreshPolicy();
status = 'Logged out.'; status = 'Logged out.';
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);
@@ -92,6 +116,12 @@
onMount(() => { onMount(() => {
void refreshWhoami(); void refreshWhoami();
void refreshPolicy();
window.addEventListener('focus', refreshPolicy);
return () => {
++policyRequest;
window.removeEventListener('focus', refreshPolicy);
};
}); });
</script> </script>
@@ -153,13 +183,25 @@
</label> </label>
<label> <label>
Display name (new account only) Display name (new account only)
<input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="name" placeholder="Local User" /> <input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null || policy !== true} autocomplete="name" placeholder="Local User" />
</label> </label>
<div class="settings-action-row"> <div class="settings-action-row">
<button type="button" disabled={busy || loading || !sessionReady || (!actor && !handle.trim())} onclick={register}>{actor ? `Add passkey to ${actor.handle}` : 'Register new account'}</button> {#if actor || policy === true}
<button type="button" disabled={busy || loading || !sessionReady || (!actor && !handle.trim())} onclick={register}>{actor ? `Add passkey to ${actor.handle}` : 'Register new account'}</button>
{/if}
<button type="button" disabled={busy || loading || !sessionReady || actor !== null || !handle.trim()} onclick={login}>Log in with passkey</button> <button type="button" disabled={busy || loading || !sessionReady || actor !== null || !handle.trim()} onclick={login}>Log in with passkey</button>
</div> </div>
</form> </form>
{#if !actor}
{#if policyLoading}
<p class="muted" role="status">Checking registration availability…</p>
{:else if policyError}
<p class="error-message" role="alert">{policyError}</p>
<button type="button" onclick={refreshPolicy}>Retry registration availability</button>
{:else if policy === false}
<p class="muted">New account registration is disabled. Existing users can still log in.</p>
{/if}
{/if}
</section> </section>
{#if status} {#if status}
@@ -11,6 +11,7 @@ import Account from "./+page.svelte";
import { import {
authUser, authUser,
credentialFixture, credentialFixture,
loginOptions,
registrationOptions, registrationOptions,
} from "#lib/workspace/auth/api.test-fixtures.ts"; } from "#lib/workspace/auth/api.test-fixtures.ts";
@@ -20,9 +21,14 @@ afterEach(() => {
}); });
// UIから本番HTTP adapterまでを接続し、fetch/ブラウザーcredential境界だけを代替する。 // UIから本番HTTP adapterまでを接続し、fetch/ブラウザーcredential境界だけを代替する。
function boundary(authenticated: boolean, completeStatus = 200) { function boundary(
authenticated: boolean,
completeStatus = 200,
policy: boolean | "error" | "loading" = true,
) {
const create = vi.fn(async () => credentialFixture()); const create = vi.fn(async () => credentialFixture());
vi.stubGlobal("navigator", { credentials: { create } }); const get = vi.fn(async () => credentialFixture(true));
vi.stubGlobal("navigator", { credentials: { create, get } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const path = String(input); const path = String(input);
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
@@ -32,9 +38,20 @@ function boundary(authenticated: boolean, completeStatus = 200) {
: null, : null,
}); });
} }
if (path === "/api/auth/registration-policy") {
if (policy === "loading") return await new Promise<Response>(() => {});
if (policy === "error") return new Response(null, { status: 503 });
return Response.json({ self_registration_enabled: policy });
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path.endsWith("/options")) return Response.json(registrationOptions); if (path.endsWith("/options")) return Response.json(registrationOptions);
if (path.endsWith("/complete")) { if (path.endsWith("/complete")) {
if (completeStatus !== 200) { if (completeStatus !== 200) {
if (completeStatus === 403 && path.includes("/registration/")) {
policy = false;
}
return new Response(null, { status: completeStatus }); return new Response(null, { status: completeStatus });
} }
authenticated = true; authenticated = true;
@@ -43,7 +60,7 @@ function boundary(authenticated: boolean, completeStatus = 200) {
throw new Error(`Unexpected API: ${path}`); throw new Error(`Unexpected API: ${path}`);
}); });
vi.stubGlobal("fetch", fetcher); vi.stubGlobal("fetch", fetcher);
return { fetcher, create }; return { fetcher, create, get };
} }
function posted(fetcher: ReturnType<typeof boundary>["fetcher"]) { function posted(fetcher: ReturnType<typeof boundary>["fetcher"]) {
@@ -90,7 +107,7 @@ test("anonymous existing Account page registers a new account via registration e
}); });
test("authenticated Account locks identity fields and adds only through self-only addition endpoints", async () => { test("authenticated Account locks identity fields and adds only through self-only addition endpoints", async () => {
const { fetcher, create } = boundary(true); const { fetcher, create } = boundary(true, 200, false);
render(Account); render(Account);
const button = await screen.findByRole("button", { const button = await screen.findByRole("button", {
name: "Add passkey to alice", name: "Add passkey to alice",
@@ -167,14 +184,87 @@ test("unknown session never enables registration or addition", async () => {
); );
render(Account); render(Account);
await screen.findByText("Auth request failed (503)."); await screen.findByText("Auth request failed (503).");
expect(
(screen.getByRole("button", {
name: "Register new account",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect( expect(
(screen.getByLabelText( (screen.getByLabelText(
"User handle (new registration or login)", "User handle (new registration or login)",
) as HTMLInputElement).disabled, ) as HTMLInputElement).disabled,
).toBe(true); ).toBe(true);
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull();
}); });
for (const policy of [false, "error", "loading"] as const) {
test(`policy ${policy} still permits the existing passkey login HTTP flow`, async () => {
const { fetcher, get, create } = boundary(false, 200, policy);
render(Account);
await screen.findByText("No browser session is active.");
await fireEvent.click(
screen.getByRole("button", { name: "Log in with passkey" }),
);
await screen.findByText("Logged in.");
expect(posted(fetcher).map((request) => request.path)).toEqual([
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
]);
expect(get).toHaveBeenCalledOnce();
expect(create).not.toHaveBeenCalled();
});
}
test("registration disabled during WebAuthn completion shows rejection and reloads policy without fallback", async () => {
const { fetcher } = boundary(false, 403, true);
render(Account);
await fireEvent.click(
await screen.findByRole("button", { name: "Register new account" }),
);
await screen.findByText("Auth request failed (403).");
await screen.findByText(/New account registration is disabled/);
expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull();
expect(screen.queryByText("New account registered.")).toBeNull();
expect(posted(fetcher).map((request) => request.path)).toEqual([
"/api/auth/passkeys/registration/options",
"/api/auth/passkeys/registration/complete",
]);
});
for (const policy of [false, "error", "loading"] as const) {
test(`registration policy ${policy} hides registration without disabling login`, async () => {
const { fetcher } = boundary(false, 200, policy);
render(Account);
await screen.findByText("No browser session is active.");
if (policy === false) {
await screen.findByText(/New account registration is disabled/);
}
if (policy === "error") {
await screen.findByText("Registration availability could not be loaded.");
}
if (policy === "loading") {
await screen.findByText("Checking registration availability…");
}
expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull();
expect(
(screen.getByRole("button", {
name: "Log in with passkey",
}) as HTMLButtonElement).disabled,
).toBe(false);
expect(posted(fetcher)).toEqual([]);
});
}
for (const policy of ["error", "loading"] as const) {
test(`policy ${policy} does not block actor-self passkey addition`, async () => {
const { fetcher } = boundary(true, 200, policy);
render(Account);
await fireEvent.click(
await screen.findByRole("button", { name: "Add passkey to alice" }),
);
await screen.findByText("Passkey added to your account.");
expect(posted(fetcher).map((request) => request.path)).toEqual([
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
});
}
@@ -0,0 +1,175 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import type { HostingSettings } from '#lib/generated/auth-api.ts';
import { loadWhoami } from '#lib/workspace/auth/api.ts';
import {
HostingRequestError,
loadHostingAccess,
loadHostingSettings,
saveHostingSettings,
SESSION_CHANGED_EVENT,
} from '#lib/workspace/auth/hosting.ts';
let viewState = $state<'loading' | 'anonymous' | 'denied' | 'error' | 'ready'>('loading');
let settings = $state<HostingSettings | null>(null);
let draft = $state(false);
let editing = $state(false);
let saving = $state(false);
let saveBlocked = $state(false);
let error = $state('');
let status = $state('');
let request = 0;
let editButton = $state<HTMLButtonElement>();
let checkbox = $state<HTMLInputElement>();
function permissionFailure(err: unknown): boolean {
if (!(err instanceof HostingRequestError)) return false;
if (err.status !== 401 && err.status !== 403) return false;
settings = null;
editing = false;
viewState = err.status === 401 ? 'anonymous' : 'denied';
return true;
}
async function load() {
const current = ++request;
viewState = 'loading';
settings = null;
editing = false;
error = '';
status = '';
saveBlocked = false;
try {
const whoami = await loadWhoami();
if (current !== request) return;
if (!whoami.actor) { viewState = 'anonymous'; return; }
const access = await loadHostingAccess();
if (current !== request) return;
if (!access.is_admin) { viewState = 'denied'; return; }
const loaded = await loadHostingSettings();
if (current !== request) return;
settings = loaded;
draft = loaded.self_registration_enabled;
viewState = 'ready';
} catch (err) {
if (current !== request) return;
if (!permissionFailure(err)) {
viewState = 'error';
error = 'Hosting settings could not be loaded.';
}
}
}
async function edit() {
if (!settings) return;
draft = settings.self_registration_enabled;
editing = true;
status = '';
await tick();
checkbox?.focus();
}
async function cancel() {
editing = false;
await tick();
editButton?.focus();
}
async function save() {
if (saving || saveBlocked || viewState !== 'ready' || !settings) return;
const current = request;
saving = true;
error = '';
status = '';
try {
const saved = await saveHostingSettings({ self_registration_enabled: draft });
if (current !== request) return;
settings = saved;
draft = saved.self_registration_enabled;
editing = false;
status = 'Hosting settings saved.';
await tick();
editButton?.focus();
} catch (err) {
if (current !== request) return;
if (!permissionFailure(err)) {
// 応答喪失時に未保存と断定しない。再読込後に次の保存を判断する。
saveBlocked = true;
error = 'Save could not be confirmed. Reload settings before making another change.';
}
} finally {
saving = false;
}
}
onMount(() => {
void load();
const refresh = () => { void load(); };
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
return () => {
++request;
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
};
});
</script>
<svelte:head><title>Hosting settings · Yoi</title></svelte:head>
<div class="hosting-settings">
<h1>Hosting settings</h1>
{#if viewState === 'loading'}
<p role="status">Loading hosting settings…</p>
{:else if viewState === 'anonymous'}
<p>Sign in with a hosting administrator account to view these settings.</p>
<a href="/account">Open Account</a>
{:else if viewState === 'denied'}
<p>Hosting administrator access is required.</p>
{:else if viewState === 'error'}
<p class="error" role="alert">{error}</p>
<button type="button" onclick={load}>Retry loading settings</button>
{:else if settings}
<section aria-labelledby="registration-heading">
<h2 id="registration-heading">Self-registration</h2>
<p class="muted">Applies to this hosting, across all Workspaces.</p>
<p>Last loaded policy: <strong>{settings.self_registration_enabled ? 'Enabled' : 'Disabled'}</strong></p>
{#if editing}
<form onsubmit={(event) => { event.preventDefault(); void save(); }}>
<label class="checkbox-row">
<input bind:this={checkbox} type="checkbox" bind:checked={draft} disabled={saving || saveBlocked} aria-describedby="registration-impact" />
Allow new account registration
</label>
<p id="registration-impact" class="muted">Disabling registration does not prevent existing users from logging in or adding a passkey to their own account.</p>
<div class="actions">
<button class="primary" type="submit" disabled={saving || saveBlocked || draft === settings.self_registration_enabled}>{saving ? 'Saving…' : 'Save settings'}</button>
<button type="button" disabled={saving} onclick={cancel}>Cancel</button>
</div>
</form>
{:else}
<button bind:this={editButton} type="button" disabled={saveBlocked} onclick={edit}>Edit registration policy</button>
{/if}
{#if error}
<p class="error" role="alert">{error}</p>
<button type="button" onclick={load}>Reload settings</button>
{/if}
{#if status}<p role="status">{status}</p>{/if}
</section>
{/if}
</div>
<style>
.hosting-settings, section, form { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
.hosting-settings { max-width: 48rem; }
h1, h2, p { margin: 0; }
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
h2 { font-size: var(--font-size-body); line-height: var(--line-height-body); }
.muted { color: var(--text-muted); }
.error { color: var(--danger); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.checkbox-row { display: flex; align-items: center; gap: var(--space-2); }
input { flex: 0 0 auto; width: 1rem; height: 1rem; }
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
button.primary { background: var(--text-strong); color: var(--bg); }
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
button:disabled { opacity: .55; cursor: not-allowed; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
@@ -0,0 +1,168 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import Settings from "./+page.svelte";
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function boundary(
persona: "anonymous" | "ordinary" | "admin",
enabled = true,
saveStatus = 200,
) {
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") {
return Response.json({
actor: persona === "anonymous"
? null
: { ...authUser, auth_method: "browser_session" },
});
}
if (path === "/api/hosting/access") {
return Response.json({ is_admin: persona === "admin" });
}
if (path === "/api/hosting/settings") {
if (persona !== "admin") return new Response(null, { status: 403 });
if (init?.method === "PUT") {
if (saveStatus !== 200) {
return new Response(null, { status: saveStatus });
}
enabled = JSON.parse(String(init.body)).self_registration_enabled;
}
return Response.json({ self_registration_enabled: enabled });
}
throw new Error(`Unexpected API ${path}`);
},
);
vi.stubGlobal("fetch", fetcher);
return fetcher;
}
for (const persona of ["anonymous", "ordinary"] as const) {
test(`${persona} direct hosting screen contains no settings controls or privileged requests`, async () => {
const fetcher = boundary(persona);
render(Settings);
await screen.findByText(
persona === "anonymous"
? "Sign in with a hosting administrator account to view these settings."
: "Hosting administrator access is required.",
);
expect(screen.queryByRole("button")).toBeNull();
expect(screen.queryByRole("checkbox")).toBeNull();
expect(
fetcher.mock.calls.some(([path]) => path === "/api/hosting/settings"),
).toBe(false);
});
}
for (const enabled of [true, false]) {
test(`admin reads policy ${enabled}, edits with keyboard focus, cancels and saves confirmed value`, async () => {
const fetcher = boundary("admin", enabled);
render(Settings);
const edit = await screen.findByRole("button", {
name: "Edit registration policy",
});
expect(screen.getByText(enabled ? "Enabled" : "Disabled")).toBeTruthy();
expect(screen.queryByRole("checkbox")).toBeNull();
await fireEvent.click(edit);
let checkbox = screen.getByRole("checkbox") as HTMLInputElement;
await waitFor(() => expect(document.activeElement).toBe(checkbox));
expect(checkbox.checked).toBe(enabled);
expect(
(screen.getByRole("button", {
name: "Save settings",
}) as HTMLButtonElement).disabled,
).toBe(true);
await fireEvent.click(checkbox);
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Edit registration policy" }),
)
);
await fireEvent.click(
screen.getByRole("button", { name: "Edit registration policy" }),
);
checkbox = screen.getByRole("checkbox") as HTMLInputElement;
expect(checkbox.checked).toBe(enabled);
await fireEvent.click(checkbox);
await fireEvent.click(
screen.getByRole("button", { name: "Save settings" }),
);
await screen.findByText("Hosting settings saved.");
expect(screen.getByText(enabled ? "Disabled" : "Enabled")).toBeTruthy();
const writes = fetcher.mock.calls.filter(([, init]) =>
init?.method === "PUT"
);
expect(writes).toHaveLength(1);
expect(JSON.parse(String(writes[0][1]?.body))).toEqual({
self_registration_enabled: !enabled,
});
});
}
for (const status of [401, 403, 503]) {
test(`save rejection ${status} never claims success or automatically retries`, async () => {
const fetcher = boundary("admin", true, status);
render(Settings);
await fireEvent.click(
await screen.findByRole("button", { name: "Edit registration policy" }),
);
await fireEvent.click(screen.getByRole("checkbox"));
await fireEvent.click(
screen.getByRole("button", { name: "Save settings" }),
);
if (status === 503) {
await screen.findByText(
"Save could not be confirmed. Reload settings before making another change.",
);
expect(screen.getByText("Enabled")).toBeTruthy();
expect(
(screen.getByRole("button", {
name: "Save settings",
}) as HTMLButtonElement).disabled,
).toBe(true);
await fireEvent.click(
screen.getByRole("button", { name: "Reload settings" }),
);
await screen.findByRole("button", { name: "Edit registration policy" });
} else {
await screen.findByText(
status === 401
? "Sign in with a hosting administrator account to view these settings."
: "Hosting administrator access is required.",
);
expect(screen.queryByRole("checkbox")).toBeNull();
expect(screen.queryByRole("button")).toBeNull();
}
expect(screen.queryByText("Hosting settings saved.")).toBeNull();
expect(fetcher.mock.calls.filter(([, init]) => init?.method === "PUT"))
.toHaveLength(1);
});
}
test("unconfirmed access and settings failures show no settings controls and offer retry", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 503 })),
);
render(Settings);
await screen.findByText("Hosting settings could not be loaded.");
expect(screen.queryByRole("checkbox")).toBeNull();
expect(screen.queryByRole("button", { name: "Edit registration policy" }))
.toBeNull();
expect(screen.getByRole("button", { name: "Retry loading settings" }))
.toBeTruthy();
});
@@ -0,0 +1,116 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { loadWhoami, loginWithPasskey } from '#lib/workspace/auth/api.ts';
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
import type { AuthUser } from '#lib/workspace/auth/model.ts';
let token = $state('');
let user = $state<AuthUser | null>(null);
let busy = $state(false);
let verifiedLogin = $state(false);
let error = $state('');
const controller = new AbortController();
let active = true;
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
async function verifySession(expected: AuthUser): Promise<boolean> {
const { actor } = await loadWhoami();
if (!active || actor?.user_id !== expected.user_id || actor.account_id !== expected.account_id || actor.auth_method !== 'browser_session') return false;
const access = await loadHostingAccess();
return active && access.is_admin;
}
async function enroll() {
if (busy || user || !token.trim()) return;
busy = true;
error = '';
const pending = enrollHostingPasskey(token.trim(), controller.signal);
// 非同期操作に渡した後、form側にはsecretを残さない。
token = '';
try {
const enrolled = await pending;
if (!active) return;
user = enrolled;
} catch {
if (active) error = 'Passkey enrollment could not be confirmed. The token was cleared. If you completed the passkey prompt, try signing in from Account before enrolling again. Otherwise check the token and its expiry with the host operator.';
} finally {
token = '';
if (active) busy = false;
}
}
async function login() {
if (busy || !user) return;
const expected = user;
busy = true;
error = '';
try {
const signedIn = await loginWithPasskey(expected.handle);
if (!active) return;
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id || !await verifySession(expected)) {
throw new Error('Login or hosting access not verified');
}
if (!active) return;
verifiedLogin = true;
notifySessionChanged();
} 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.';
} finally {
if (active) busy = false;
}
}
onDestroy(() => {
active = false;
token = '';
controller.abort();
});
</script>
<svelte:head><title>Hosting setup · Yoi</title></svelte:head>
<div class="hosting-setup">
<h1>Hosting setup</h1>
{#if !user}
<p>Use a host-issued setup token to add the first passkey to the administrator account selected by the host. Opening this page does not create an account or grant administrator access.</p>
<details>
<summary>Get a token on the trusted host</summary>
<p>The host operator runs this command against the Server database, using a new handle:</p>
<pre><code>yoi-server hosting-admin --database PATH --handle NEW --create-user</code></pre>
<p>Use the access_token printed once by that command within 15 minutes. Keep it private; do not put it in a URL, screenshot, shared terminal log, or support message. This is not public registration or an activation code.</p>
</details>
<form onsubmit={(event) => { event.preventDefault(); void enroll(); }} autocomplete="off">
<label for="host-setup-token">Host setup token</label>
<input id="host-setup-token" type="password" bind:value={token} autocomplete="off" spellcheck="false" autocapitalize="off" maxlength="4096" disabled={busy} required aria-describedby="setup-token-help" />
<p id="setup-token-help" class="muted">Only enter a token from your trusted host operator on this Server’s trusted HTTPS origin (or localhost). It is kept only in this page’s memory and cleared after an attempt or when leaving.</p>
<button class="primary" type="submit" disabled={busy || !token.trim()}>{busy ? 'Adding passkey…' : 'Add administrator passkey'}</button>
</form>
<a href="/account">Already have a passkey? Open Account</a>
{:else if !verifiedLogin}
<p role="status">Passkey added for {user.handle}. Enrollment is not a passkey login.</p>
<p>Sign in with the new passkey to verify it before opening hosting settings.</p>
<button class="primary" type="button" disabled={busy} onclick={login}>{busy ? 'Signing in…' : 'Sign in with new passkey'}</button>
{:else}
<p role="status">Passkey login and hosting administrator access verified.</p>
<a href="/hosting/settings">Open hosting settings</a>
{/if}
{#if error}<p class="error" role="alert">{error}</p>{/if}
</div>
<style>
.hosting-setup, form, details[open] { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
.hosting-setup { max-width: 42rem; }
h1, p, pre { margin: 0; }
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
.muted { color: var(--text-muted); }
.error { color: var(--danger); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); }
summary { cursor: pointer; }
input { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); font: inherit; cursor: pointer; }
button.primary { background: var(--text-strong); color: var(--bg); }
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
button:disabled { opacity: .55; cursor: not-allowed; }
button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
@@ -0,0 +1,263 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import Setup from "./+page.svelte";
import {
authUser,
credentialFixture,
loginOptions,
registrationOptions,
} from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
// UI→本番adapter/serializerを接続。fetchとbrowser credentialだけ代替する。
// Backend認証・権限の拒否や署名を検証する試験ではない。
function boundary(
{ status = 200, admin = true, actor = true, failLogin = false } = {},
) {
vi.stubGlobal("isSecureContext", true);
const create = vi.fn(async () => credentialFixture());
const get = vi.fn(async () => 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 Response.json({
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
});
}
if (path === "/api/hosting/access") {
return Response.json({ is_admin: admin });
}
if (path === "/api/auth/passkeys/addition/options") {
return status === 200
? Response.json(registrationOptions)
: new Response("host-test-token", { status });
}
if (path === "/api/auth/passkeys/addition/complete") {
return Response.json({ user: authUser });
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return failLogin
? new Response(null, { status: 401 })
: Response.json({ user: authUser });
}
throw new Error(`Unexpected API: ${path}`);
});
vi.stubGlobal("fetch", fetcher);
return { fetcher, create, get };
}
async function enroll() {
await fireEvent.input(screen.getByLabelText("Host setup token"), {
target: { value: "host-test-token" },
});
await fireEvent.click(
screen.getByRole("button", { name: "Add administrator passkey" }),
);
}
test("public shell does not request or grant anything without explicit host token input", () => {
const { fetcher, create } = boundary();
render(Setup);
const input = screen.getByLabelText("Host setup token") as HTMLInputElement;
expect(input.type).toBe("password");
expect(input.autocomplete).toBe("off");
expect(input.getAttribute("name")).toBeNull();
expect(
(screen.getByRole("button", {
name: "Add administrator passkey",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
expect(fetcher).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
});
test("enrollment clears token and omits cookies, then verifies an actual passkey login before settings", async () => {
const { fetcher, create, get } = boundary();
const localWrite = vi.spyOn(window.localStorage, "setItem");
const sessionWrite = vi.spyOn(window.sessionStorage, "setItem");
render(Setup);
await enroll();
await screen.findByRole("button", { name: "Sign in with new passkey" });
expect(fetcher.mock.calls.some(([path]) => path === "/api/hosting/access"))
.toBe(false);
expect(create).toHaveBeenCalledTimes(1);
expect(get).not.toHaveBeenCalled();
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
expect(document.body.textContent).not.toContain("host-test-token");
expect(localWrite).not.toHaveBeenCalled();
expect(sessionWrite).not.toHaveBeenCalled();
const requests = fetcher.mock.calls;
for (const [path, init] of requests) {
expect(String(path)).not.toContain("host-test-token");
expect(String(init?.body)).not.toContain("host-test-token");
expect(new Headers(init?.headers).get("authorization")).toBe(
String(path).includes("/addition/") ? "Bearer host-test-token" : null,
);
}
await fireEvent.click(
screen.getByRole("button", { name: "Sign in with new passkey" }),
);
const settings = await screen.findByRole("link", {
name: "Open hosting settings",
});
expect(settings.getAttribute("href")).toBe("/hosting/settings");
expect(get).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
.toHaveLength(1);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
.toHaveLength(1);
expect(
fetcher.mock.calls.some(([path]) => String(path).includes("registration")),
).toBe(false);
});
for (const status of [401, 403, 503]) {
test(`token rejection ${status} clears input and never falls back to registration`, async () => {
const { fetcher, create } = boundary({ status });
const log = vi.spyOn(console, "error");
render(Setup);
await enroll();
await screen.findByRole("alert");
expect(
(screen.getByLabelText("Host setup token") as HTMLInputElement).value,
).toBe("");
expect(screen.getByRole("alert").textContent).not.toContain(
"host-test-token",
);
expect(log).not.toHaveBeenCalled();
expect(fetcher).toHaveBeenCalledTimes(1);
expect(create).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
.toBeNull();
});
}
for (
const context of [{ admin: false }, { actor: false }, { failLogin: true }]
) {
test(`unverified login or hosting access never exposes settings: ${JSON.stringify(context)}`, async () => {
boundary(context);
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();
});
}
test("a different cookie actor cannot be accepted as the enrolled administrator", async () => {
const { fetcher } = boundary();
const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami"
? Response.json({
actor: {
...authUser,
user_id: "other-user",
auth_method: "browser_session",
},
})
: original(input, init)
);
render(Setup);
await enroll();
await screen.findByRole("button", { name: "Sign in with new passkey" });
await fireEvent.click(
screen.getByRole("button", { name: "Sign in with new passkey" }),
);
await screen.findByRole("alert");
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
expect(fetcher.mock.calls.some(([path]) => path === "/api/hosting/access"))
.toBe(false);
});
test("leaving during the passkey prompt aborts enrollment and prevents later completion", async () => {
const { create, fetcher } = boundary();
let resolve!: (credential: Credential) => void;
create.mockImplementation(() =>
new Promise<Credential>((done) => {
resolve = done;
})
);
const { unmount } = render(Setup);
await enroll();
await waitFor(() => expect(create).toHaveBeenCalledTimes(1));
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe("");
const signal =
(create.mock.calls[0] as unknown as [CredentialCreationOptions])[0].signal!;
unmount();
expect(signal.aborted).toBe(true);
resolve(credentialFixture());
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
const { unmount: unmountAgain } = render(Setup);
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe("");
unmountAgain();
});
test("completion response loss does not claim success and points to login before another enrollment", async () => {
const { fetcher } = boundary();
const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
if (String(input) === "/api/auth/passkeys/addition/complete") {
throw new Error("host-test-token");
}
return original(input, init);
});
render(Setup);
await enroll();
await screen.findByRole("alert");
expect(screen.getByRole("alert").textContent).toContain(
"try signing in from Account",
);
expect(screen.getByRole("alert").textContent).not.toContain(
"host-test-token",
);
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe("");
expect(fetcher).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
.toBeNull();
});
test("API token identity is not mistaken for a verified browser session", async () => {
const { fetcher } = boundary();
const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami"
? Response.json({ actor: { ...authUser, auth_method: "api_token" } })
: original(input, init)
);
render(Setup);
await enroll();
await screen.findByRole("button", { name: "Sign in with new passkey" });
await fireEvent.click(
screen.getByRole("button", { name: "Sign in with new passkey" }),
);
await screen.findByRole("alert");
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
});
@@ -24,6 +24,7 @@ interface Fixture {
workspace_id: string; workspace_id: string;
owner_token: string; owner_token: string;
second_token: string; second_token: string;
foreign_token: string;
worker_entry: DriveEntry; worker_entry: DriveEntry;
} }
function assert(value: unknown, message: string): asserts value { function assert(value: unknown, message: string): asserts value {
@@ -164,14 +165,14 @@ try {
"revoke must be durably visible", "revoke must be durably visible",
); );
} }
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.second_token); globalThis.fetch = authenticatedFetch(fixture.origin, fixture.foreign_token);
try { try {
await listDriveGrants(ws); await listDriveGrants(ws);
throw new Error("member must not manage grants"); throw new Error("foreign Account must not manage grants");
} catch (error) { } catch (error) {
assert( assert(
error instanceof DriveGrantError && error.outcome === "not_committed", error instanceof DriveGrantError && error.outcome === "not_committed",
"member grant read must be a real authorized rejection", "foreign Account grant read must be a real authorized rejection",
); );
} }
} finally { } finally {
@@ -318,7 +319,7 @@ const secondRead = await second.readText(ws, updated.entry);
equal( equal(
secondRead.entry.last_mutation_id, secondRead.entry.last_mutation_id,
updated.last_mutation_id, updated.last_mutation_id,
"second actor must observe same last_mutation_id", "second owner client must observe same last_mutation_id",
); );
const drafts = ["# actor A draft\n", "# actor B draft\n"]; const drafts = ["# actor A draft\n", "# actor B draft\n"];
const race = await Promise.allSettled( const race = await Promise.allSettled(
@@ -335,7 +336,7 @@ const race = await Promise.allSettled(
equal( equal(
race.filter((result) => result.status === "fulfilled").length, race.filter((result) => result.status === "fulfilled").length,
1, 1,
"same-last_mutation_id two-actor CAS must have exactly one winner", "same-last_mutation_id two-client CAS must have exactly one winner",
); );
const loser = race.find((result) => result.status === "rejected"); const loser = race.find((result) => result.status === "rejected");
assert( assert(
@@ -348,7 +349,7 @@ const latest = await owner.readText(ws, updated.entry);
equal( equal(
latest.text, latest.text,
drafts[winnerIndex], drafts[winnerIndex],
"durable content must be the winning actor's draft", "durable content must be the winning client's draft",
); );
// This API boundary proves no automatic retry/overwrite. UI dirty-draft // This API boundary proves no automatic retry/overwrite. UI dirty-draft
// retention is deliberately verified by the separate browser fixture. // retention is deliberately verified by the separate browser fixture.
@@ -0,0 +1,5 @@
# Hosting UIのブラウザ検証
production UIと合成API応答の組合せを確認する。Setupだけはbrowser API境界も`setup-projection.ts`で置換する。Backend認可・実token・暗号署名・native WebAuthnを証明する検証ではない。実sessionやsecretは持ち込まず、request headers/bodyも記録しない。
共有buildは読取り専用とし、画像・診断・一時領域は`target/web-ux`へ分離する。実行入口と境界の理由は各test/capture sourceに置く。自動検査の成功は、画像とreview contextの目視判定を代替しない。
@@ -0,0 +1,113 @@
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const output = `${root}/target/web-ux/hosting`;
async function sha(bytes: Uint8Array<ArrayBuffer>) {
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
value.toString(16).padStart(2, "0")
).join("");
}
const results = [];
for (const theme of ["light", "dark"]) {
for (const prefix of ["before", "after", "navigation-before", "navigation-after"]) {
const run = `${prefix}-final-${theme}`;
const context: ReviewContext = JSON.parse(
await Deno.readTextFile(`${output}/${run}/review-context.json`),
);
assertEquals(context.diagnostics, []);
assert(!context.diagnosticSummary.truncated);
let screenshots = 0, expectedFailureCaptures = 0;
for (const capture of context.captures) {
assertEquals(capture.document.status, 200);
assert(!capture.errorSummary.truncated);
const expectedFailure = ["admin-load-error", "admin-save-error", "account-error"].includes(
capture.route.id,
);
if (expectedFailure) {
expectedFailureCaptures++;
// Non-OK bodies are cancelled by production client; ERR_ABORTED is the
// same deliberately failed response, not an unrelated network failure.
assertEquals(capture.errors.length, 3);
assertEquals(
capture.errors.filter((error) => error.status === 503).length,
1,
);
assertEquals(
capture.errors.filter((error) =>
error.kind === "console" && error.message.includes("503")
).length,
1,
);
assertEquals(
capture.errors.filter((error) =>
error.kind === "request" && error.message === "net::ERR_ABORTED"
).length,
1,
);
} else assertEquals(capture.errors, []);
assert(capture.snapshot !== null);
const snapshot = await Deno.readTextFile(`${output}/${run}/${capture.snapshot.bundlePath}`);
if (["ordinary", "anonymous"].includes(capture.route.id)) {
assert(!snapshot.includes('button "Edit registration policy"'));
assert(!snapshot.includes('link "Hosting settings"'));
}
if (capture.route.id === "ordinary-account-off") {
assert(snapshot.includes('button "Add passkey to alice"'));
assert(!snapshot.includes('link "Hosting settings"'));
}
for (const screenshot of capture.screenshots) {
assertEquals(
await sha(await Deno.readFile(`${output}/${run}/${screenshot.bundlePath}`)),
screenshot.sha256,
);
screenshots++;
}
}
assertEquals(expectedFailureCaptures, prefix === "after" ? 9 : 0);
results.push({
run,
captures: context.captures.length,
screenshots,
expectedFailureCaptures,
allDocuments200: true,
unexpectedErrors: 0,
imageHashesVerified: true,
diagnosticTruncation: false,
});
}
}
async function inventory(directory: string): Promise<{ path: string; sha256: string }[]> {
const files = [];
for await (const entry of Deno.readDir(directory)) {
const path = `${directory}/${entry.name}`;
if (entry.isDirectory) files.push(...await inventory(path));
else if (entry.isFile) {
files.push({
path: path.slice(root.length + 1),
sha256: await sha(await Deno.readFile(path)),
});
}
}
return files.sort((a, b) => a.path.localeCompare(b.path));
}
const baselineAssets = await inventory(`${root}/target/web-ux/baseline-source/web/workspace/build`);
const currentAssets = await inventory(`${root}/web/workspace/build`);
await Deno.writeTextFile(
`${output}/evidence-audit.json`,
JSON.stringify(
{
boundary:
"Actual production static shells; synthetic HTTP only. No Backend authorization/persistence/WebAuthn evidence.",
baselineSource: "git archive 7042ec7d50ca8b03b8034c33e9792f4b6ac9d48d web/workspace",
currentSource:
"Dirty shared worktree at same HEAD; exact served build bytes identified by inventory. Parent owns source/build provenance and final visual verdict.",
results,
baselineAssets,
currentAssets,
},
null,
2,
),
);
console.log(JSON.stringify(results, null, 2));
@@ -0,0 +1,142 @@
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const output = `${root}/target/web-ux/hosting`;
const synthetic = `ui-projection-${crypto.randomUUID()}`;
// 合成値はこの子processのmemory/envに限定する。argv、URL、保存証拠へ出さない。
await Deno.mkdir(`${root}/target/web-ux/tmp`, { recursive: true });
const audit = [];
const includeExisting = Deno.args.includes("--existing");
const runLabel = Deno.args.find((arg) => arg.startsWith("--run-label="))?.slice(12) ??
String(Date.now());
assert(/^[a-z0-9-]+$/.test(runLabel), "run label must be a safe path component");
for (const theme of ["light", "dark"]) {
for (
const kind of includeExisting ? ["setup", "settings"] : ["setup"]
) {
const run = `${kind === "setup" ? "setup" : "after-setup"}-${runLabel}-${theme}`;
const child = await new Deno.Command(Deno.execPath(), {
cwd: root,
env: { TMPDIR: `${root}/target/web-ux/tmp`, WEB_UX_SYNTHETIC_SETUP_INPUT: synthetic },
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-env",
"--allow-net",
"--allow-read",
"--allow-write=target/web-ux",
"--allow-run",
"--allow-sys",
"tools/web-ux/cli.ts",
"capture",
"--scenario",
`tools/web-ux/scenarios/hosting-${kind}-${theme}.json`,
"--output",
"target/web-ux/hosting",
"--run-id",
run,
],
stdout: "inherit",
stderr: "inherit",
}).output();
assertEquals(child.code, 2); // 両scenarioとも意図した拒否・障害表示を含む。
const context: ReviewContext = JSON.parse(
await Deno.readTextFile(`${output}/${run}/review-context.json`),
);
assertEquals(context.diagnostics, []);
assertEquals(context.status, "completed-with-errors");
assertEquals(context.captures.length, kind === "setup" ? 18 : 54);
let images = 0;
for (const capture of context.captures) {
assertEquals(capture.document.status, 200);
assertEquals(capture.errorSummary.truncated, false);
const rejected = capture.route.id === "setup-reject";
const expected = rejected ||
["admin-load-error", "admin-save-error", "account-error"].includes(capture.route.id);
if (expected) {
assert(capture.errors.every((error) =>
error.status === (rejected ? 403 : 503) ||
(error.kind === "console" && error.message.includes(String(rejected ? 403 : 503))) ||
(error.kind === "request" && error.message === "net::ERR_ABORTED")
));
assert(capture.errors.some((error) => error.status === (rejected ? 403 : 503)));
} else assertEquals(capture.errors, []);
for (const image of capture.screenshots) {
assertEquals(
await sha(await Deno.readFile(`${output}/${run}/${image.bundlePath}`)),
image.sha256,
);
images++;
}
}
let scannedTextFiles = 0;
for await (const path of files(`${output}/${run}`)) {
if (/\.(json|md|html|log)$/.test(path)) {
assert(
!(await Deno.readTextFile(path)).includes(synthetic),
"synthetic input must not appear in artifacts",
);
scannedTextFiles++;
}
}
audit.push({
run,
source: context.source,
captures: context.captures.length,
images,
unexpectedErrors: 0,
allDocuments200: true,
imageHashesVerified: true,
syntheticInputAbsent: true,
scannedTextFiles,
});
}
}
async function sha(bytes: Uint8Array<ArrayBuffer>) {
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
value.toString(16).padStart(2, "0")
).join("");
}
async function* files(dir: string): AsyncGenerator<string> {
for await (const entry of Deno.readDir(dir)) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory) yield* files(path);
else if (entry.isFile) yield path;
}
}
async function inventory(dir: string) {
const result = [];
for await (const path of files(dir)) {
result.push({
path: path.slice(root.length + 1),
sha256: await sha(await Deno.readFile(path)),
});
}
return result.sort((a, b) => a.path.localeCompare(b.path));
}
const diff = await new Deno.Command("git", {
cwd: root,
args: ["diff", "--binary", "HEAD", "--", "web/workspace/src", "web/workspace/deno.json"],
stdout: "piped",
stderr: "null",
}).output();
assert(diff.success);
await Deno.writeTextFile(
`${output}/setup-evidence-audit.json`,
JSON.stringify(
{
boundary:
"Production UI modules/styles unchanged. Setup-only bootstrap replaces navigator.credentials create/get; synthetic API responses. Not native WebAuthn, real token proof, authorization or persistence evidence. No request header/body logging.",
audit,
currentTrackedWebDiffSha256: await sha(diff.stdout),
currentSourceFiles: await inventory(`${root}/web/workspace/src`),
currentBuildFiles: await inventory(`${root}/web/workspace/build`),
fixtureFiles: await inventory(import.meta.dirname!),
},
null,
2,
),
);
console.log("Capture diagnostics, image hashes and synthetic-input artifact scan PASS.");
@@ -0,0 +1,111 @@
// UI projection fixture ONLY: actual production assets, synthetic API boundary.
// No real sessions/accounts/DB or Backend authorization claims. Cookie is a
// public fixture selector, not a credential; it is never saved as auth state.
import { extname, join, resolve } from "@std/path";
import { setupProjectionResponse } from "./setup-projection.ts";
const [port, build] = Deno.args;
if (!port || !build) throw new Error("usage: fixture-server.ts <port> <build-root>");
const buildRoot = resolve(build);
const mime: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".woff2": "font/woff2",
".png": "image/png",
};
const policies = new Map<string, boolean>();
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。
let setupMode: string | null = null;
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
const selected = url.searchParams.get("fixture");
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) setupMode = selected;
const mode = selected ?? request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
(url.pathname.startsWith("/api/auth/passkeys/addition/") ? setupMode : null) ?? "admin-on";
if (selected) policies.delete(mode);
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
if (setup) return setup;
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
const ordinary = mode.startsWith("ordinary");
if (url.pathname === "/api/auth/whoami") {
return Response.json({
actor: anonymous ? null : {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
auth_method: "browser_session",
},
});
}
if (url.pathname === "/api/workspaces") return Response.json([]);
if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: !ordinary && !anonymous });
}
const policy = () => ({ self_registration_enabled: policies.get(mode) ?? !mode.includes("off") });
if (url.pathname === "/api/auth/registration-policy") {
if (mode === "account-error") {
return new Response("synthetic availability failure", { status: 503 });
}
if (mode === "account-loading") return new Promise<Response>(() => {});
return Response.json(policy());
}
if (url.pathname === "/api/hosting/settings") {
if (anonymous || ordinary) {
return new Response("synthetic denied", { status: anonymous ? 401 : 403 });
}
if (mode === "admin-load-error") {
return new Response("synthetic settings failure", { status: 503 });
}
if (mode === "admin-loading" || (mode === "admin-saving" && request.method === "PUT")) {
return new Promise<Response>(() => {});
}
if (request.method === "PUT") {
if (mode === "admin-save-error") {
return new Response("synthetic unconfirmed save", { status: 503 });
}
const data = await request.json();
if (typeof data.self_registration_enabled !== "boolean") {
return new Response("invalid", { status: 400 });
}
policies.set(mode, data.self_registration_enabled);
}
return Response.json(policy());
}
if (url.pathname.startsWith("/api/")) {
return new Response("unexpected fixture API", { status: 404 });
}
const path = join(buildRoot, decodeURIComponent(url.pathname));
if (!path.startsWith(buildRoot + "/")) return new Response("invalid path", { status: 400 });
let bytes: Uint8Array;
let extension = extname(path);
try {
bytes = await Deno.readFile(path);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound) && !(error instanceof Deno.errors.IsADirectory)) {
throw error;
}
bytes = await Deno.readFile(join(buildRoot, "index.html"));
extension = ".html";
}
// productionのmarkup/style/moduleを保ち、Setupだけbrowser API境界を代替する。
// このbootstrapはnavigator.credentialsのみを置換し、formや表示を操作しない。
if (extension === ".html" && mode.startsWith("setup-")) {
bytes = new TextEncoder().encode(
new TextDecoder().decode(bytes).replace(
"<head>",
'<head><script src="/__ui_fixture/webauthn.js"></script>',
),
);
}
const headers = new Headers({
"content-type": mime[extension] ?? "application/octet-stream",
"cache-control": "no-store",
});
if (selected) headers.set("set-cookie", `ui-fixture=${selected}; Path=/; SameSite=Strict`);
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
});
@@ -0,0 +1,59 @@
import { resolve } from "@std/path";
import type { Scenario } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
for (const theme of ["light", "dark"]) {
const scenario: Scenario = JSON.parse(
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
);
scenario.id = `hosting-baseline-${theme}`;
scenario.title = "Before: tracked HEAD Account and global navigation; no hosting route existed";
scenario.routes = [
{
id: "account-on",
label: "Anonymous Account before",
path: "/account?fixture=account-on",
goal: "Compare existing Account registration/login before policy gating.",
dataState: "Synthetic anonymous session; unchanged baseline production shell.",
ready: { kind: "selector", selector: 'button:has-text("Register new account"):enabled' },
capturePoints: [{ id: "initial", label: "Before anonymous Account" }],
},
{
id: "admin-account",
label: "Signed-in Account before",
path: "/account?fixture=admin-account",
goal: "Compare Account and global navigation before admin settings link.",
dataState: "Synthetic signed-in session; no hosting route/link before.",
ready: { kind: "selector", selector: 'button:has-text("Add passkey to alice")' },
capturePoints: [{ id: "initial", label: "Before signed-in Account" }],
},
];
scenario.processes![0].cwd = root;
scenario.processes![0].args![scenario.processes![0].args!.length - 1] =
"target/web-ux/baseline-source/web/workspace/build";
for (const route of scenario.routes) route.capturePoints[0].fullPage = true;
await Deno.writeTextFile(
`${root}/target/web-ux/baseline-${theme}.json`,
JSON.stringify(scenario, null, 2),
);
for (const version of ["before", "after"]) {
const nav = structuredClone(scenario);
nav.id = `hosting-navigation-${version}-${theme}`;
nav.title = `${version} mobile global navigation on production shell`;
nav.viewports = nav.viewports.filter((item) => item.width === 390);
for (const route of nav.routes) {
route.capturePoints = [{
id: "navigation",
label: "Mobile global links",
interaction: [{ action: "click", selector: 'button[aria-label="Show sidebar"]' }],
ready: { kind: "selector", selector: 'nav[aria-label="Global pages"]' },
}];
}
if (version === "after") {
nav.processes![0].args![nav.processes![0].args!.length - 1] = "web/workspace/build";
}
await Deno.writeTextFile(
`${root}/target/web-ux/navigation-${version}-${theme}.json`,
JSON.stringify(nav, null, 2),
);
}
}
@@ -0,0 +1,175 @@
import { resolve } from "@std/path";
import type { RouteScenario, Scenario } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const selector = (selector: string) => ({ kind: "selector" as const, selector });
const click = (selector: string) => ({ action: "click" as const, selector });
const press = (selector: string, key: string) => ({ action: "press" as const, selector, key });
const route = (
id: string,
path: string,
ready: string,
dataState: string,
points = [{ id: "initial", label: "Initial state" }],
): RouteScenario => ({
id,
label: id,
path: `${path}?fixture=${id}`,
goal: "Verify production shell UI projection, not server authorization.",
dataState,
ready: selector(ready),
capturePoints: points,
});
const routes: RouteScenario[] = [
...["on", "off"].map((policy) =>
route(
`admin-${policy}`,
"/hosting/settings",
'button:has-text("Edit registration policy")',
`Synthetic admin; policy ${policy}.`,
[
{ id: "initial", label: "Loaded hosting-wide policy" },
{
id: "edit",
label: "Keyboard edit focus",
interaction: [press('button:has-text("Edit registration policy")', "Enter")],
ready: selector('input[type="checkbox"]:focus'),
},
{
id: "saved",
label: "Confirmed save",
interaction: [
press('input[type="checkbox"]', "Space"),
click('button:has-text("Save settings")'),
],
ready: selector('[role="status"]:has-text("Hosting settings saved.")'),
},
] as RouteScenario["capturePoints"],
)
),
route(
"ordinary",
"/hosting/settings",
'p:has-text("Hosting administrator access is required.")',
"Synthetic authenticated ordinary user, no admin controls.",
),
route(
"anonymous",
"/hosting/settings",
'a:has-text("Open Account")',
"Synthetic anonymous visitor; sign-in guidance, no controls.",
),
route(
"admin-loading",
"/hosting/settings",
'[role="status"]:has-text("Loading hosting settings")',
"Settings GET intentionally held pending; shell remains usable.",
),
route(
"admin-load-error",
"/hosting/settings",
'button:has-text("Retry loading settings")',
"Intentional HTTP503; expected console resource error and visible alert.",
),
...["saving", "save-error"].map((state) =>
route(
`admin-${state}`,
"/hosting/settings",
'button:has-text("Edit registration policy")',
state === "saving"
? "PUT held pending; last loaded policy retained, edit controls disabled."
: "Intentional PUT HTTP503; outcome unknown, retry blocked until reload.",
[
{
id: state,
label: state,
interaction: [
click('button:has-text("Edit registration policy")'),
press('input[type="checkbox"]', "Space"),
click('button:has-text("Save settings")'),
],
ready: selector(state === "saving" ? 'button:has-text("Saving…")' : '[role="alert"]'),
},
] as RouteScenario["capturePoints"],
)
),
route(
"account-on",
"/account",
'button:has-text("Register new account"):enabled',
"Anonymous policy on; registration and login available.",
),
route(
"account-off",
"/account",
'p:has-text("New account registration is disabled.")',
"Anonymous policy off; no registration action, login available.",
),
route(
"admin-account",
"/account",
'button:has-text("Add passkey to alice")',
"Authenticated admin; own passkey addition and global settings link.",
),
route(
"ordinary-account-off",
"/account",
'button:has-text("Add passkey to alice")',
"Authenticated ordinary user with policy off; own passkey addition available without admin nav.",
),
route(
"account-error",
"/account",
'button:has-text("Retry registration availability")',
"Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
),
route(
"account-loading",
"/account",
'[role="status"]:has-text("Checking registration availability")',
"Policy request held pending; login remains available after session loads.",
),
];
for (const account of routes.filter((item) => item.path.startsWith("/account"))) {
account.capturePoints[0].fullPage = true;
}
for (const theme of ["light", "dark"] as const) {
const scenario: Scenario = {
schemaVersion: 1,
id: `hosting-settings-${theme}`,
title: "Hosting settings: synthetic UI conditions on production shell",
baseUrl: "http://127.0.0.1:15378",
colorScheme: theme,
reducedMotion: "reduce",
personas: [{
id: "fixture",
label: "Synthetic API projection; no real authentication",
auth: { kind: "anonymous" },
}],
viewports: [{ label: "desktop", width: 1440, height: 900 }, {
label: "narrow",
width: 768,
height: 900,
}, { label: "mobile", width: 390, height: 844 }],
routes,
processes: [{
id: "hosting-ui-fixture",
command: "deno",
cwd: "../../..",
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15378",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15378/health",
}],
};
await Deno.writeTextFile(
`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`,
JSON.stringify(scenario, null, 2) + "\n",
);
}
@@ -0,0 +1,88 @@
import { resolve } from "@std/path";
import type { Interaction, Scenario } from "../../../../tools/web-ux/src/types.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const submit: Interaction[] = [{
action: "fill",
selector: "#host-setup-token",
value: "${WEB_UX_SYNTHETIC_SETUP_INPUT}",
}, { action: "click", selector: 'button:has-text("Add administrator passkey")' }];
for (const theme of ["light", "dark"]) {
const scenario: Scenario = JSON.parse(
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
);
scenario.id = `hosting-setup-${theme}`;
scenario.title =
"Hosting setup production UI: synthetic HTTP and navigator.credentials boundary only";
scenario.redact = { text: ["${WEB_UX_SYNTHETIC_SETUP_INPUT}"] };
scenario.routes = [{
id: "setup-flow",
label: "Setup enrollment and login UI",
path: "/hosting/setup?fixture=setup-flow",
goal:
"Separate host-token enrollment from explicit passkey login and verified settings access.",
dataState:
"Synthetic actor, API and non-cryptographic navigator.credentials; no real token/authentication. Test-only bootstrap changes browser API boundary, not production UI modules/styles.",
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
capturePoints: [
{ id: "initial", label: "Empty password field; enrollment disabled", fullPage: true },
{
id: "host-help",
label: "Trusted-host instructions expanded",
interaction: [{ action: "press", selector: "summary", key: "Enter" }],
ready: { kind: "selector", selector: "details[open]" },
fullPage: true,
},
{
id: "masked-entry",
label: "Synthetic input password-masked; action enabled",
interaction: [{ action: "press", selector: "summary", key: "Enter" }, submit[0]],
ready: {
kind: "selector",
selector: 'button:has-text("Add administrator passkey"):enabled',
},
fullPage: true,
},
{
id: "enrolled",
label: "Enrollment is not login; explicit login handoff",
interaction: [submit[1]],
ready: {
kind: "selector",
selector: 'button:has-text("Sign in with new passkey"):enabled',
},
fullPage: true,
},
{
id: "verified",
label: "Matching actor/session/access projected verified; settings link",
interaction: [{
action: "press",
selector: 'button:has-text("Sign in with new passkey")',
key: "Enter",
}],
ready: { kind: "selector", selector: 'a:has-text("Open hosting settings")' },
fullPage: true,
},
],
}, {
id: "setup-reject",
label: "Rejected setup enrollment",
path: "/hosting/setup?fixture=setup-reject",
goal:
"Show safe unconfirmed-enrollment guidance with empty cleared token and no settings action.",
dataState:
"Intentional addition/options HTTP403. Expected console/resource diagnostics; no real authorization exercised.",
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
capturePoints: [{
id: "rejected",
label: "Rejected attempt; token cleared",
interaction: submit,
ready: { kind: "selector", selector: '[role="alert"]' },
fullPage: true,
}],
}];
await Deno.writeTextFile(
`${root}/tools/web-ux/scenarios/hosting-setup-${theme}.json`,
JSON.stringify(scenario, null, 2) + "\n",
);
}
@@ -0,0 +1,250 @@
/// <reference lib="dom" />
// Production-shell browser behavior with synthetic HTTP projections only.
// Run against prebuilt assets; never builds or uses a real session/backend.
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const evidence = `${root}/target/web-ux/hosting/interactions`;
async function noOverflow(page: Page) {
assertEquals(
await page.evaluate(() => ({
page: document.documentElement.scrollWidth > innerWidth,
main: [...document.querySelectorAll("main")].some((main) =>
main.scrollWidth > main.clientWidth
),
})),
{ page: false, main: false },
);
}
Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown save outcome on production shell", async () => {
const processes = await startOwnedProcesses(
[{
id: "hosting-interactions",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-net",
"--allow-read",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15379",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15379/health",
readyTimeoutMs: 10000,
}],
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
evidence,
[],
);
const browser = await chromium.launch();
const results: unknown[] = [];
try {
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
const consoleErrors: string[] = [];
const httpErrors: string[] = [];
const failedRequests: { url: string; error: string }[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
}
});
page.on(
"requestfailed",
(request) =>
failedRequests.push({
url: request.url(),
error: request.failure()?.errorText ?? "unknown",
}),
);
const settingsRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/hosting/settings") {
settingsRequests.push(request.method());
}
});
await page.goto("http://127.0.0.1:15379/account?fixture=admin-on");
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
if (
await page.getByRole("button", { name: "Show sidebar", exact: true }).isVisible()
) await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
const nav = page.getByRole("navigation", { name: "Global pages" });
await nav.getByRole("link", { name: "Hosting settings", exact: true }).waitFor();
await nav.getByRole("link", { name: "Account", exact: true }).focus();
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Device Login");
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Hosting settings");
assertEquals(
await page.locator(":focus").evaluate((element) =>
getComputedStyle(element).outlineStyle !== "none"
),
true,
);
await page.keyboard.press("Enter");
const edit = page.getByRole("button", { name: "Edit registration policy" });
await edit.waitFor();
assertEquals(
await page.locator('a[href="/hosting/settings"]').getAttribute("aria-current"),
"page",
);
if (
await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() &&
width < 768
) await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
await noOverflow(page);
await edit.focus();
await page.keyboard.press("Enter");
const checkbox = page.getByRole("checkbox", { name: "Allow new account registration" });
assertEquals(
await checkbox.evaluate((element) => element === document.activeElement),
true,
);
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.getByRole("button", { name: "Cancel", exact: true }).click();
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
await page.keyboard.press("Enter");
assertEquals(await checkbox.isChecked(), true);
await page.keyboard.press("Space");
await page.keyboard.press("Tab");
assertEquals(await page.locator(":focus").innerText(), "Save settings");
await page.keyboard.press("Enter");
await page.getByRole("status").filter({ hasText: "Hosting settings saved." }).waitFor();
assertEquals(await edit.evaluate((element) => element === document.activeElement), true);
await page.getByText("Disabled", { exact: true }).waitFor();
assertEquals(settingsRequests.filter((method) => method === "PUT").length, 1);
await noOverflow(page);
for (const mode of ["ordinary", "anonymous"]) {
settingsRequests.length = 0;
await page.goto(`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`);
await page.getByText(
mode === "ordinary"
? "Hosting administrator access is required."
: "Sign in with a hosting administrator account to view these settings.",
{ exact: true },
).waitFor();
assertEquals(await edit.count(), 0);
assertEquals(await checkbox.count(), 0);
assertEquals(
await nav.getByRole("link", { name: "Hosting settings", exact: true }).count(),
0,
);
assertEquals(settingsRequests, []);
await noOverflow(page);
}
await page.goto("http://127.0.0.1:15379/account?fixture=account-off");
await page.getByText(
"New account registration is disabled. Existing users can still log in.",
).waitFor();
assertEquals(await page.getByRole("button", { name: "Register new account" }).count(), 0);
assertEquals(
await page.getByRole("button", { name: "Log in with passkey" }).isEnabled(),
true,
);
await noOverflow(page);
await page.goto("http://127.0.0.1:15379/account?fixture=ordinary-account-off");
await page.getByRole("button", { name: "Add passkey to alice" }).waitFor();
assertEquals(
await page.getByRole("button", { name: "Add passkey to alice" }).isEnabled(),
true,
);
await page.goto("http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error");
await edit.click();
await checkbox.press("Space");
await page.getByRole("button", { name: "Save settings", exact: true }).click();
await page.getByRole("alert").waitFor();
assertEquals(await checkbox.isChecked(), false);
assertEquals(await checkbox.isDisabled(), true);
assertEquals(
await page.getByRole("button", { name: "Save settings", exact: true }).isDisabled(),
true,
);
await noOverflow(page);
await page.getByRole("button", { name: "Reload settings", exact: true }).click();
await edit.waitFor();
assertEquals(await page.getByRole("alert").count(), 0);
assertEquals(await edit.isEnabled(), true);
for (
const [mode, path, ready] of [
[
"admin-load-error",
"/hosting/settings",
'button:has-text("Retry loading settings")',
],
["account-error", "/account", 'button:has-text("Retry registration availability")'],
[
"admin-loading",
"/hosting/settings",
'[role="status"]:has-text("Loading hosting settings")',
],
[
"account-loading",
"/account",
'[role="status"]:has-text("Checking registration availability")',
],
]
) {
await page.goto(`http://127.0.0.1:15379${path}?fixture=${mode}`);
await page.locator(ready).waitFor();
await noOverflow(page);
}
// Navigation may also cancel the shell's concurrent identity/access
// reads. Only an explicit browser cancellation of these reads is expected.
assert(
failedRequests.every(({ url, error }) =>
error === "net::ERR_ABORTED" && [
"/api/hosting/settings",
"/api/auth/registration-policy",
"/api/auth/whoami",
"/api/hosting/access",
].some((path) => url.endsWith(path))
),
JSON.stringify(failedRequests),
);
assertEquals(httpErrors, [
"503 /api/hosting/settings",
"503 /api/hosting/settings",
"503 /api/auth/registration-policy",
]);
assertEquals(consoleErrors.length, 3);
assert(consoleErrors.every((message) => message.includes("503")));
assertEquals(errors, []);
results.push({
theme,
width,
keyboard: "pass",
overflow: "none",
deniedControls: "absent",
save: "confirmed",
unconfirmedSave: "blocked until reload",
realAuthorizationTested: false,
});
} finally {
await context.close();
}
}
}
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
} finally {
await browser.close();
await stopOwnedProcesses(processes);
}
});
@@ -0,0 +1,64 @@
// Setupの表示条件だけを代替する。Bearerやrequest bodyは読まず、記録もしない。
// WebAuthnは固定の非暗号credentialを返すため、本物の認証・署名検証ではない。
export const setupWebAuthnProjection = `
(() => {
const bytes = () => new Uint8Array([117, 105]).buffer;
const core = () => ({ id: 'ui-projection-only', rawId: bytes(), type: 'public-key', getClientExtensionResults: () => ({}) });
Object.defineProperty(navigator.credentials, 'create', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), attestationObject: bytes(), getTransports: () => ['internal'] } }) });
Object.defineProperty(navigator.credentials, 'get', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), authenticatorData: bytes(), signature: bytes(), userHandle: null } }) });
})();
`;
const user = {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
};
const stages = new Map<string, "enrolled" | "login">();
export function setupProjectionResponse(
path: string,
mode: string,
reset: boolean,
): Response | null {
if (!mode.startsWith("setup-")) return null;
if (reset) stages.delete(mode);
if (path === "/__ui_fixture/webauthn.js") {
return new Response(setupWebAuthnProjection, {
headers: { "content-type": "text/javascript" },
});
}
if (path === "/api/auth/whoami") {
return Response.json({
actor: stages.get(mode) === "login" ? { ...user, auth_method: "browser_session" } : null,
});
}
if (path === "/api/hosting/access") {
return Response.json({ is_admin: stages.get(mode) === "login" });
}
if (path === "/api/auth/passkeys/addition/options") {
if (mode === "setup-reject") {
return new Response("synthetic enrollment rejection", { status: 403 });
}
return Response.json({
challenge_id: "fixture-add",
public_key: {
challenge: "dWk",
rp: { name: "UI projection" },
user: { id: "dWk", name: "alice", displayName: "Alice" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
});
}
if (path === "/api/auth/passkeys/addition/complete") {
stages.set(mode, "enrolled");
return Response.json({ user });
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json({ challenge_id: "fixture-login", public_key: { challenge: "dWk" } });
}
if (path === "/api/auth/passkeys/login/complete") {
stages.set(mode, "login");
return Response.json({ user });
}
return null;
}
@@ -0,0 +1,184 @@
/// <reference lib="dom" />
// UI projectionのみ。実APIのBearer認可・WebAuthn・cookie発行は検証しない。
// requestを観測する場合もmethod/pathだけとし、headers/bodyは取得・保存しない。
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const evidence = `${root}/target/web-ux/hosting/setup-interactions`;
async function noOverflow(page: Page) {
assertEquals(
await page.evaluate(() => ({
page: document.documentElement.scrollWidth > innerWidth,
main: [...document.querySelectorAll("main")].some((main) =>
main.scrollWidth > main.clientWidth
),
})),
{ page: false, main: false },
);
}
Deno.test("setup UI separates masked host-token enrollment, rejected attempts, explicit login and verified settings navigation", async () => {
const processes = await startOwnedProcesses(
[{
id: "setup-interactions",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-net",
"--allow-read",
"web/workspace/test/hosting-browser/fixture-server.ts",
"15380",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15380/health",
readyTimeoutMs: 10000,
}],
`${root}/tools/web-ux/scenarios/hosting-setup-light.json`,
evidence,
[],
);
const browser = await chromium.launch();
const results = [];
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const pageErrors: string[] = [],
consoleErrors: string[] = [],
httpErrors: string[] = [],
requestFailures: string[] = [];
const methodsAndPaths: string[] = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("response", (response) => {
if (response.status() >= 400) {
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
}
});
page.on(
"requestfailed",
(request) => requestFailures.push(new URL(request.url()).pathname),
);
page.on(
"request",
(request) =>
methodsAndPaths.push(`${request.method()} ${new URL(request.url()).pathname}`),
);
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-flow");
const input = page.locator("#host-setup-token");
await input.waitFor();
const enroll = page.getByRole("button", {
name: "Add administrator passkey",
exact: true,
});
assertEquals(await input.getAttribute("type"), "password");
assertEquals(await enroll.isDisabled(), true);
assert(
!methodsAndPaths.some((entry) => entry.startsWith("POST ")),
);
assertEquals(
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
0,
);
await noOverflow(page);
await page.locator("summary").focus();
await page.keyboard.press("Enter");
await page.locator("details[open]").waitFor();
await noOverflow(page);
await page.keyboard.press("Enter");
const synthetic = `ui-projection-${crypto.randomUUID()}`;
await input.fill(synthetic);
assertEquals(await enroll.isEnabled(), true);
await input.focus();
await page.keyboard.press("Tab");
assertEquals(
await enroll.evaluate((element) => element === document.activeElement),
true,
);
assertEquals(
await enroll.evaluate((element) => getComputedStyle(element).outlineStyle !== "none"),
true,
);
await page.keyboard.press("Enter");
const login = page.getByRole("button", { name: "Sign in with new passkey", exact: true });
await login.waitFor();
await page.getByRole("status").filter({ hasText: "Enrollment is not a passkey login." })
.waitFor();
assertEquals(await input.count(), 0);
assertEquals(
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
0,
);
assertEquals(
methodsAndPaths.filter((entry) => entry.startsWith("POST ")),
[
"POST /api/auth/passkeys/addition/options",
"POST /api/auth/passkeys/addition/complete",
],
);
await noOverflow(page);
await login.focus();
await page.keyboard.press("Enter");
const settings = page.getByRole("link", { name: "Open hosting settings", exact: true });
await settings.waitFor();
await page.getByRole("status").filter({
hasText: "Passkey login and hosting administrator access verified.",
}).waitFor();
await noOverflow(page);
await settings.focus();
await page.keyboard.press("Enter");
await page.getByRole("button", { name: "Edit registration policy", exact: true })
.waitFor();
await noOverflow(page);
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-reject");
await input.fill(synthetic);
await enroll.click();
const error = page.getByRole("alert");
await error.waitFor();
assert(!(await input.inputValue()));
assertEquals(await enroll.isDisabled(), true);
assert(!(await error.innerText()).includes(synthetic));
assertEquals(await settings.count(), 0);
await noOverflow(page);
assertEquals(pageErrors, []);
assertEquals(httpErrors, ["403 /api/auth/passkeys/addition/options"]);
assertEquals(consoleErrors.length, 1);
assert(consoleErrors[0].includes("403"));
assert(requestFailures.every((path) => path === "/api/auth/passkeys/addition/options"));
results.push({
width,
theme: colorScheme,
emptyDisabled: true,
passwordMasked: true,
keyboard: "pass",
tokenClearedAfterRejectedAttempt: true,
enrollmentNotLogin: true,
explicitLoginBeforeSettings: true,
overflow: "none",
unexpectedErrors: 0,
requestHeadersOrBodiesRecorded: false,
realAuthenticationTested: false,
});
} finally {
await context.close();
}
}
}
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
} finally {
await browser.close();
await stopOwnedProcesses(processes);
}
});