clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

feat: stream server-rendered profiles

Dami committed 36e521e
Add Fixi, Paxi, and SSEXi as the browser update path.\nSplit browser behaviour into strict TypeScript modules.\nPrepare version 0.6.0 and remove legacy identity migration support.
commit
36e521e55a7b0913414f62e1d48b7d355b37757b
parent
26bc188bbc7c0375d2306cd3c595331e4e48d0d1
author
Dami <dami.etoile@gmail.com>
36 files changed, +3336 -1170
M.github/release-intro.md41+-
M.github/workflows/release.yml16+-
M.gitignore1+
MCargo.lock14+-
MCargo.toml2+-
MDockerfile17+-
MREADME.md27+-
Abiome.json15+
Mcrates/devsite-cli/src/main.rs17+-
Mcrates/devsite-daemon/src/config.rs27+-
Mcrates/devsite-server/Cargo.toml2+
Mcrates/devsite-server/src/api.rs533+++++++++++-
Mcrates/devsite-server/src/main.rs3+
Mcrates/devsite-server/src/oidc.rs2+-
Mdeploy/README.md4+-
Mdocs/auth-providers.md6+-
Mdocs/keys-and-identities.md10+-
Mdocs/profile-template.md21+-
Apackage-lock.json1412++++++++++++++++++++++++++++++
Apackage.json25+
Aplaywright.config.ts12+
Mplugins/devsite-access/.codex-plugin/plugin.json2+-
Atsconfig.json14+
Dweb/app.js1032----------------------
Aweb/app.spec.ts136+++
Aweb/app.ts75++
Mweb/concepts.html12+-
Aweb/dashboard.ts453++++++++++
Aweb/home.ts56+
Mweb/index.html102++-
Aweb/install.ts46+
Aweb/profile.ts117++
Aweb/secrets.ts38+
Aweb/services.ts105++
Aweb/shared.ts45+
Aweb/theme.ts66+
M .github/release-intro.md +16 -25
@@ -1,38 +1,29 @@
1-dev.site v0.5.1 moves production Iroh traffic to the managed shared relay tier.1+dev.site v0.6.0 gives static profile pages a small server-rendered update path.22 3-## highlights3+## Highlights44 5-- The CLI and daemon use four project relays through an Iroh custom relay map.6-- The control plane keeps the Iroh Services API key in its environment.7-- The control plane gives each client and daemon an endpoint-scoped relay token.8-- Public Iroh relays remain available only to transport integration tests.5+- The control plane renders profile HTML for each viewer.6+- Fixi starts profile requests and handles incoming share actions.7+- SSEXi receives the initial profile and later profile changes.8+- Paxi morphs each profile fragment while the page keeps local folder state.9+- Strict TypeScript and Biome now check the browser code.10+- The control plane filters profile updates by affected account.911 10-## deployment12+## Identity file change1113 12-Set `IROH_SERVICES_API_SECRET` on the control plane before you release the new CLI.13-The server must return scoped relay tokens to version 0.5.1 clients.14+Version 0.6.0 removes the legacy identity file migration promised in version 0.5.1.1415 15-The relay token grants relay use only. It does not grant access to a dev.site service.16-The existing signed capability checks still control each service connection.16+The CLI and daemon use `devsite-endpoint.key` and `devsite-endpoint.pub`. They do not read17+or move `identity.key` or `identity.pub`.1718 18-## identity file migration19+If these legacy files remain, start version 0.5.1 once before you install version 0.6.0.1920 20-Version 0.5.1 continues to move legacy `identity.key` and `identity.pub` files to21-`devsite-endpoint.key` and `devsite-endpoint.pub`.22-23-The migration checks only the dev.site config directory. It moves a legacy file only when24-the destination file does not exist.25-26-`devsite doctor` warns that version 0.6.0 removes legacy file support.27-28-Before you upgrade to version 0.6.0, run `devsite login` or `devsite daemon run` with29-version 0.5.1 or an earlier version.30-31-## upgrade notes21+## Upgrade notes3222 3323 - Database migrations are not required.24+- Existing profiles and themes continue to work.3425 - Existing `devsite-endpoint.key` and `devsite-endpoint.pub` files continue to work.35-- Version 0.6.0 will not read or move `identity.key` or `identity.pub`.26+- The release workflow now checks Rust, TypeScript, Biome, and browser tests.3627 3728 Homebrew users can upgrade with:3829
M .github/workflows/release.yml +15 -1
M .gitignore +1 -0
M Cargo.lock +8 -6
@@ -922,7 +922,7 @@ dependencies = [
922922 923923 [[package]]924924 name = "devsite-cli"925-version = "0.5.1"925+version = "0.6.0"926926 dependencies = [927927 "anyhow",928928 "clap",
@@ -943,7 +943,7 @@ dependencies = [
943943 944944 [[package]]945945 name = "devsite-client"946-version = "0.5.1"946+version = "0.6.0"947947 dependencies = [948948 "anyhow",949949 "devsite-iroh",
@@ -956,7 +956,7 @@ dependencies = [
956956 957957 [[package]]958958 name = "devsite-daemon"959-version = "0.5.1"959+version = "0.6.0"960960 dependencies = [961961 "anyhow",962962 "data-encoding",
@@ -976,7 +976,7 @@ dependencies = [
976976 977977 [[package]]978978 name = "devsite-iroh"979-version = "0.5.1"979+version = "0.6.0"980980 dependencies = [981981 "anyhow",982982 "data-encoding",
@@ -986,7 +986,7 @@ dependencies = [
986986 987987 [[package]]988988 name = "devsite-proto"989-version = "0.5.1"989+version = "0.6.0"990990 dependencies = [991991 "data-encoding",992992 "ed25519-dalek 2.2.0",
@@ -998,7 +998,7 @@ dependencies = [
998998 999999 [[package]]10001000 name = "devsite-server"1001-version = "0.5.1"1001+version = "0.6.0"10021002 dependencies = [10031003 "anyhow",10041004 "axum",
@@ -1006,6 +1006,7 @@ dependencies = [
10061006 "devsite-iroh",10071007 "devsite-proto",10081008 "ed25519-dalek 2.2.0",1009+ "futures-util",10091010 "jsonwebtoken",10101011 "rand 0.8.7",10111012 "reqwest 0.12.28",
@@ -1014,6 +1015,7 @@ dependencies = [
10141015 "serde_json",10151016 "sha2 0.10.9",10161017 "tokio",1018+ "tokio-stream",10171019 "tower-http",10181020 "tracing",10191021 "tracing-subscriber",
M Cargo.toml +1 -1
M Dockerfile +10 -7
M README.md +18 -9
@@ -212,13 +212,12 @@ Native Linux packages may install `packaging/systemd/devsite.service` as a user
212212 systemctl --user enable --now devsite.service213213 ```214214 215-Before you upgrade to version 0.6.0, run `devsite login` or `devsite daemon run` with an216-earlier version. That version moves legacy `identity.key` and `identity.pub` files only217-from the dev.site config directory. Version 0.6.0 uses `devsite-endpoint.key` and218-`devsite-endpoint.pub`. It does not read or move the legacy files. See219-[Keys and endpoint identities](docs/keys-and-identities.md) for the migration rules. The220-daemon registers that public endpoint id with the control plane at startup, publishes its221-address through Iroh, and reloads service targets and authorizations every two seconds.215+Version 0.6.0 uses `devsite-endpoint.key` and `devsite-endpoint.pub`. It does not read or216+move legacy `identity.key` and `identity.pub` files. Start version 0.5.1 once before the217+upgrade if these files remain. See [Keys and endpoint identities](docs/keys-and-identities.md)218+for the migration rules. The daemon registers that public endpoint id with the control219+plane at startup. It publishes its address through Iroh and reloads authorizations every220+two seconds.222221 Adding or removing a service does not require a restart.223222 224223 `devsite daemon status` reports whether this config directory has a live daemon. The same
@@ -240,14 +239,21 @@ remains a bounded list of approved declarations rather than arbitrary CSS: Pico
240239 control the theme, while `--devsite-folders`, `--devsite-open-folders`, and241240 `--devsite-folder-order` control the initial semantic folder layout.242241 242+Profile routes load a static shell and one viewer-specific HTML stream. Fixi starts the243+request. SSEXi reads each event, and Paxi morphs each profile fragment into the page. The244+control plane sends a new fragment after profile data changes. The browser keeps the245+reader's folder state during each morph.246+243247 ## Local development244248 245-Prerequisite: stable Rust.249+Prerequisites: stable Rust and Node.js 24.246250 247251 Set `IROH_SERVICES_API_SECRET` before you start the control plane.248252 The control plane uses it to make endpoint-scoped relay tokens.249253 250254 ```bash255+npm ci256+npm run build:web251257 export DEVSITE_PUBLIC_ORIGIN=http://127.0.0.1:4000252258 cargo run -p devsite-server253259 ```
@@ -269,6 +275,9 @@ cargo run -p devsite-server -- issue-session alice
269275 Build and test everything:270276 271277 ```bash278+npm run check:web279+npx playwright install chromium280+npm run test:web272281 cargo test --workspace273282 cargo build --release -p devsite-cli274283 ```
@@ -302,7 +311,7 @@ the configured issuer, client, and subjects.
302311 | `crates/devsite-daemon` | Capability verification and fixed-target TCP forwarding. |303312 | `crates/devsite-cli` | Login, links, service hosting/connect, themes, and daemon lifecycle. |304313 | `crates/devsite-server` | Axum/SQLite control plane and capability issuance. |305-| `web/` | Semantic HTML, plain JavaScript, Pico CSS, and vendored fonts. |314+| `web/` | Semantic HTML, strict TypeScript, Fixi, Paxi, SSEXi, Pico CSS, and vendored fonts. |306315 307316 ## Security boundaries308317
A biome.json +15 -0
M crates/devsite-cli/src/main.rs +9 -8
M crates/devsite-daemon/src/config.rs +4 -23
@@ -166,7 +166,6 @@ impl Paths {
166166 /// This key *is* the daemon's identity — every capability ever issued names it as the167167 /// audience — so it must survive restarts and must not be world-readable.168168 pub fn load_or_create_identity(&self) -> Result<SecretKey> {169- self.migrate_legacy_identity_files()?;170169 let path = self.identity();171170 let key = if path.exists() {172171 let raw =
@@ -191,24 +190,6 @@ impl Paths {
191190 }192191 Ok(key)193192 }194-195- /// Move legacy endpoint identity files to purpose-specific names.196- /// Remove this migration helper in version 0.6.0.197- /// The migration reads files only from this dev.site config directory.198- /// Existing destination files take precedence.199- fn migrate_legacy_identity_files(&self) -> Result<()> {200- for (legacy, current) in [201- (self.root.join("identity.key"), self.identity()),202- (self.root.join("identity.pub"), self.identity_public()),203- ] {204- if legacy.exists() && !current.exists() {205- std::fs::rename(&legacy, &current).with_context(|| {206- format!("moving {} to {}", legacy.display(), current.display())207- })?;208- }209- }210- Ok(())211- }212193 }213194 214195 #[cfg(unix)]
@@ -308,7 +289,7 @@ mod tests {
308289 }309290 310291 #[test]311- fn legacy_identity_files_migrate_before_version_0_6_0_removes_compatibility() {292+ fn legacy_identity_files_are_not_read_or_moved() {312293 let paths = test_paths();313294 std::fs::create_dir_all(&paths.root).unwrap();314295 let original = SecretKey::generate();
@@ -321,11 +302,11 @@ mod tests {
321302 322303 let loaded = paths.load_or_create_identity().unwrap();323304 324- assert_eq!(loaded.to_bytes(), original.to_bytes());305+ assert_ne!(loaded.to_bytes(), original.to_bytes());325306 assert!(paths.identity().exists());326307 assert!(paths.identity_public().exists());327- assert!(!paths.root.join("identity.key").exists());328- assert!(!paths.root.join("identity.pub").exists());308+ assert!(paths.root.join("identity.key").exists());309+ assert!(paths.root.join("identity.pub").exists());329310 std::fs::remove_dir_all(&paths.root).unwrap();330311 }331312 }
M crates/devsite-server/Cargo.toml +2 -0
M crates/devsite-server/src/api.rs +506 -27
@@ -1,20 +1,27 @@
11 //! HTTP surface of the control plane.22 33 use std::collections::{HashMap, HashSet};4+use std::convert::Infallible;5+use std::fmt::Write as _;46 use std::hash::Hash;57 use std::str::FromStr;8+use std::sync::atomic::{AtomicU64, Ordering};69 use std::sync::{Arc, Mutex};7-use std::time::{SystemTime, UNIX_EPOCH};10+use std::time::{Duration, SystemTime, UNIX_EPOCH};811 912 use axum::extract::{Path, Query, State};1013 use axum::http::{header, HeaderMap, StatusCode};11-use axum::response::{IntoResponse, Response};14+use axum::response::sse::{Event, KeepAlive, Sse};15+use axum::response::{Html, IntoResponse, Response};1216 use axum::routing::{delete, get, post, put};1317 use axum::{Json, Router};1418 use devsite_proto::access_plan::ServiceGrantPlanClaims;1519 use devsite_proto::capability::KeyBytes;1620 use devsite_proto::{AccountId, ResourceId};21+use futures_util::stream::{self, StreamExt};1722 use serde::{Deserialize, Serialize};23+use tokio::sync::broadcast;24+use tokio_stream::wrappers::BroadcastStream;1825 1926 use crate::auth::{self, ExternalIdentity};2027 use crate::db::{Db, MachineAuthentication, ResourceKind, ShareStatus};
@@ -29,6 +36,28 @@ pub struct AppState {
2936 pub identity_namespace: String,3037 pub public_origin: String,3138 pub relay_issuer: devsite_iroh::RelayIssuer,39+ pub profile_changes: broadcast::Sender<ProfileChange>,40+ pub profile_revision: AtomicU64,41+}42+43+#[derive(Clone)]44+pub struct ProfileChange {45+ revision: u64,46+ accounts: Arc<HashSet<AccountId>>,47+}48+49+impl AppState {50+ fn notify_profiles(&self, accounts: impl IntoIterator<Item = AccountId>) {51+ let accounts = accounts.into_iter().collect::<HashSet<_>>();52+ if accounts.is_empty() {53+ return;54+ }55+ let revision = self.profile_revision.fetch_add(1, Ordering::Relaxed) + 1;56+ let _ = self.profile_changes.send(ProfileChange {57+ revision,58+ accounts: Arc::new(accounts),59+ });60+ }3261 }3362 3463 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -439,6 +468,14 @@ pub fn router(state: Shared) -> Router {
439468 "/api/share-invitations/{id}",440469 delete(decline_share_invitation),441470 )471+ .route(472+ "/ui/share-invitations/{id}/accept",473+ post(accept_share_invitation_ui),474+ )475+ .route(476+ "/ui/share-invitations/{id}",477+ delete(decline_share_invitation_ui),478+ )442479 .route("/api/daemon", get(daemon_info).put(register_daemon))443480 .route("/api/daemon/authorizations", get(daemon_authorizations))444481 .route("/api/machine/enroll", put(enroll_machine))
@@ -457,6 +494,7 @@ pub fn router(state: Shared) -> Router {
457494 .route("/api/theme", get(read_theme).put(write_theme))458495 .route("/api/theme/properties", get(theme_properties))459496 .route("/api/profile/{handle}", get(profile))497+ .route("/ui/profile/{handle}/stream", get(profile_stream))460498 .route("/api/services/{id}/ticket", post(create_connection_ticket))461499 .route("/api/tickets/redeem", post(redeem_connection_ticket))462500 .route(
@@ -596,6 +634,8 @@ async fn claim_handle(
596634 }597635 }598636 db.set_handle(id, &handle).map_err(ApiError::internal)?;637+ drop(db);638+ state.notify_profiles([id]);599639 Ok(Json(serde_json::json!({ "handle": handle })))600640 }601641
@@ -626,7 +666,10 @@ async fn create_resource(
626666 // not Carol plus whoever was named the last time it ran.627667 db.set_shares(resource, &prepared.viewers)628668 .map_err(ApiError::internal)?;669+ let viewers = db.shared_with(resource).map_err(ApiError::internal)?;629670 prepared.plan.target.resource_id = Some(resource.to_string());671+ drop(db);672+ state.notify_profiles(std::iter::once(owner).chain(viewers));630673 631674 Ok(Json(CreateResourceResponse {632675 resource_id: resource.to_string(),
@@ -999,12 +1042,15 @@ async fn delete_resource(
9991042 let resource = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;10001043 10011044 let db = state.db.lock().unwrap();1045+ let viewers = db.shared_with(resource).map_err(ApiError::internal)?;10021046 if !db10031047 .delete_resource(owner, resource)10041048 .map_err(ApiError::internal)?10051049 {10061050 return Err(ApiError::not_found());10071051 }1052+ drop(db);1053+ state.notify_profiles(std::iter::once(owner).chain(viewers));10081054 Ok(StatusCode::NO_CONTENT)10091055 }10101056
@@ -1067,9 +1113,13 @@ async fn set_resource_shares(
10671113 "only a shared resource can name viewers",10681114 ));10691115 }1116+ let old_viewers = db.shared_with(resource_id).map_err(ApiError::internal)?;10701117 let viewers = resolve_share_accounts(&db, owner, &body.share_with)?;10711118 db.set_shares(resource_id, &viewers)10721119 .map_err(ApiError::internal)?;1120+ let new_viewers = db.shared_with(resource_id).map_err(ApiError::internal)?;1121+ drop(db);1122+ state.notify_profiles(std::iter::once(owner).chain(old_viewers).chain(new_viewers));10731123 Ok(StatusCode::NO_CONTENT)10741124 }10751125
@@ -1097,16 +1147,7 @@ async fn accept_share_invitation(
10971147 headers: HeaderMap,10981148 Path(id): Path<String>,10991149 ) -> ApiResult<impl IntoResponse> {1100- let viewer = require_browser_account(&state, &headers)?;1101- check_rate(&state, viewer, RateClass::Mutation)?;1102- let resource = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;1103- let mut db = state.db.lock().unwrap();1104- if !db1105- .accept_share(viewer, resource)1106- .map_err(ApiError::internal)?1107- {1108- return Err(ApiError::not_found());1109- }1150+ update_share_invitation(&state, &headers, &id, true)?;11101151 Ok(StatusCode::NO_CONTENT)11111152 }11121153
@@ -1115,17 +1156,119 @@ async fn decline_share_invitation(
11151156 headers: HeaderMap,11161157 Path(id): Path<String>,11171158 ) -> ApiResult<impl IntoResponse> {1118- let viewer = require_browser_account(&state, &headers)?;1119- check_rate(&state, viewer, RateClass::Mutation)?;1120- let resource = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;1159+ update_share_invitation(&state, &headers, &id, false)?;1160+ Ok(StatusCode::NO_CONTENT)1161+}1162+1163+async fn accept_share_invitation_ui(1164+ State(state): State<Shared>,1165+ headers: HeaderMap,1166+ Path(id): Path<String>,1167+) -> ApiResult<Html<String>> {1168+ let viewer = update_share_invitation(&state, &headers, &id, true)?;1169+ incoming_share_controls(&state, viewer)1170+}1171+1172+async fn decline_share_invitation_ui(1173+ State(state): State<Shared>,1174+ headers: HeaderMap,1175+ Path(id): Path<String>,1176+) -> ApiResult<Html<String>> {1177+ let viewer = update_share_invitation(&state, &headers, &id, false)?;1178+ incoming_share_controls(&state, viewer)1179+}1180+1181+fn update_share_invitation(1182+ state: &AppState,1183+ headers: &HeaderMap,1184+ id: &str,1185+ accept: bool,1186+) -> ApiResult<AccountId> {1187+ let viewer = require_browser_account(state, headers)?;1188+ check_rate(state, viewer, RateClass::Mutation)?;1189+ let resource = ResourceId::from_str(id).map_err(|_| ApiError::not_found())?;11211190 let mut db = state.db.lock().unwrap();1122- if !db1123- .decline_share(viewer, resource)1124- .map_err(ApiError::internal)?1125- {1191+ let changed = if accept {1192+ db.accept_share(viewer, resource)1193+ } else {1194+ db.decline_share(viewer, resource)1195+ }1196+ .map_err(ApiError::internal)?;1197+ if !changed {11261198 return Err(ApiError::not_found());11271199 }1128- Ok(StatusCode::NO_CONTENT)1200+ let owner = db1201+ .resource(resource)1202+ .map_err(ApiError::internal)?1203+ .ok_or_else(ApiError::not_found)?1204+ .owner_id;1205+ drop(db);1206+ state.notify_profiles([viewer, owner]);1207+ Ok(viewer)1208+}1209+1210+fn incoming_share_controls(state: &AppState, viewer: AccountId) -> ApiResult<Html<String>> {1211+ let db = state.db.lock().unwrap();1212+ let invitations = db.incoming_shares(viewer).map_err(ApiError::internal)?;1213+ if invitations.is_empty() {1214+ return Ok(Html(1215+ "<li><small>No incoming shares.</small></li>".to_string(),1216+ ));1217+ }1218+1219+ let mut html = String::new();1220+ for invitation in invitations {1221+ let id = invitation.resource.id;1222+ let destination = invitation1223+ .resource1224+ .url1225+ .as_deref()1226+ .map_or_else(String::new, |url| {1227+ format!(" - <span data-selectable>{}</span>", escape_html(url))1228+ });1229+ html.push_str(&format!(1230+ "<li><div><strong>{}</strong><small>from @{} - {}{}</small></div>\1231+ <div class=\"dashboard-actions\">",1232+ escape_html(&invitation.resource.name),1233+ escape_html(&invitation.owner_handle),1234+ invitation.resource.kind.as_str(),1235+ destination,1236+ ));1237+ if invitation.status == ShareStatus::Pending {1238+ html.push_str(&format!(1239+ "<button type=\"button\" fx-action=\"/ui/share-invitations/{id}/accept\" \1240+ fx-method=\"POST\" fx-target=\"#incoming-shares\" \1241+ fx-swap=\"innerHTML\">Accept</button>"1242+ ));1243+ }1244+ let label = if invitation.status == ShareStatus::Pending {1245+ "Decline"1246+ } else {1247+ "Remove"1248+ };1249+ html.push_str(&format!(1250+ "<button class=\"secondary outline\" type=\"button\" \1251+ fx-action=\"/ui/share-invitations/{id}\" fx-method=\"DELETE\" \1252+ fx-target=\"#incoming-shares\" fx-swap=\"innerHTML\">{label}</button>\1253+ </div></li>"1254+ ));1255+ }1256+ Ok(Html(html))1257+}1258+1259+fn escape_html(value: &str) -> String {1260+ let mut escaped = String::with_capacity(value.len());1261+ for character in value.chars() {1262+ match character {1263+ '&' => escaped.push_str("&amp;"),1264+ '<' => escaped.push_str("&lt;"),1265+ '>' => escaped.push_str("&gt;"),1266+ '"' => escaped.push_str("&quot;"),1267+ '\'' => escaped.push_str("&#39;"),1268+ other => escaped.push(other),1269+ }1270+ }1271+ escaped11291272 }11301273 11311274 /// Record which endpoint id this account's daemon answers on.
@@ -1295,6 +1438,8 @@ async fn set_profile_settings(
12951438 let db = state.db.lock().unwrap();12961439 db.set_profile_private_only(account, body.private_only)12971440 .map_err(ApiError::internal)?;1441+ drop(db);1442+ state.notify_profiles([account]);12981443 Ok(Json(12991444 serde_json::json!({ "private_only": body.private_only }),13001445 ))
@@ -1460,6 +1605,8 @@ async fn write_theme(
14601605 let db = state.db.lock().unwrap();14611606 db.set_custom_css(account, (!css.is_empty()).then_some(css.as_str()))14621607 .map_err(ApiError::internal)?;1608+ drop(db);1609+ state.notify_profiles([account]);14631610 14641611 Ok(Json(ThemeResponse {14651612 css,
@@ -1472,7 +1619,20 @@ async fn profile(
14721619 headers: HeaderMap,14731620 Path(handle): Path<String>,14741621 ) -> ApiResult<impl IntoResponse> {1475- let viewer = current_account(&state, &headers)?;1622+ Ok(Json(profile_snapshot(&state, &headers, &handle)?.response))1623+}1624+1625+struct ProfileSnapshot {1626+ owner_id: AccountId,1627+ response: ProfileResponse,1628+}1629+1630+fn profile_snapshot(1631+ state: &AppState,1632+ headers: &HeaderMap,1633+ handle: &str,1634+) -> ApiResult<ProfileSnapshot> {1635+ let viewer = current_account(state, headers)?;14761636 let handle = handle.trim_start_matches('@').to_ascii_lowercase();14771637 14781638 let db = state.db.lock().unwrap();
@@ -1537,12 +1697,265 @@ async fn profile(
15371697 // the website no longer writes anything. What the owner sees that others do15381698 // not is already expressed by the entries themselves — private resources,15391699 // and the "shared with me" list, which is populated for the owner alone.1540- Ok(Json(ProfileResponse {1541- handle,1542- entries,1543- shared_with_me,1544- theme,1545- }))1700+ Ok(ProfileSnapshot {1701+ owner_id: owner.id,1702+ response: ProfileResponse {1703+ handle,1704+ entries,1705+ shared_with_me,1706+ theme,1707+ },1708+ })1709+}1710+1711+async fn profile_stream(1712+ State(state): State<Shared>,1713+ headers: HeaderMap,1714+ Path(handle): Path<String>,1715+) -> ApiResult<Response> {1716+ let receiver = state.profile_changes.subscribe();1717+ let revision = state.profile_revision.load(Ordering::Relaxed);1718+ let initial = profile_snapshot(&state, &headers, &handle)?;1719+ let owner = initial.owner_id;1720+ let filter_state = Arc::clone(&state);1721+ let render_state = Arc::clone(&state);1722+ let update_headers = headers.clone();1723+ let update_handle = handle.clone();1724+1725+ let initial = stream::iter(profile_events(&initial.response, revision));1726+ let updates = BroadcastStream::new(receiver)1727+ .map(move |change| match change {1728+ Ok(change) if change.accounts.contains(&owner) => Some(change.revision),1729+ Ok(_) => None,1730+ Err(_) => Some(filter_state.profile_revision.load(Ordering::Relaxed)),1731+ })1732+ .filter_map(|revision| async move { revision })1733+ .map(move |revision| {1734+ match profile_snapshot(&render_state, &update_headers, &update_handle) {1735+ Ok(profile) => profile_events(&profile.response, revision),1736+ Err(_) => vec![Ok(Event::default()1737+ .event("unavailable")1738+ .data("not found")1739+ .id(revision.to_string()))],1740+ }1741+ })1742+ .flat_map(stream::iter);1743+1744+ let events = initial.chain(updates);1745+ let mut response = Sse::new(events)1746+ .keep_alive(1747+ KeepAlive::new()1748+ .interval(Duration::from_secs(15))1749+ .text("keep-alive"),1750+ )1751+ .into_response();1752+ // A streaming response must reach the browser as each event arrives.1753+ response.headers_mut().insert(1754+ header::CONTENT_ENCODING,1755+ header::HeaderValue::from_static("identity"),1756+ );1757+ response.headers_mut().insert(1758+ header::HeaderName::from_static("x-accel-buffering"),1759+ header::HeaderValue::from_static("no"),1760+ );1761+ Ok(response)1762+}1763+1764+fn profile_events(profile: &ProfileResponse, revision: u64) -> Vec<Result<Event, Infallible>> {1765+ let id = revision.to_string();1766+ vec![1767+ Ok(Event::default()1768+ .event(r##"{"target":"#profile-theme","swap":"textContent"}"##)1769+ .data(render_profile_theme(profile))1770+ .id(id.clone())),1771+ Ok(Event::default()1772+ .data(render_profile_main(profile))1773+ .id(id)1774+ .retry(Duration::from_secs(3))),1775+ ]1776+}1777+1778+fn render_profile_theme(profile: &ProfileResponse) -> String {1779+ let declarations = profile1780+ .theme1781+ .iter()1782+ .filter(|declaration| declaration.property.starts_with("--pico-"))1783+ .map(|declaration| format!(" {}: {};", declaration.property, declaration.value))1784+ .collect::<Vec<_>>();1785+ if declarations.is_empty() {1786+ // SSEXi does not apply a message with empty data. One space clears the style.1787+ return " ".to_string();1788+ }1789+ format!(1790+ ":root[data-profile=\"{}\"] {{\n{}\n}}\n",1791+ profile.handle,1792+ declarations.join("\n")1793+ )1794+}1795+1796+struct ProfileLayout {1797+ folders_open: bool,1798+ open_folders: HashSet<String>,1799+ folder_order: Vec<String>,1800+}1801+1802+fn profile_layout(declarations: &[theme::Declaration]) -> ProfileLayout {1803+ let value = |property| {1804+ declarations1805+ .iter()1806+ .find(|declaration| declaration.property == property)1807+ .map(|declaration| declaration.value.as_str())1808+ };1809+ let names = |property| {1810+ value(property)1811+ .and_then(|list| serde_json::from_str::<Vec<String>>(&format!("[{list}]")).ok())1812+ .unwrap_or_default()1813+ };1814+ ProfileLayout {1815+ folders_open: value(theme::FOLDERS) != Some("closed"),1816+ open_folders: names(theme::OPEN_FOLDERS).into_iter().collect(),1817+ folder_order: names(theme::FOLDER_ORDER),1818+ }1819+}1820+1821+fn render_profile_main(profile: &ProfileResponse) -> String {1822+ let scheme = profile1823+ .theme1824+ .iter()1825+ .find(|declaration| declaration.property == theme::SCHEME)1826+ .map(|declaration| declaration.value.as_str())1827+ .unwrap_or("auto");1828+ let entries = profile1829+ .entries1830+ .iter()1831+ .chain(profile.shared_with_me.iter())1832+ .collect::<Vec<_>>();1833+ let layout = profile_layout(&profile.theme);1834+ let mut loose = Vec::new();1835+ let mut folders: HashMap<String, Vec<&ProfileEntry>> = HashMap::new();1836+ let mut first_folder_order = Vec::new();1837+ for entry in &entries {1838+ if let Some(folder) = &entry.folder {1839+ if !folders.contains_key(folder) {1840+ first_folder_order.push(folder.clone());1841+ }1842+ folders.entry(folder.clone()).or_default().push(entry);1843+ } else {1844+ loose.push(*entry);1845+ }1846+ }1847+ let mut folder_order = Vec::new();1848+ let mut placed = HashSet::new();1849+ for folder in layout.folder_order.iter().chain(first_folder_order.iter()) {1850+ if folders.contains_key(folder) && placed.insert(folder.clone()) {1851+ folder_order.push(folder.clone());1852+ }1853+ }1854+1855+ let total = entries.len();1856+ let count = if total == 1 {1857+ "1 site".to_string()1858+ } else {1859+ format!("{total} sites")1860+ };1861+ let mut html = format!(1862+ "<main class=\"container\" id=\"main\" data-profile-handle=\"{}\" \1863+ data-profile-scheme=\"{}\"><article id=\"profile\"><hgroup><h1>@{}</h1>\1864+ <p>{count}</p></hgroup>",1865+ escape_html(&profile.handle),1866+ escape_html(scheme),1867+ escape_html(&profile.handle),1868+ );1869+ if total == 0 {1870+ html.push_str("<p><small>Nothing published yet.</small></p>");1871+ } else {1872+ if !loose.is_empty() {1873+ render_profile_entries(&mut html, &loose);1874+ }1875+ for folder in folder_order {1876+ let items = folders.get(&folder).expect("a placed folder must exist");1877+ let open = layout.folders_open || layout.open_folders.contains(&folder);1878+ let _ = write!(1879+ html,1880+ "<details class=\"folder\" data-folder=\"{}\"{}><summary>{} \1881+ <small>{}</small></summary>",1882+ escape_html(&folder),1883+ if open { " open" } else { "" },1884+ escape_html(&folder),1885+ items.len(),1886+ );1887+ render_profile_entries(&mut html, items);1888+ html.push_str("</details>");1889+ }1890+ }1891+ html.push_str("</article></main>");1892+ html1893+}1894+1895+fn render_profile_entries(html: &mut String, entries: &[&ProfileEntry]) {1896+ html.push_str("<ul class=\"entries\">");1897+ for entry in entries {1898+ let owner = entry.owner_handle.as_deref();1899+ let owner_label = owner1900+ .map(|handle| format!(" <small>from @{}</small>", escape_html(handle)))1901+ .unwrap_or_default();1902+ let _ = write!(1903+ html,1904+ "<li class=\"entry\" id=\"site-{}\" data-kind=\"{}\" \1905+ data-visibility=\"{}\">",1906+ entry.resource_id, entry.kind, entry.visibility,1907+ );1908+ if entry.kind == "link" {1909+ let url = entry.url.as_deref().unwrap_or_default();1910+ let note = if entry.visibility == "public" {1911+ String::new()1912+ } else {1913+ format!("{} - ", escape_html(entry.visibility))1914+ };1915+ let _ = write!(1916+ html,1917+ "<a href=\"{}\" target=\"_blank\" rel=\"ugc nofollow noopener noreferrer\">\1918+ {}{owner_label}</a><small class=\"state\">{note}<span class=\"host\">\1919+ <span>{}&nbsp;</span></span>↗</small>",1920+ escape_html(url),1921+ escape_html(&entry.name),1922+ escape_html(&link_host(url)),1923+ );1924+ } else {1925+ let owner_attribute = owner1926+ .map(|handle| format!(" data-service-owner=\"{}\"", escape_html(handle)))1927+ .unwrap_or_default();1928+ let note = if entry.visibility == "public" {1929+ String::new()1930+ } else {1931+ format!(1932+ "<small class=\"state\">{}</small>",1933+ escape_html(entry.visibility)1934+ )1935+ };1936+ let _ = write!(1937+ html,1938+ "<button class=\"outline\" type=\"button\" data-service-id=\"{}\" \1939+ data-service-name=\"{}\"{owner_attribute}>{}{owner_label}</button>{note}",1940+ entry.resource_id,1941+ escape_html(&entry.name),1942+ escape_html(&entry.name),1943+ );1944+ }1945+ html.push_str("</li>");1946+ }1947+ html.push_str("</ul>");1948+}1949+1950+fn link_host(value: &str) -> String {1951+ let Ok(url) = url::Url::parse(value) else {1952+ return String::new();1953+ };1954+ let mut host = url.host_str().unwrap_or_default().to_string();1955+ if let Some(port) = url.port() {1956+ let _ = write!(host, ":{port}");1957+ }1958+ host15461959 }15471960 15481961 fn to_entry(resource: &crate::db::Resource, owner_handle: Option<String>) -> ProfileEntry {
@@ -2114,6 +2527,72 @@ fn parse_endpoint_id(text: &str) -> Option<KeyBytes> {
21142527 mod tests {21152528 use super::*;21162529 2530+ #[test]2531+ fn html_fragments_escape_text_and_attributes() {2532+ assert_eq!(escape_html("<&>\"'"), "&lt;&amp;&gt;&quot;&#39;");2533+ }2534+2535+ #[test]2536+ fn profile_fragments_include_layout_theme_and_safe_entries() {2537+ let profile = ProfileResponse {2538+ handle: "alice".into(),2539+ entries: vec![ProfileEntry {2540+ resource_id: "res_one".into(),2541+ name: "docs <stable>".into(),2542+ kind: "link",2543+ visibility: "public",2544+ url: Some("https://docs.example:8443/guide".into()),2545+ folder: Some("Docs".into()),2546+ owner_handle: None,2547+ }],2548+ shared_with_me: vec![ProfileEntry {2549+ resource_id: "res_two".into(),2550+ name: "database".into(),2551+ kind: "service",2552+ visibility: "shared",2553+ url: None,2554+ folder: Some("Services".into()),2555+ owner_handle: Some("bob".into()),2556+ }],2557+ theme: vec![2558+ theme::Declaration {2559+ property: theme::SCHEME,2560+ value: "dark".into(),2561+ },2562+ theme::Declaration {2563+ property: theme::FOLDERS,2564+ value: "closed".into(),2565+ },2566+ theme::Declaration {2567+ property: theme::OPEN_FOLDERS,2568+ value: "\"Services\"".into(),2569+ },2570+ theme::Declaration {2571+ property: theme::FOLDER_ORDER,2572+ value: "\"Services\", \"Docs\"".into(),2573+ },2574+ theme::Declaration {2575+ property: "--pico-primary",2576+ value: "rebeccapurple".into(),2577+ },2578+ ],2579+ };2580+2581+ let html = render_profile_main(&profile);2582+ assert!(html.starts_with("<main class=\"container\" id=\"main\""));2583+ assert!(html.contains("data-profile-scheme=\"dark\""));2584+ assert!(html.contains("docs &lt;stable&gt;"));2585+ assert!(html.contains("docs.example:8443"));2586+ assert!(html.contains("data-folder=\"Services\" open"));2587+ assert!(html.find("data-folder=\"Services\"") < html.find("data-folder=\"Docs\""));2588+ assert!(html.contains("data-service-owner=\"bob\""));2589+2590+ let css = render_profile_theme(&profile);2591+ assert!(css.contains(":root[data-profile=\"alice\"]"));2592+ assert!(css.contains("--pico-primary: rebeccapurple"));2593+ assert!(!css.contains(theme::FOLDER_ORDER));2594+ }2595+21172596 #[test]21182597 fn links_support_every_visibility_and_tcp_services_are_not_public() {21192598 assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Public).is_ok());
M crates/devsite-server/src/main.rs +3 -0
M crates/devsite-server/src/oidc.rs +1 -1
M deploy/README.md +2 -2
M docs/auth-providers.md +3 -3
M docs/keys-and-identities.md +5 -5
M docs/profile-template.md +12 -9
@@ -44,8 +44,9 @@ no `--pico-font-family` in the whitelist.
4444 4545 ## The template4646 47-Rendered by `web/app.js`. Profile declarations are only meaningful against this48-structure, so changes here and changes to the whitelist belong in the same commit.47+The control plane renders this structure for `GET /ui/profile/{handle}/stream`.48+SSEXi reads each HTML fragment, and Paxi morphs it into `<main>`. Profile declarations are49+only meaningful against this structure. Change the structure and whitelist together.4950 5051 ```html5152 <body>
@@ -53,7 +54,8 @@ structure, so changes here and changes to the whitelist belong in the same commi
5354 <nav>…wordmark…<ul id="session">…</ul></nav>5455 </header>5556 56- <main class="container" id="main">57+ <main class="container" id="main" data-profile-handle="alice"58+ data-profile-scheme="auto">5759 <article id="profile">5860 <hgroup>5961 <h1>@alice</h1>
@@ -64,21 +66,22 @@ structure, so changes here and changes to the whitelist belong in the same commi
6466 <ul class="entries">6567 <!-- reached at its own address, so it is an anchor and takes you away.6668 The host is always present and folded into the arrow; see below. -->67- <li class="entry" data-kind="link" data-visibility="public">69+ <li class="entry" id="site-res_…" data-kind="link" data-visibility="public">6870 <a href="https://klot.ski" target="_blank" rel="noopener noreferrer">klot.ski</a>6971 <small class="state"><span class="host"><span>klot.ski&nbsp;</span></span>↗</small>7072 </li>7173 <!-- reached through its owner's daemon, so it is a button that opens7274 the browser's ticket-minting prompt here. -->73- <li class="entry" data-kind="service" data-visibility="private">74- <button class="outline">Hermes</button>75+ <li class="entry" id="site-res_…" data-kind="service" data-visibility="private">76+ <button class="outline" data-service-id="res_…"77+ data-service-name="Hermes">Hermes</button>7578 <small class="state">private</small>7679 </li>7780 </ul>7881 7982 <!-- then a fold per folder, in the owner's declared order followed by8083 unlisted folders in the order they first appear -->81- <details class="folder" open>84+ <details class="folder" data-folder="Games" open>8285 <summary>Games <small>3</small></summary>8386 <ul class="entries">…</ul>8487 </details>
@@ -149,8 +152,8 @@ what the page's success and failure messages are drawn with.
149152 The list lives in `PROPERTIES` in `theme.rs` and is served at150153 `GET /api/theme/properties`, which is also what `devsite theme properties`151154 prints. Every `--pico-*` name in it is a variable the vendored Pico actually152-defines. The three folder-layout names are consumed explicitly by `app.js` and153-never emitted as CSS. An unrecognized name is rejected rather than accepted,155+defines. The control plane consumes the three folder-layout names as profile data. It156+never emits them as CSS. An unrecognized name is rejected rather than accepted,154157 stored, and left to do nothing.155158 156159 | Group | Properties | Value |
A package-lock.json +1412 -0
@@ -0,0 +1,1412 @@
1+{2+ "name": "devsite-web",3+ "lockfileVersion": 3,4+ "requires": true,5+ "packages": {6+ "": {7+ "name": "devsite-web",8+ "dependencies": {9+ "@bigskysoftware/paxi-js": "0.0.1",10+ "@bigskysoftware/ssexi-js": "0.0.1",11+ "fixi-js": "0.9.4"12+ },13+ "devDependencies": {14+ "@biomejs/biome": "2.5.8",15+ "@playwright/test": "1.62.1",16+ "@types/node": "26.2.0",17+ "esbuild": "0.28.2",18+ "html-minifier-terser": "7.2.0",19+ "typescript": "7.0.2"20+ }21+ },22+ "node_modules/@bigskysoftware/paxi-js": {23+ "version": "0.0.1",24+ "resolved": "https://registry.npmjs.org/@bigskysoftware/paxi-js/-/paxi-js-0.0.1.tgz",25+ "integrity": "sha512-Ws2uGvD2ct4qz8ZfYhhU0U7mEmJL3li68qnks4iRIGo9blFkyCMxDVOP1S9MzhpUeGKcHBjeNtYya4mpwToBbg==",26+ "license": "BSD-0"27+ },28+ "node_modules/@bigskysoftware/ssexi-js": {29+ "version": "0.0.1",30+ "resolved": "https://registry.npmjs.org/@bigskysoftware/ssexi-js/-/ssexi-js-0.0.1.tgz",31+ "integrity": "sha512-F0N7OMT6S0rhaOCgLw2KehL0pbiqV/bZPlnbNDAr7glCKOaAsCN5smo+lOhDi2sA+H8SFDPUfegW0ZMT1G58zQ==",32+ "license": "BSD-0"33+ },34+ "node_modules/@biomejs/biome": {35+ "version": "2.5.8",36+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.8.tgz",37+ "integrity": "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw==",38+ "dev": true,39+ "license": "MIT OR Apache-2.0",40+ "bin": {41+ "biome": "bin/biome"42+ },43+ "engines": {44+ "node": ">=14.21.3"45+ },46+ "funding": {47+ "type": "opencollective",48+ "url": "https://opencollective.com/biome"49+ },50+ "optionalDependencies": {51+ "@biomejs/cli-darwin-arm64": "2.5.8",52+ "@biomejs/cli-darwin-x64": "2.5.8",53+ "@biomejs/cli-linux-arm64": "2.5.8",54+ "@biomejs/cli-linux-arm64-musl": "2.5.8",55+ "@biomejs/cli-linux-x64": "2.5.8",56+ "@biomejs/cli-linux-x64-musl": "2.5.8",57+ "@biomejs/cli-win32-arm64": "2.5.8",58+ "@biomejs/cli-win32-x64": "2.5.8"59+ }60+ },61+ "node_modules/@biomejs/cli-darwin-arm64": {62+ "version": "2.5.8",63+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.8.tgz",64+ "integrity": "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA==",65+ "cpu": [66+ "arm64"67+ ],68+ "dev": true,69+ "license": "MIT OR Apache-2.0",70+ "optional": true,71+ "os": [72+ "darwin"73+ ],74+ "engines": {75+ "node": ">=14.21.3"76+ }77+ },78+ "node_modules/@biomejs/cli-darwin-x64": {79+ "version": "2.5.8",80+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.8.tgz",81+ "integrity": "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w==",82+ "cpu": [83+ "x64"84+ ],85+ "dev": true,86+ "license": "MIT OR Apache-2.0",87+ "optional": true,88+ "os": [89+ "darwin"90+ ],91+ "engines": {92+ "node": ">=14.21.3"93+ }94+ },95+ "node_modules/@biomejs/cli-linux-arm64": {96+ "version": "2.5.8",97+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.8.tgz",98+ "integrity": "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ==",99+ "cpu": [100+ "arm64"101+ ],102+ "dev": true,103+ "libc": [104+ "glibc"105+ ],106+ "license": "MIT OR Apache-2.0",107+ "optional": true,108+ "os": [109+ "linux"110+ ],111+ "engines": {112+ "node": ">=14.21.3"113+ }114+ },115+ "node_modules/@biomejs/cli-linux-arm64-musl": {116+ "version": "2.5.8",117+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.8.tgz",118+ "integrity": "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw==",119+ "cpu": [120+ "arm64"121+ ],122+ "dev": true,123+ "libc": [124+ "musl"125+ ],126+ "license": "MIT OR Apache-2.0",127+ "optional": true,128+ "os": [129+ "linux"130+ ],131+ "engines": {132+ "node": ">=14.21.3"133+ }134+ },135+ "node_modules/@biomejs/cli-linux-x64": {136+ "version": "2.5.8",137+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.8.tgz",138+ "integrity": "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ==",139+ "cpu": [140+ "x64"141+ ],142+ "dev": true,143+ "libc": [144+ "glibc"145+ ],146+ "license": "MIT OR Apache-2.0",147+ "optional": true,148+ "os": [149+ "linux"150+ ],151+ "engines": {152+ "node": ">=14.21.3"153+ }154+ },155+ "node_modules/@biomejs/cli-linux-x64-musl": {156+ "version": "2.5.8",157+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.8.tgz",158+ "integrity": "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w==",159+ "cpu": [160+ "x64"161+ ],162+ "dev": true,163+ "libc": [164+ "musl"165+ ],166+ "license": "MIT OR Apache-2.0",167+ "optional": true,168+ "os": [169+ "linux"170+ ],171+ "engines": {172+ "node": ">=14.21.3"173+ }174+ },175+ "node_modules/@biomejs/cli-win32-arm64": {176+ "version": "2.5.8",177+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.8.tgz",178+ "integrity": "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA==",179+ "cpu": [180+ "arm64"181+ ],182+ "dev": true,183+ "license": "MIT OR Apache-2.0",184+ "optional": true,185+ "os": [186+ "win32"187+ ],188+ "engines": {189+ "node": ">=14.21.3"190+ }191+ },192+ "node_modules/@biomejs/cli-win32-x64": {193+ "version": "2.5.8",194+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.8.tgz",195+ "integrity": "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA==",196+ "cpu": [197+ "x64"198+ ],199+ "dev": true,200+ "license": "MIT OR Apache-2.0",201+ "optional": true,202+ "os": [203+ "win32"204+ ],205+ "engines": {206+ "node": ">=14.21.3"207+ }208+ },209+ "node_modules/@esbuild/aix-ppc64": {210+ "version": "0.28.2",211+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",212+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",213+ "cpu": [214+ "ppc64"215+ ],216+ "dev": true,217+ "license": "MIT",218+ "optional": true,219+ "os": [220+ "aix"221+ ],222+ "engines": {223+ "node": ">=18"224+ }225+ },226+ "node_modules/@esbuild/android-arm": {227+ "version": "0.28.2",228+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",229+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",230+ "cpu": [231+ "arm"232+ ],233+ "dev": true,234+ "license": "MIT",235+ "optional": true,236+ "os": [237+ "android"238+ ],239+ "engines": {240+ "node": ">=18"241+ }242+ },243+ "node_modules/@esbuild/android-arm64": {244+ "version": "0.28.2",245+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",246+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",247+ "cpu": [248+ "arm64"249+ ],250+ "dev": true,251+ "license": "MIT",252+ "optional": true,253+ "os": [254+ "android"255+ ],256+ "engines": {257+ "node": ">=18"258+ }259+ },260+ "node_modules/@esbuild/android-x64": {261+ "version": "0.28.2",262+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",263+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",264+ "cpu": [265+ "x64"266+ ],267+ "dev": true,268+ "license": "MIT",269+ "optional": true,270+ "os": [271+ "android"272+ ],273+ "engines": {274+ "node": ">=18"275+ }276+ },277+ "node_modules/@esbuild/darwin-arm64": {278+ "version": "0.28.2",279+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",280+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",281+ "cpu": [282+ "arm64"283+ ],284+ "dev": true,285+ "license": "MIT",286+ "optional": true,287+ "os": [288+ "darwin"289+ ],290+ "engines": {291+ "node": ">=18"292+ }293+ },294+ "node_modules/@esbuild/darwin-x64": {295+ "version": "0.28.2",296+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",297+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",298+ "cpu": [299+ "x64"300+ ],301+ "dev": true,302+ "license": "MIT",303+ "optional": true,304+ "os": [305+ "darwin"306+ ],307+ "engines": {308+ "node": ">=18"309+ }310+ },311+ "node_modules/@esbuild/freebsd-arm64": {312+ "version": "0.28.2",313+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",314+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",315+ "cpu": [316+ "arm64"317+ ],318+ "dev": true,319+ "license": "MIT",320+ "optional": true,321+ "os": [322+ "freebsd"323+ ],324+ "engines": {325+ "node": ">=18"326+ }327+ },328+ "node_modules/@esbuild/freebsd-x64": {329+ "version": "0.28.2",330+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",331+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",332+ "cpu": [333+ "x64"334+ ],335+ "dev": true,336+ "license": "MIT",337+ "optional": true,338+ "os": [339+ "freebsd"340+ ],341+ "engines": {342+ "node": ">=18"343+ }344+ },345+ "node_modules/@esbuild/linux-arm": {346+ "version": "0.28.2",347+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",348+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",349+ "cpu": [350+ "arm"351+ ],352+ "dev": true,353+ "license": "MIT",354+ "optional": true,355+ "os": [356+ "linux"357+ ],358+ "engines": {359+ "node": ">=18"360+ }361+ },362+ "node_modules/@esbuild/linux-arm64": {363+ "version": "0.28.2",364+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",365+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",366+ "cpu": [367+ "arm64"368+ ],369+ "dev": true,370+ "license": "MIT",371+ "optional": true,372+ "os": [373+ "linux"374+ ],375+ "engines": {376+ "node": ">=18"377+ }378+ },379+ "node_modules/@esbuild/linux-ia32": {380+ "version": "0.28.2",381+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",382+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",383+ "cpu": [384+ "ia32"385+ ],386+ "dev": true,387+ "license": "MIT",388+ "optional": true,389+ "os": [390+ "linux"391+ ],392+ "engines": {393+ "node": ">=18"394+ }395+ },396+ "node_modules/@esbuild/linux-loong64": {397+ "version": "0.28.2",398+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",399+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",400+ "cpu": [401+ "loong64"402+ ],403+ "dev": true,404+ "license": "MIT",405+ "optional": true,406+ "os": [407+ "linux"408+ ],409+ "engines": {410+ "node": ">=18"411+ }412+ },413+ "node_modules/@esbuild/linux-mips64el": {414+ "version": "0.28.2",415+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",416+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",417+ "cpu": [418+ "mips64el"419+ ],420+ "dev": true,421+ "license": "MIT",422+ "optional": true,423+ "os": [424+ "linux"425+ ],426+ "engines": {427+ "node": ">=18"428+ }429+ },430+ "node_modules/@esbuild/linux-ppc64": {431+ "version": "0.28.2",432+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",433+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",434+ "cpu": [435+ "ppc64"436+ ],437+ "dev": true,438+ "license": "MIT",439+ "optional": true,440+ "os": [441+ "linux"442+ ],443+ "engines": {444+ "node": ">=18"445+ }446+ },447+ "node_modules/@esbuild/linux-riscv64": {448+ "version": "0.28.2",449+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",450+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",451+ "cpu": [452+ "riscv64"453+ ],454+ "dev": true,455+ "license": "MIT",456+ "optional": true,457+ "os": [458+ "linux"459+ ],460+ "engines": {461+ "node": ">=18"462+ }463+ },464+ "node_modules/@esbuild/linux-s390x": {465+ "version": "0.28.2",466+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",467+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",468+ "cpu": [469+ "s390x"470+ ],471+ "dev": true,472+ "license": "MIT",473+ "optional": true,474+ "os": [475+ "linux"476+ ],477+ "engines": {478+ "node": ">=18"479+ }480+ },481+ "node_modules/@esbuild/linux-x64": {482+ "version": "0.28.2",483+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",484+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",485+ "cpu": [486+ "x64"487+ ],488+ "dev": true,489+ "license": "MIT",490+ "optional": true,491+ "os": [492+ "linux"493+ ],494+ "engines": {495+ "node": ">=18"496+ }497+ },498+ "node_modules/@esbuild/netbsd-arm64": {499+ "version": "0.28.2",500+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",501+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",502+ "cpu": [503+ "arm64"504+ ],505+ "dev": true,506+ "license": "MIT",507+ "optional": true,508+ "os": [509+ "netbsd"510+ ],511+ "engines": {512+ "node": ">=18"513+ }514+ },515+ "node_modules/@esbuild/netbsd-x64": {516+ "version": "0.28.2",517+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",518+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",519+ "cpu": [520+ "x64"521+ ],522+ "dev": true,523+ "license": "MIT",524+ "optional": true,525+ "os": [526+ "netbsd"527+ ],528+ "engines": {529+ "node": ">=18"530+ }531+ },532+ "node_modules/@esbuild/openbsd-arm64": {533+ "version": "0.28.2",534+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",535+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",536+ "cpu": [537+ "arm64"538+ ],539+ "dev": true,540+ "license": "MIT",541+ "optional": true,542+ "os": [543+ "openbsd"544+ ],545+ "engines": {546+ "node": ">=18"547+ }548+ },549+ "node_modules/@esbuild/openbsd-x64": {550+ "version": "0.28.2",551+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",552+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",553+ "cpu": [554+ "x64"555+ ],556+ "dev": true,557+ "license": "MIT",558+ "optional": true,559+ "os": [560+ "openbsd"561+ ],562+ "engines": {563+ "node": ">=18"564+ }565+ },566+ "node_modules/@esbuild/openharmony-arm64": {567+ "version": "0.28.2",568+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",569+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",570+ "cpu": [571+ "arm64"572+ ],573+ "dev": true,574+ "license": "MIT",575+ "optional": true,576+ "os": [577+ "openharmony"578+ ],579+ "engines": {580+ "node": ">=18"581+ }582+ },583+ "node_modules/@esbuild/sunos-x64": {584+ "version": "0.28.2",585+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",586+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",587+ "cpu": [588+ "x64"589+ ],590+ "dev": true,591+ "license": "MIT",592+ "optional": true,593+ "os": [594+ "sunos"595+ ],596+ "engines": {597+ "node": ">=18"598+ }599+ },600+ "node_modules/@esbuild/win32-arm64": {601+ "version": "0.28.2",602+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",603+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",604+ "cpu": [605+ "arm64"606+ ],607+ "dev": true,608+ "license": "MIT",609+ "optional": true,610+ "os": [611+ "win32"612+ ],613+ "engines": {614+ "node": ">=18"615+ }616+ },617+ "node_modules/@esbuild/win32-ia32": {618+ "version": "0.28.2",619+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",620+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",621+ "cpu": [622+ "ia32"623+ ],624+ "dev": true,625+ "license": "MIT",626+ "optional": true,627+ "os": [628+ "win32"629+ ],630+ "engines": {631+ "node": ">=18"632+ }633+ },634+ "node_modules/@esbuild/win32-x64": {635+ "version": "0.28.2",636+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",637+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",638+ "cpu": [639+ "x64"640+ ],641+ "dev": true,642+ "license": "MIT",643+ "optional": true,644+ "os": [645+ "win32"646+ ],647+ "engines": {648+ "node": ">=18"649+ }650+ },651+ "node_modules/@jridgewell/gen-mapping": {652+ "version": "0.3.13",653+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",654+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",655+ "dev": true,656+ "license": "MIT",657+ "dependencies": {658+ "@jridgewell/sourcemap-codec": "^1.5.0",659+ "@jridgewell/trace-mapping": "^0.3.24"660+ }661+ },662+ "node_modules/@jridgewell/resolve-uri": {663+ "version": "3.1.2",664+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",665+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",666+ "dev": true,667+ "license": "MIT",668+ "engines": {669+ "node": ">=6.0.0"670+ }671+ },672+ "node_modules/@jridgewell/source-map": {673+ "version": "0.3.11",674+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",675+ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",676+ "dev": true,677+ "license": "MIT",678+ "dependencies": {679+ "@jridgewell/gen-mapping": "^0.3.5",680+ "@jridgewell/trace-mapping": "^0.3.25"681+ }682+ },683+ "node_modules/@jridgewell/sourcemap-codec": {684+ "version": "1.5.5",685+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",686+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",687+ "dev": true,688+ "license": "MIT"689+ },690+ "node_modules/@jridgewell/trace-mapping": {691+ "version": "0.3.31",692+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",693+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",694+ "dev": true,695+ "license": "MIT",696+ "dependencies": {697+ "@jridgewell/resolve-uri": "^3.1.0",698+ "@jridgewell/sourcemap-codec": "^1.4.14"699+ }700+ },701+ "node_modules/@playwright/test": {702+ "version": "1.62.1",703+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",704+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",705+ "dev": true,706+ "license": "Apache-2.0",707+ "dependencies": {708+ "playwright": "1.62.1"709+ },710+ "bin": {711+ "playwright": "cli.js"712+ },713+ "engines": {714+ "node": ">=20"715+ }716+ },717+ "node_modules/@types/node": {718+ "version": "26.2.0",719+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",720+ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",721+ "dev": true,722+ "license": "MIT",723+ "dependencies": {724+ "undici-types": "~8.3.0"725+ }726+ },727+ "node_modules/@typescript/typescript-aix-ppc64": {728+ "version": "7.0.2",729+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",730+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",731+ "cpu": [732+ "ppc64"733+ ],734+ "dev": true,735+ "license": "Apache-2.0",736+ "optional": true,737+ "os": [738+ "aix"739+ ],740+ "engines": {741+ "node": ">=16.20.0"742+ }743+ },744+ "node_modules/@typescript/typescript-darwin-arm64": {745+ "version": "7.0.2",746+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",747+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",748+ "cpu": [749+ "arm64"750+ ],751+ "dev": true,752+ "license": "Apache-2.0",753+ "optional": true,754+ "os": [755+ "darwin"756+ ],757+ "engines": {758+ "node": ">=16.20.0"759+ }760+ },761+ "node_modules/@typescript/typescript-darwin-x64": {762+ "version": "7.0.2",763+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",764+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",765+ "cpu": [766+ "x64"767+ ],768+ "dev": true,769+ "license": "Apache-2.0",770+ "optional": true,771+ "os": [772+ "darwin"773+ ],774+ "engines": {775+ "node": ">=16.20.0"776+ }777+ },778+ "node_modules/@typescript/typescript-freebsd-arm64": {779+ "version": "7.0.2",780+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",781+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",782+ "cpu": [783+ "arm64"784+ ],785+ "dev": true,786+ "license": "Apache-2.0",787+ "optional": true,788+ "os": [789+ "freebsd"790+ ],791+ "engines": {792+ "node": ">=16.20.0"793+ }794+ },795+ "node_modules/@typescript/typescript-freebsd-x64": {796+ "version": "7.0.2",797+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",798+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",799+ "cpu": [800+ "x64"801+ ],802+ "dev": true,803+ "license": "Apache-2.0",804+ "optional": true,805+ "os": [806+ "freebsd"807+ ],808+ "engines": {809+ "node": ">=16.20.0"810+ }811+ },812+ "node_modules/@typescript/typescript-linux-arm": {813+ "version": "7.0.2",814+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",815+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",816+ "cpu": [817+ "arm"818+ ],819+ "dev": true,820+ "license": "Apache-2.0",821+ "optional": true,822+ "os": [823+ "linux"824+ ],825+ "engines": {826+ "node": ">=16.20.0"827+ }828+ },829+ "node_modules/@typescript/typescript-linux-arm64": {830+ "version": "7.0.2",831+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",832+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",833+ "cpu": [834+ "arm64"835+ ],836+ "dev": true,837+ "license": "Apache-2.0",838+ "optional": true,839+ "os": [840+ "linux"841+ ],842+ "engines": {843+ "node": ">=16.20.0"844+ }845+ },846+ "node_modules/@typescript/typescript-linux-loong64": {847+ "version": "7.0.2",848+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",849+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",850+ "cpu": [851+ "loong64"852+ ],853+ "dev": true,854+ "license": "Apache-2.0",855+ "optional": true,856+ "os": [857+ "linux"858+ ],859+ "engines": {860+ "node": ">=16.20.0"861+ }862+ },863+ "node_modules/@typescript/typescript-linux-mips64el": {864+ "version": "7.0.2",865+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",866+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",867+ "cpu": [868+ "mips64el"869+ ],870+ "dev": true,871+ "license": "Apache-2.0",872+ "optional": true,873+ "os": [874+ "linux"875+ ],876+ "engines": {877+ "node": ">=16.20.0"878+ }879+ },880+ "node_modules/@typescript/typescript-linux-ppc64": {881+ "version": "7.0.2",882+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",883+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",884+ "cpu": [885+ "ppc64"886+ ],887+ "dev": true,888+ "license": "Apache-2.0",889+ "optional": true,890+ "os": [891+ "linux"892+ ],893+ "engines": {894+ "node": ">=16.20.0"895+ }896+ },897+ "node_modules/@typescript/typescript-linux-riscv64": {898+ "version": "7.0.2",899+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",900+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",901+ "cpu": [902+ "riscv64"903+ ],904+ "dev": true,905+ "license": "Apache-2.0",906+ "optional": true,907+ "os": [908+ "linux"909+ ],910+ "engines": {911+ "node": ">=16.20.0"912+ }913+ },914+ "node_modules/@typescript/typescript-linux-s390x": {915+ "version": "7.0.2",916+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",917+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",918+ "cpu": [919+ "s390x"920+ ],921+ "dev": true,922+ "license": "Apache-2.0",923+ "optional": true,924+ "os": [925+ "linux"926+ ],927+ "engines": {928+ "node": ">=16.20.0"929+ }930+ },931+ "node_modules/@typescript/typescript-linux-x64": {932+ "version": "7.0.2",933+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",934+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",935+ "cpu": [936+ "x64"937+ ],938+ "dev": true,939+ "license": "Apache-2.0",940+ "optional": true,941+ "os": [942+ "linux"943+ ],944+ "engines": {945+ "node": ">=16.20.0"946+ }947+ },948+ "node_modules/@typescript/typescript-netbsd-arm64": {949+ "version": "7.0.2",950+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",951+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",952+ "cpu": [953+ "arm64"954+ ],955+ "dev": true,956+ "license": "Apache-2.0",957+ "optional": true,958+ "os": [959+ "netbsd"960+ ],961+ "engines": {962+ "node": ">=16.20.0"963+ }964+ },965+ "node_modules/@typescript/typescript-netbsd-x64": {966+ "version": "7.0.2",967+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",968+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",969+ "cpu": [970+ "x64"971+ ],972+ "dev": true,973+ "license": "Apache-2.0",974+ "optional": true,975+ "os": [976+ "netbsd"977+ ],978+ "engines": {979+ "node": ">=16.20.0"980+ }981+ },982+ "node_modules/@typescript/typescript-openbsd-arm64": {983+ "version": "7.0.2",984+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",985+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",986+ "cpu": [987+ "arm64"988+ ],989+ "dev": true,990+ "license": "Apache-2.0",991+ "optional": true,992+ "os": [993+ "openbsd"994+ ],995+ "engines": {996+ "node": ">=16.20.0"997+ }998+ },999+ "node_modules/@typescript/typescript-openbsd-x64": {1000+ "version": "7.0.2",1001+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",1002+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",1003+ "cpu": [1004+ "x64"1005+ ],1006+ "dev": true,1007+ "license": "Apache-2.0",1008+ "optional": true,1009+ "os": [1010+ "openbsd"1011+ ],1012+ "engines": {1013+ "node": ">=16.20.0"1014+ }1015+ },1016+ "node_modules/@typescript/typescript-sunos-x64": {1017+ "version": "7.0.2",1018+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",1019+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",1020+ "cpu": [1021+ "x64"1022+ ],1023+ "dev": true,1024+ "license": "Apache-2.0",1025+ "optional": true,1026+ "os": [1027+ "sunos"1028+ ],1029+ "engines": {1030+ "node": ">=16.20.0"1031+ }1032+ },1033+ "node_modules/@typescript/typescript-win32-arm64": {1034+ "version": "7.0.2",1035+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",1036+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",1037+ "cpu": [1038+ "arm64"1039+ ],1040+ "dev": true,1041+ "license": "Apache-2.0",1042+ "optional": true,1043+ "os": [1044+ "win32"1045+ ],1046+ "engines": {1047+ "node": ">=16.20.0"1048+ }1049+ },1050+ "node_modules/@typescript/typescript-win32-x64": {1051+ "version": "7.0.2",1052+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",1053+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",1054+ "cpu": [1055+ "x64"1056+ ],1057+ "dev": true,1058+ "license": "Apache-2.0",1059+ "optional": true,1060+ "os": [1061+ "win32"1062+ ],1063+ "engines": {1064+ "node": ">=16.20.0"1065+ }1066+ },1067+ "node_modules/acorn": {1068+ "version": "8.18.0",1069+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",1070+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",1071+ "dev": true,1072+ "license": "MIT",1073+ "bin": {1074+ "acorn": "bin/acorn"1075+ },1076+ "engines": {1077+ "node": ">=0.4.0"1078+ }1079+ },1080+ "node_modules/buffer-from": {1081+ "version": "1.1.2",1082+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",1083+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",1084+ "dev": true,1085+ "license": "MIT"1086+ },1087+ "node_modules/camel-case": {1088+ "version": "4.1.2",1089+ "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",1090+ "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",1091+ "dev": true,1092+ "license": "MIT",1093+ "dependencies": {1094+ "pascal-case": "^3.1.2",1095+ "tslib": "^2.0.3"1096+ }1097+ },1098+ "node_modules/clean-css": {1099+ "version": "5.3.3",1100+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",1101+ "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==",1102+ "dev": true,1103+ "license": "MIT",1104+ "dependencies": {1105+ "source-map": "~0.6.0"1106+ },1107+ "engines": {1108+ "node": ">= 10.0"1109+ }1110+ },1111+ "node_modules/commander": {1112+ "version": "10.0.1",1113+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",1114+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",1115+ "dev": true,1116+ "license": "MIT",1117+ "engines": {1118+ "node": ">=14"1119+ }1120+ },1121+ "node_modules/dot-case": {1122+ "version": "3.0.4",1123+ "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",1124+ "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",1125+ "dev": true,1126+ "license": "MIT",1127+ "dependencies": {1128+ "no-case": "^3.0.4",1129+ "tslib": "^2.0.3"1130+ }1131+ },1132+ "node_modules/entities": {1133+ "version": "4.5.0",1134+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",1135+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",1136+ "dev": true,1137+ "license": "BSD-2-Clause",1138+ "engines": {1139+ "node": ">=0.12"1140+ },1141+ "funding": {1142+ "url": "https://github.com/fb55/entities?sponsor=1"1143+ }1144+ },1145+ "node_modules/esbuild": {1146+ "version": "0.28.2",1147+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",1148+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",1149+ "dev": true,1150+ "hasInstallScript": true,1151+ "license": "MIT",1152+ "bin": {1153+ "esbuild": "bin/esbuild"1154+ },1155+ "engines": {1156+ "node": ">=18"1157+ },1158+ "optionalDependencies": {1159+ "@esbuild/aix-ppc64": "0.28.2",1160+ "@esbuild/android-arm": "0.28.2",1161+ "@esbuild/android-arm64": "0.28.2",1162+ "@esbuild/android-x64": "0.28.2",1163+ "@esbuild/darwin-arm64": "0.28.2",1164+ "@esbuild/darwin-x64": "0.28.2",1165+ "@esbuild/freebsd-arm64": "0.28.2",1166+ "@esbuild/freebsd-x64": "0.28.2",1167+ "@esbuild/linux-arm": "0.28.2",1168+ "@esbuild/linux-arm64": "0.28.2",1169+ "@esbuild/linux-ia32": "0.28.2",1170+ "@esbuild/linux-loong64": "0.28.2",1171+ "@esbuild/linux-mips64el": "0.28.2",1172+ "@esbuild/linux-ppc64": "0.28.2",1173+ "@esbuild/linux-riscv64": "0.28.2",1174+ "@esbuild/linux-s390x": "0.28.2",1175+ "@esbuild/linux-x64": "0.28.2",1176+ "@esbuild/netbsd-arm64": "0.28.2",1177+ "@esbuild/netbsd-x64": "0.28.2",1178+ "@esbuild/openbsd-arm64": "0.28.2",1179+ "@esbuild/openbsd-x64": "0.28.2",1180+ "@esbuild/openharmony-arm64": "0.28.2",1181+ "@esbuild/sunos-x64": "0.28.2",1182+ "@esbuild/win32-arm64": "0.28.2",1183+ "@esbuild/win32-ia32": "0.28.2",1184+ "@esbuild/win32-x64": "0.28.2"1185+ }1186+ },1187+ "node_modules/fixi-js": {1188+ "version": "0.9.4",1189+ "resolved": "https://registry.npmjs.org/fixi-js/-/fixi-js-0.9.4.tgz",1190+ "integrity": "sha512-ruUKWlM+t6k7YvoVO67z/1jMw04Q6rJCEBeZJPeFzt7U/pXz3S4bp+qxll+nwpOPN/f+pyTdLU70Ic5G+gyk1A==",1191+ "license": "BSD-0"1192+ },1193+ "node_modules/fsevents": {1194+ "version": "2.3.2",1195+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",1196+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",1197+ "dev": true,1198+ "hasInstallScript": true,1199+ "license": "MIT",1200+ "optional": true,1201+ "os": [1202+ "darwin"1203+ ],1204+ "engines": {1205+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"1206+ }1207+ },1208+ "node_modules/html-minifier-terser": {1209+ "version": "7.2.0",1210+ "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",1211+ "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==",1212+ "dev": true,1213+ "license": "MIT",1214+ "dependencies": {1215+ "camel-case": "^4.1.2",1216+ "clean-css": "~5.3.2",1217+ "commander": "^10.0.0",1218+ "entities": "^4.4.0",1219+ "param-case": "^3.0.4",1220+ "relateurl": "^0.2.7",1221+ "terser": "^5.15.1"1222+ },1223+ "bin": {1224+ "html-minifier-terser": "cli.js"1225+ },1226+ "engines": {1227+ "node": "^14.13.1 || >=16.0.0"1228+ }1229+ },1230+ "node_modules/lower-case": {1231+ "version": "2.0.2",1232+ "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",1233+ "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",1234+ "dev": true,1235+ "license": "MIT",1236+ "dependencies": {1237+ "tslib": "^2.0.3"1238+ }1239+ },1240+ "node_modules/no-case": {1241+ "version": "3.0.4",1242+ "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",1243+ "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",1244+ "dev": true,1245+ "license": "MIT",1246+ "dependencies": {1247+ "lower-case": "^2.0.2",1248+ "tslib": "^2.0.3"1249+ }1250+ },1251+ "node_modules/param-case": {1252+ "version": "3.0.4",1253+ "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",1254+ "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",1255+ "dev": true,1256+ "license": "MIT",1257+ "dependencies": {1258+ "dot-case": "^3.0.4",1259+ "tslib": "^2.0.3"1260+ }1261+ },1262+ "node_modules/pascal-case": {1263+ "version": "3.1.2",1264+ "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",1265+ "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",1266+ "dev": true,1267+ "license": "MIT",1268+ "dependencies": {1269+ "no-case": "^3.0.4",1270+ "tslib": "^2.0.3"1271+ }1272+ },1273+ "node_modules/playwright": {1274+ "version": "1.62.1",1275+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",1276+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",1277+ "dev": true,1278+ "license": "Apache-2.0",1279+ "dependencies": {1280+ "playwright-core": "1.62.1"1281+ },1282+ "bin": {1283+ "playwright": "cli.js"1284+ },1285+ "engines": {1286+ "node": ">=20"1287+ },1288+ "optionalDependencies": {1289+ "fsevents": "2.3.2"1290+ }1291+ },1292+ "node_modules/playwright-core": {1293+ "version": "1.62.1",1294+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",1295+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",1296+ "dev": true,1297+ "license": "Apache-2.0",1298+ "bin": {1299+ "playwright-core": "cli.js"1300+ },1301+ "engines": {1302+ "node": ">=20"1303+ }1304+ },1305+ "node_modules/relateurl": {1306+ "version": "0.2.7",1307+ "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",1308+ "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",1309+ "dev": true,1310+ "license": "MIT",1311+ "engines": {1312+ "node": ">= 0.10"1313+ }1314+ },1315+ "node_modules/source-map": {1316+ "version": "0.6.1",1317+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",1318+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",1319+ "dev": true,1320+ "license": "BSD-3-Clause",1321+ "engines": {1322+ "node": ">=0.10.0"1323+ }1324+ },1325+ "node_modules/source-map-support": {1326+ "version": "0.5.21",1327+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",1328+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",1329+ "dev": true,1330+ "license": "MIT",1331+ "dependencies": {1332+ "buffer-from": "^1.0.0",1333+ "source-map": "^0.6.0"1334+ }1335+ },1336+ "node_modules/terser": {1337+ "version": "5.50.0",1338+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.50.0.tgz",1339+ "integrity": "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==",1340+ "dev": true,1341+ "license": "BSD-2-Clause",1342+ "dependencies": {1343+ "@jridgewell/source-map": "^0.3.3",1344+ "acorn": "^8.15.0",1345+ "commander": "^2.20.0",1346+ "source-map-support": "~0.5.20"1347+ },1348+ "bin": {1349+ "terser": "bin/terser"1350+ },1351+ "engines": {1352+ "node": ">=10"1353+ }1354+ },1355+ "node_modules/terser/node_modules/commander": {1356+ "version": "2.20.3",1357+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",1358+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",1359+ "dev": true,1360+ "license": "MIT"1361+ },1362+ "node_modules/tslib": {1363+ "version": "2.8.1",1364+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",1365+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",1366+ "dev": true,1367+ "license": "0BSD"1368+ },1369+ "node_modules/typescript": {1370+ "version": "7.0.2",1371+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",1372+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",1373+ "dev": true,1374+ "license": "Apache-2.0",1375+ "bin": {1376+ "tsc": "bin/tsc"1377+ },1378+ "engines": {1379+ "node": ">=16.20.0"1380+ },1381+ "optionalDependencies": {1382+ "@typescript/typescript-aix-ppc64": "7.0.2",1383+ "@typescript/typescript-darwin-arm64": "7.0.2",1384+ "@typescript/typescript-darwin-x64": "7.0.2",1385+ "@typescript/typescript-freebsd-arm64": "7.0.2",1386+ "@typescript/typescript-freebsd-x64": "7.0.2",1387+ "@typescript/typescript-linux-arm": "7.0.2",1388+ "@typescript/typescript-linux-arm64": "7.0.2",1389+ "@typescript/typescript-linux-loong64": "7.0.2",1390+ "@typescript/typescript-linux-mips64el": "7.0.2",1391+ "@typescript/typescript-linux-ppc64": "7.0.2",1392+ "@typescript/typescript-linux-riscv64": "7.0.2",1393+ "@typescript/typescript-linux-s390x": "7.0.2",1394+ "@typescript/typescript-linux-x64": "7.0.2",1395+ "@typescript/typescript-netbsd-arm64": "7.0.2",1396+ "@typescript/typescript-netbsd-x64": "7.0.2",1397+ "@typescript/typescript-openbsd-arm64": "7.0.2",1398+ "@typescript/typescript-openbsd-x64": "7.0.2",1399+ "@typescript/typescript-sunos-x64": "7.0.2",1400+ "@typescript/typescript-win32-arm64": "7.0.2",1401+ "@typescript/typescript-win32-x64": "7.0.2"1402+ }1403+ },1404+ "node_modules/undici-types": {1405+ "version": "8.3.0",1406+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",1407+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",1408+ "dev": true,1409+ "license": "MIT"1410+ }1411+ }1412+}
A package.json +25 -0
A playwright.config.ts +12 -0
M plugins/devsite-access/.codex-plugin/plugin.json +1 -1
A tsconfig.json +14 -0
D web/app.js +0 -1032
@@ -1,1032 +0,0 @@
1-// dev.site — profiles, sharing controls, and TCP service connection instructions.2-//3-// The public profile reads. The signed-in homepage is a deliberately small4-// dashboard for account-bound controls: privacy, share revocation and machine5-// credentials. Creating links and exposures remains in the CLI.6-//7-// The markup this file produces is a contract, not an implementation detail: a8-// profile's theme is a list of --pico-* assignments that only mean anything9-// against the elements below. Keep it semantic, keep it documented, and change10-// it in step with docs/profile-template.md.11-12-const $ = (id) => document.getElementById(id);13-const main = $('main');14-const pageFooter = $('page-footer');15-16-let me = null;17-18-// -- utilities -----------------------------------------------------------------19-20-const esc = (value) =>21- String(value).replace(/[&<>"']/g, (c) =>22- ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));23-24-function setPageTitle(suffix = '') {25- document.title = suffix ? `dev.site - ${suffix}` : 'dev.site';26-}27-28-async function api(path, options = {}) {29- const response = await fetch(path, {30- credentials: 'same-origin',31- headers: { 'Content-Type': 'application/json' },32- ...options,33- });34- if (!response.ok) {35- const body = await response.json().catch(() => ({}));36- throw new Error(body.error || `${response.status}`);37- }38- return response.status === 204 ? null : response.json();39-}40-41-// -- provider-neutral sign-in --------------------------------------------------42-43-function startSignIn() {44- const returnTo = `${location.pathname}${location.search}`;45- location.assign(`/auth/start?return_to=${encodeURIComponent(returnTo)}`);46-}47-48-// -- themes --------------------------------------------------------------------49-50-/// Apply a profile's theme.51-///52-/// The server has already checked every property against its whitelist and every53-/// value against a grammar, so this is only assembly: one rule, scoped to the54-/// profile, holding nothing but custom-property assignments.55-///56-/// The `:root` in the selector is load-bearing. Pico sets its palette on57-/// `:root:not([data-theme=dark])`, which is specificity (0,2,0) because `:not()`58-/// contributes its argument's weight. A bare `[data-profile="…"]` is (0,1,0) and59-/// loses to it outright — no matter how late in the document it appears. With60-/// `:root` in front we match that (0,2,0) and win on source order, being the last61-/// stylesheet in the page. This was wrong until it was tested in a browser, and62-/// the failure was silent: themes stored, served, and ignored.63-function applyTheme(handle, declarations = []) {64- document.querySelector('.brand strong').hidden = false;65- const root = document.documentElement;66- // Belt and braces: handles are already restricted to this alphabet server-side.67- const scope = String(handle).replace(/[^A-Za-z0-9_-]/g, '');68- root.dataset.profile = scope;69-70- // The one key that is not a Pico variable — it picks which of Pico's own71- // palettes the profile starts from. Pico also sets the root color-scheme,72- // so CSS light-dark() values follow this choice without a second switcher.73- const scheme = declarations.find((d) => d.property === '--devsite-scheme')?.value;74- if (scheme === 'light' || scheme === 'dark') {75- root.dataset.theme = scheme;76- } else {77- delete root.dataset.theme;78- }79-80- const body = declarations81- // Layout declarations are data for renderProfile, not CSS. Restricting the82- // generated rule positively to Pico variables keeps quoted folder names out83- // of the style element regardless of what printable Unicode they contain.84- .filter((d) => d.property.startsWith('--pico-'))85- .map((d) => ` ${d.property}: ${d.value};`)86- .join('\n');87- $('profile-theme').textContent = body88- ? `:root[data-profile="${scope}"] {\n${body}\n}\n`89- : '';90- requestAnimationFrame(updateLogoContrast);91-}92-93-function clearTheme() {94- document.querySelector('.brand strong').hidden = false;95- delete document.documentElement.dataset.profile;96- delete document.documentElement.dataset.theme;97- $('profile-theme').textContent = '';98- pageFooter.replaceChildren();99- pageFooter.hidden = true;100- requestAnimationFrame(updateLogoContrast);101-}102-103-function parseComputedColor(value) {104- const match = value.match(/^rgba?\(\s*([\d.]+)[, ]+\s*([\d.]+)[, ]+\s*([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i);105- if (!match) return null;106- return {107- rgb: [Number(match[1]), Number(match[2]), Number(match[3])],108- alpha: match[4] === undefined ? 1 : Number(match[4]),109- };110-}111-112-/// Composite the rendered background layers behind the mark. This observes the113-/// color itself rather than a theme label, so custom dark colors work in either mode.114-function logoBackground() {115- let node = document.querySelector('.brand');116- let alpha = 0;117- const premultiplied = [0, 0, 0];118- while (node instanceof Element) {119- const layer = parseComputedColor(getComputedStyle(node).backgroundColor);120- if (layer && layer.alpha > 0) {121- for (let channel = 0; channel < 3; channel += 1) {122- premultiplied[channel] += layer.rgb[channel] * layer.alpha * (1 - alpha);123- }124- alpha += layer.alpha * (1 - alpha);125- if (alpha >= 0.999) break;126- }127- node = node.parentElement;128- }129- return premultiplied.map((channel) => channel + 255 * (1 - alpha));130-}131-132-function updateLogoContrast() {133- if (!$('dev-logo-ring')) return;134- const luminance = logoBackground()135- .map((channel) => channel / 255)136- .map((channel) => channel <= 0.04045137- ? channel / 12.92138- : ((channel + 0.055) / 1.055) ** 2.4)139- .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);140- document.documentElement.style.setProperty(141- '--devsite-logo-ring',142- luminance < 0.179 ? 'rgb(255,255,255)' : 'rgb(0,0,0)',143- );144-}145-146-// -- rendering -----------------------------------------------------------------147-148-function renderSession() {149- const session = $('session');150- if (!me) {151- session.innerHTML = '<li><a href="#" id="signin" role="button" class="outline">Sign in</a></li>';152- $('signin').addEventListener('click', (e) => { e.preventDefault(); startSignIn(); });153- return;154- }155- session.innerHTML = me.handle156- ? `<li><a href="/@${esc(me.handle)}">@${esc(me.handle)}</a></li>`157- : '<li><small>no handle yet</small></li>';158-}159-160-/// One row of a profile.161-///162-/// Everything on a profile is a site. The only difference between them is how you163-/// get there: a link is reached at its own address, so it is an anchor and takes164-/// you away; a service is reached through its owner's daemon, so it is a button165-/// that opens its ticket prompt here. That is the whole of the distinction, and166-/// the row says it with an arrow rather than by sorting the page into kinds.167-///168-/// A service carries no reachability state. Nothing on this page knows whether169-/// the far end is running — the control plane is not told, and the only thing170-/// that could answer is the CLI's later connection attempt. The browser only171-/// mints a ticket, so the row makes no reachability claim.172-function siteRow(item, { onClick, from } = {}) {173- const li = document.createElement('li');174- li.className = 'entry';175- li.dataset.kind = item.kind;176- li.dataset.visibility = item.visibility;177-178- const name = esc(item.name)179- + (from ? ` <small>from @${esc(from)}</small>` : '');180-181- if (item.kind === 'link') {182- const note = item.visibility === 'public' ? '' : `${esc(item.visibility)} - `;183- li.innerHTML =184- `<a href="${esc(item.url)}" target="_blank" rel="ugc nofollow noopener noreferrer">${name}</a>` +185- `<small class="state">${note}${linkHost(item)}</small>`;186- return li;187- }188-189- // Visibility is worth saying only where it is not the default. On someone190- // else's profile it also explains why you can see a thing at all.191- const note = item.visibility === 'public' ? '' : esc(item.visibility);192- li.innerHTML = `<button class="outline">${name}</button>`193- + (note ? `<small class="state">${note}</small>` : '');194- if (onClick) li.querySelector('button').addEventListener('click', onClick);195- return li;196-}197-198-/// Where a link goes: always in the markup, revealed one row at a time.199-///200-/// Every link carries its host, including the ones named after it. The arrow is201-/// what you see at rest; pointing at a row expands the host leftward out of it.202-/// So `github.com` is there on both repos without two rows saying the same thing203-/// at once, and a link named `klot.ski` is not silently missing information the204-/// others have — it is just never showing it and its neighbour at the same time.205-///206-/// The nested span is what makes it animate: the outer element is an inline grid207-/// whose single column goes 0fr → 1fr, which transitions to exactly the content's208-/// width without anyone having to guess a max-width. The trailing space lives209-/// inside the clipped span so it collapses with the text rather than leaving a210-/// gap before the arrow.211-function linkHost(item) {212- const host = esc(new URL(item.url).host);213- return `<span class="host"><span>${host}&nbsp;</span></span>↗`;214-}215-216-function entryList(items, options = {}) {217- const list = document.createElement('ul');218- list.className = 'entries';219- for (const item of items) {220- list.append(siteRow(item, {221- ...options,222- from: item.owner_handle,223- onClick: () => showServiceTicketDialog(item),224- }));225- }226- return list;227-}228-229-/// The folders on a profile, in the order they first appear.230-///231-/// Derived from the entries rather than stored anywhere: a folder is a name232-/// repeated across the sites that share it, so this is the only place the set of233-/// them exists. A folder that a visitor may see nothing inside does not appear at234-/// all, because it never gets built.235-function foldersOf(entries) {236- const folders = new Map();237- for (const item of entries) {238- if (!item.folder) continue;239- if (!folders.has(item.folder)) folders.set(item.folder, []);240- folders.get(item.folder).push(item);241- }242- return folders;243-}244-245-/// Validated layout declarations arrive in canonical JSON-string-list form.246-/// Parsing them again here is assembly rather than trust: malformed values can247-/// only mean an older/newer server mismatch, in which case the safe default is248-/// the original all-open, first-appearance layout.249-function profileLayout(declarations = []) {250- const value = (property) =>251- declarations.find((declaration) => declaration.property === property)?.value;252- const names = (property) => {253- const list = value(property);254- if (!list) return [];255- try {256- const parsed = JSON.parse(`[${list}]`);257- return Array.isArray(parsed) && parsed.every((name) => typeof name === 'string')258- ? parsed259- : [];260- } catch {261- return [];262- }263- };264- return {265- foldersOpen: value('--devsite-folders') !== 'closed',266- openFolders: new Set(names('--devsite-open-folders')),267- folderOrder: names('--devsite-folder-order'),268- };269-}270-271-/// Named folders come first in the requested order. A name that does not exist272-/// for this visitor is ignored, and unlisted visible folders retain the order in273-/// which they first appeared.274-function orderedFolders(folders, order) {275- const result = [];276- const placed = new Set();277- for (const name of order) {278- if (folders.has(name)) {279- result.push([name, folders.get(name)]);280- placed.add(name);281- }282- }283- for (const entry of folders) {284- if (!placed.has(entry[0])) result.push(entry);285- }286- return result;287-}288-289-/// One semantic fold. Layout declarations choose only its initial state; once290-/// rendered, the reader owns the ordinary `<details>` interaction.291-function folder(name, items, layout) {292- const details = document.createElement('details');293- details.className = 'folder';294- details.open = layout.foldersOpen || layout.openFolders.has(name);295- details.innerHTML =296- `<summary>${esc(name)} <small>${items.length}</small></summary>`;297- details.append(entryList(items));298- return details;299-}300-301-function renderProfile(profile) {302- setPageTitle(`@${profile.handle}`);303- applyTheme(profile.handle, profile.theme);304- const layout = profileLayout(profile.theme);305- main.innerHTML = '';306-307- const article = document.createElement('article');308- article.id = 'profile';309-310- // Accepted shares are ordinary service rows once they reach the profile. Their311- // owner annotation preserves provenance without making sharing a top-level section.312- const entries = [...profile.entries, ...profile.shared_with_me];313- const total = entries.length;314- const heading = document.createElement('hgroup');315- heading.innerHTML = `<h1>@${esc(profile.handle)}</h1>`316- + `<p>${total === 1 ? '1 site' : `${total} sites`}</p>`;317- article.append(heading);318-319- // Loose sites first, in the order they were published, then a fold per folder320- // in the order those first appear. No sections by kind or by visibility: a321- // profile is a list of sites, and sorting it into "links" and "services" would322- // be publishing an implementation detail as a heading. Folders are different —323- // they are the owner's own grouping, and they say so in the owner's words.324- if (total) {325- const loose = entries.filter((item) => !item.folder);326- if (loose.length) article.append(entryList(loose));327-328- for (const [name, items] of orderedFolders(foldersOf(entries), layout.folderOrder)) {329- article.append(folder(name, items, layout));330- }331- } else {332- const empty = document.createElement('p');333- empty.innerHTML = '<small>Nothing published yet.</small>';334- article.append(empty);335- }336-337- main.append(article);338-}339-340-// -- service tickets -----------------------------------------------------------341-342-/// Pico's modal convention: the `open` attribute on the dialog, and a lock class343-/// on the document while it is up.344-function openServiceTicketDialog() {345- document.documentElement.classList.add('modal-is-open', 'modal-is-opening');346- $('service-ticket-dialog').setAttribute('open', '');347- setTimeout(() => document.documentElement.classList.remove('modal-is-opening'), 400);348-}349-350-function closeServiceTicketDialog() {351- document.documentElement.classList.remove('modal-is-open', 'modal-is-opening');352- $('service-ticket-dialog').removeAttribute('open');353- $('service-ticket-body').replaceChildren();354-}355-356-function maskedSecret(secret) {357- const prefix = String(secret).match(/^[a-z]+_/)?.[0] || '';358- return `${prefix}***`;359-}360-361-/// The one presentation for secrets handed to the CLI. Only the prefix and a362-/// mask enter the DOM; the full command stays in this closure for clipboard use.363-function renderSecretCommand(container, verb, secret) {364- const command = `devsite ${verb} ${secret}`;365- const group = document.createElement('div');366- group.className = 'secret-command';367- group.setAttribute('role', 'group');368- group.setAttribute('aria-label', `devsite ${verb} command`);369-370- const code = document.createElement('code');371- const executable = document.createElement('span');372- executable.className = 'command-executable';373- executable.textContent = 'devsite';374- const action = document.createElement('span');375- action.className = 'command-verb';376- action.textContent = verb;377- const masked = document.createElement('span');378- masked.className = 'command-secret';379- masked.textContent = maskedSecret(secret);380- code.append(executable, ' ', action, ' ', masked);381- const button = document.createElement('button');382- button.type = 'button';383- button.textContent = 'Copy';384-385- const status = document.createElement('small');386- status.className = 'secret-copy-status';387- status.setAttribute('aria-live', 'polite');388- button.addEventListener('click', async () => {389- await navigator.clipboard.writeText(command);390- status.textContent = 'Copied.';391- });392-393- group.append(code, button);394- container.replaceChildren(group, status);395-}396-397-function renderServiceTicketPrompt(resourceId, container, status) {398- container.innerHTML = `399- <p>This is a private TCP service. Mint a short-lived ticket, then connect it to a loopback port with the CLI.</p>400- <button type="button" class="get-ticket">Get ticket</button>`;401- status.textContent = '';402-403- container.querySelector('.get-ticket').addEventListener('click', async (event) => {404- const button = event.currentTarget;405- button.setAttribute('aria-busy', 'true');406- status.textContent = '';407- try {408- const result = await api(`/api/services/${encodeURIComponent(resourceId)}/ticket`, {409- method: 'POST',410- body: '{}',411- });412- container.innerHTML = `413- <p>This ticket can be redeemed once and expires shortly.</p>414- <div class="secret-command-slot"></div>`;415- renderSecretCommand(container.querySelector('.secret-command-slot'), 'connect', result.ticket);416- } catch (err) {417- button.removeAttribute('aria-busy');418- status.textContent = err.message;419- }420- });421-}422-423-function showServiceTicketDialog(item) {424- $('service-ticket-title').textContent = item.owner_handle425- ? `${item.name} — @${item.owner_handle}`426- : item.name;427- renderServiceTicketPrompt(428- item.resource_id,429- $('service-ticket-body'),430- $('service-ticket-status'),431- );432- openServiceTicketDialog();433-}434-435-// -- setup views ---------------------------------------------------------------436-437-function prefersHomebrewInstall() {438- const platform = navigator.userAgentData?.platform || navigator.platform || '';439- return /mac/i.test(platform) || /Macintosh|Mac OS X/i.test(navigator.userAgent);440-}441-442-function installControlMarkup() {443- return prefersHomebrewInstall()444- ? `<fieldset role="group">445- <input id="install-command" aria-label="Homebrew install command"446- value="brew install FelineStateMachine/tap/devsite" readonly>447- <button id="copy-install" type="button">Copy</button>448- </fieldset>`449- : `<fieldset role="group">450- <input aria-label="Latest binary release"451- value="GitHub · latest devsite binary release" readonly>452- <a href="https://github.com/FelineStateMachine/devsite/releases/latest"453- role="button">Download</a>454- </fieldset>`;455-}456-457-function bindInstallControl() {458- const installCommand = $('install-command');459- const copyInstall = $('copy-install');460- if (!installCommand || !copyInstall) return;461-462- let copyReset;463- copyInstall.addEventListener('click', async () => {464- try {465- await navigator.clipboard.writeText(installCommand.value);466- copyInstall.textContent = 'Copied';467- clearTimeout(copyReset);468- copyReset = setTimeout(() => { copyInstall.textContent = 'Copy'; }, 1400);469- } catch {470- copyInstall.textContent = 'Select';471- installCommand.select();472- }473- });474-}475-476-const CLI_VERBS = new Set([477- 'access', 'connect', 'daemon', 'grant', 'host', 'link', 'login', 'request',478- 'resolve', 'run', 'service', 'set',479-]);480-481-/// Lightweight highlighting for the static examples below. This deliberately482-/// understands only devsite's command vocabulary: it is presentation, not a483-/// shell parser, and preserving the original text keeps selection/copy honest.484-function highlightCliExamples(container) {485- container.querySelectorAll('pre > code').forEach((code) => {486- const source = code.textContent;487- if (!source.trimStart().startsWith('devsite ')) return;488-489- const highlighted = document.createDocumentFragment();490- let wordIndex = 0;491- source.split(/(\s+)/).forEach((token) => {492- if (!token || /^\s+$/.test(token)) {493- highlighted.append(token);494- if (token.includes('\n')) wordIndex = 0;495- return;496- }497-498- const span = document.createElement('span');499- if (wordIndex === 0 && token === 'devsite') span.className = 'command-executable';500- else if (wordIndex <= 2 && CLI_VERBS.has(token)) span.className = 'command-verb';501- else if (token.startsWith('--')) span.className = 'command-option';502- else if (/^(?:dmt|dsp|dss|dst)_/.test(token)) span.className = 'command-secret';503- else span.className = 'command-value';504- span.textContent = token;505- highlighted.append(span);506- wordIndex += 1;507- });508- code.replaceChildren(highlighted);509- });510-}511-512-function renderSignedOut() {513- setPageTitle('home');514- clearTheme();515- document.querySelector('.brand strong').hidden = true;516- main.innerHTML = `517- <article>518- <hgroup>519- <h1 aria-label="dev.site"><span aria-hidden="true"> █ ▀ ▄520- ▄▄▄█ ▄▄▄ ▄ ▄ ▄▄▄ ▄▄▄ ▄▄█▄▄ ▄▄▄521-█▀ ▀█ █▀ █ ▀▄ ▄▀ █ ▀ █ █ █▀ █522-█ █ █▀▀▀▀ █▄█ ▀▀▀▄ █ █ █▀▀▀▀523-▀█▄██ ▀█▄▄▀ █ █ ▀▄▄▄▀ ▄▄█▄▄ ▀▄▄ ▀█▄▄▀</span></h1>524- <p>links and services across all of your development sites</p>525- </hgroup>526- <figure class="sequence">527- <figcaption>authorization only - no service bytes</figcaption>528- <header aria-hidden="true">529- <strong>host cli</strong>530- <strong>dev.site</strong>531- <strong>approved user</strong>532- </header>533- <div>534- <i aria-hidden="true"></i><i aria-hidden="true"></i><i aria-hidden="true"></i>535- <ol aria-label="A host proves control of and registers its bound endpoint, then receives current service authorizations from dev.site. An approved user redeems a one-use ticket into a client-bound session, requests a signed capability for each connection, then opens an end-to-end encrypted Iroh QUIC stream to the host service.">536- <li><span>prove + register bound endpoint</span></li>537- <li><span>sync authorization snapshot</span></li>538- <li><span>redeem one-use ticket + client key</span></li>539- <li><span>client-bound session</span></li>540- <li><span>request one-stream capability</span></li>541- <li><span>signed capability + daemon id</span></li>542- <li><span>end-to-end encrypted Iroh QUIC</span></li>543- </ol>544- </div>545- </figure>546- </article>547-548- <section aria-labelledby="install-title">549- <h2 id="install-title">Install</h2>550- ${installControlMarkup()}551- </section>552-553- <hr>554-555- <details>556- <summary>Overview</summary>557- <p>Store useful URLs, shared services, and temporary access</p>558- <article>559- <h3>Machine Enrollment</h3>560- <pre><code>devsite login dmt_***</code></pre>561- <blockquote>A single-use ticket creates a revocable, identity-bound machine credential</blockquote>562- </article>563- <article>564- <h3>Add Links to your site</h3>565- <pre><code>devsite link set --name docs --url https://docs.example.com --public</code></pre>566- <blockquote>List a public, private, shared URLs on your page</blockquote>567- </article>568- <article>569- <h3>Share your Services</h3>570- <pre><code>devsite service host 5432 --name postgres</code></pre>571- <blockquote>572- <p>Access any local TCP port. never publicly accessible</p>573- <p>Service is accessed anywhere with a single use ticket.</p>574- </blockquote>575- <pre><code>devsite connect dst_***</code></pre>576- </article>577- <article>578- <h3>Delegate Granular Access</h3>579- <blockquote><p>A sandboxed agent can request a service by keyword. The request is matched to an accessible service, and a scoped grant is presented for approval.</p>580- <p>The requester receives only a short-lived grant bound to the selected581- service and its temporary endpoint.</p></blockquote>582- <pre><code>devsite access request postgres --request request.json --key requester.key</code></pre>583- <blockquote>Resolution is limited to services available to the approving account. Grants584- are short-lived and revocable.</blockquote>585- <pre><code>devsite access resolve postgres586-devsite access grant --request request.json --plan587-devsite access grant --request request.json --approved-plan dsp_***</code></pre>588- <blockquote>The requester opens an on-demand, endpoint-bound connection to the selected service.</blockquote>589- <pre><code>devsite access connect dss_*** --key requester.key</code></pre>590- </article>591- <article>592- <h3>Persist the Daemon</h3>593- <pre><code>devsite daemon run</code></pre>594- <blockquote>autorun it with Homebrew services or systemd to keep services available for595- on-demand connections</blockquote>596- </article>597- </details>598-599- <details>600- <summary>Important Concepts</summary>601- <p>Tickets, keys, and the ALPN tell the story of how it works</p>602- <nav aria-label="Security concept guides">603- <ul>604- <li><a href="/concepts.html#tickets">Tickets</a></li>605- <li><a href="/concepts.html#keys">Keys</a></li>606- <li><a href="/concepts.html#protocol">ALPN</a></li>607- </ul>608- </nav>609- </details>`;610-611- pageFooter.innerHTML = `612- <nav aria-label="Footer">613- <ul><li><a href="https://github.com/FelineStateMachine/devsite"614- target="_blank" rel="noopener">GitHub</a></li></ul>615- </nav>`;616- pageFooter.hidden = false;617- bindInstallControl();618- highlightCliExamples(main);619-}620-621-function renderClaimHandle() {622- setPageTitle('dashboard');623- clearTheme();624- main.innerHTML = `625- <article>626- <hgroup>627- <h1>Choose a handle.</h1>628- <p>It becomes the address of your profile. Letters, digits, hyphens and underscores.</p>629- </hgroup>630- <form id="claim-form">631- <fieldset role="group">632- <input id="handle" name="handle" placeholder="yourhandle" autocomplete="off"633- spellcheck="false" aria-label="Handle" required>634- <button type="submit">Claim</button>635- </fieldset>636- <small id="claim-note"></small>637- </form>638- </article>`;639-640- $('claim-form').addEventListener('submit', async (e) => {641- e.preventDefault();642- const field = $('handle');643- const note = $('claim-note');644- try {645- const result = await api('/api/profile', {646- method: 'POST',647- body: JSON.stringify({ handle: field.value }),648- });649- location.assign('/');650- } catch (err) {651- field.setAttribute('aria-invalid', 'true');652- note.innerHTML = `<span class="error">${esc(err.message)}</span>`;653- }654- });655-}656-657-function formatTime(seconds) {658- return seconds ? new Date(seconds * 1000).toLocaleString() : 'never';659-}660-661-function resourceControl(resource) {662- const item = document.createElement('li');663- const shares = resource.shares || (resource.shared_with || [])664- .map((handle) => ({ handle, status: 'accepted' }));665- item.innerHTML = `666- <div>667- <strong>${esc(resource.name)}</strong>668- <small>${esc(resource.kind)} - ${esc(resource.visibility)}</small>669- </div>670- <div class="dashboard-actions"></div>`;671- const actions = item.querySelector('.dashboard-actions');672- for (const share of shares) {673- const button = document.createElement('button');674- button.className = 'secondary outline revoke-share';675- button.type = 'button';676- button.textContent = `Revoke @${share.handle} - ${share.status}`;677- button.addEventListener('click', async () => {678- button.setAttribute('aria-busy', 'true');679- try {680- await api(`/api/resources/${encodeURIComponent(resource.resource_id)}/shares`, {681- method: 'PUT',682- body: JSON.stringify({683- share_with: shares684- .filter((value) => value.handle !== share.handle)685- .map((value) => value.handle),686- }),687- });688- await showDashboard();689- } catch (err) {690- button.removeAttribute('aria-busy');691- button.setAttribute('aria-invalid', 'true');692- button.title = err.message;693- }694- });695- actions.append(button);696- }697- if (!shares.length) actions.innerHTML = '<small>No individual shares</small>';698- return item;699-}700-701-function incomingShareControl(share) {702- const item = document.createElement('li');703- const destination = share.url704- ? ` - <span data-selectable>${esc(share.url)}</span>`705- : '';706- item.innerHTML = `707- <div>708- <strong>${esc(share.name)}</strong>709- <small>from @${esc(share.owner_handle)} - ${esc(share.kind)}${destination}</small>710- </div>711- <div class="dashboard-actions"></div>`;712- const actions = item.querySelector('.dashboard-actions');713-714- if (share.status === 'pending') {715- const accept = document.createElement('button');716- accept.type = 'button';717- accept.textContent = 'Accept';718- accept.addEventListener('click', async () => {719- accept.setAttribute('aria-busy', 'true');720- try {721- await api(`/api/share-invitations/${encodeURIComponent(share.resource_id)}/accept`, {722- method: 'POST',723- });724- await showDashboard();725- } catch (err) {726- accept.removeAttribute('aria-busy');727- accept.setAttribute('aria-invalid', 'true');728- accept.title = err.message;729- }730- });731- actions.append(accept);732- }733-734- const decline = document.createElement('button');735- decline.className = 'secondary outline';736- decline.type = 'button';737- decline.textContent = share.status === 'pending' ? 'Decline' : 'Remove';738- decline.addEventListener('click', async () => {739- decline.setAttribute('aria-busy', 'true');740- try {741- await api(`/api/share-invitations/${encodeURIComponent(share.resource_id)}`, {742- method: 'DELETE',743- });744- await showDashboard();745- } catch (err) {746- decline.removeAttribute('aria-busy');747- decline.setAttribute('aria-invalid', 'true');748- decline.title = err.message;749- }750- });751- actions.append(decline);752- return item;753-}754-755-function credentialControl(credential) {756- const item = document.createElement('li');757- const scopes = credential.scopes?.length758- ? ` · ${credential.scopes.map((scope) => esc(scope)).join(', ')}`759- : '';760- item.innerHTML = `761- <div>762- <strong>${esc(credential.name)}</strong>763- <small>last used ${esc(formatTime(credential.last_used_at))}${scopes}</small>764- </div>`;765- const button = document.createElement('button');766- button.className = 'secondary outline';767- button.type = 'button';768- button.textContent = 'Revoke';769- button.addEventListener('click', async () => {770- button.setAttribute('aria-busy', 'true');771- try {772- await api(`/api/machine-credentials/${encodeURIComponent(credential.id)}`, {773- method: 'DELETE',774- });775- await showDashboard();776- } catch (err) {777- button.removeAttribute('aria-busy');778- button.setAttribute('aria-invalid', 'true');779- button.title = err.message;780- }781- });782- item.append(button);783- return item;784-}785-786-async function signOut(button) {787- button.setAttribute('aria-busy', 'true');788- try {789- await api('/api/auth/session', { method: 'DELETE' });790- } catch (err) {791- button.removeAttribute('aria-busy');792- button.setAttribute('aria-invalid', 'true');793- button.title = err.message;794- return;795- }796-797- me = null;798- history.replaceState({}, '', '/');799- clearTheme();800- renderSession();801- renderSignedOut();802-}803-804-async function showDashboard(newCredential = null) {805- setPageTitle('dashboard');806- clearTheme();807- main.setAttribute('aria-busy', 'true');808- const [resourceListing, incomingListing, credentialListing, settings] = await Promise.all([809- api('/api/resources'),810- api('/api/share-invitations'),811- api('/api/machine-credentials'),812- api('/api/profile/settings'),813- ]);814-815- main.innerHTML = `816- <hgroup>817- <h1>@${esc(me.handle)}</h1>818- <p>Manage what your profile reveals and which machines may change it.</p>819- </hgroup>820-821- <article>822- <header><strong>Profile</strong></header>823- <p><a href="/@${esc(me.handle)}">View your profile</a></p>824- <label>825- <input id="private-only" type="checkbox" role="switch"826- ${settings.private_only ? 'checked' : ''}>827- Private-only profile828- </label>829- <small>When enabled, only you can open the profile page. Services accepted by other830- people still appear as ordinary entries on their own profiles.</small>831- <p id="profile-settings-status"></p>832- </article>833-834- <article>835- <details>836- <summary><strong>Incoming shares</strong></summary>837- <p>Nothing is added to your profile until you accept it. Link destinations are shown838- as text so you can inspect them without opening them.</p>839- <ul class="dashboard-list" id="incoming-shares"></ul>840- </details>841- </article>842-843- <article>844- <details>845- <summary><strong>Shared by you</strong></summary>846- <ul class="dashboard-list" id="dashboard-resources"></ul>847- </details>848- </article>849-850- <article>851- <details${newCredential ? ' open' : ''}>852- <summary><strong>Machine credentials</strong></summary>853- <p>Create a single-use ticket that enrolls one Ed25519 machine identity.</p>854- <form id="credential-form">855- <fieldset role="group">856- <input id="credential-name" name="name" maxlength="60"857- placeholder="This MacBook" aria-label="Machine name" required>858- <button type="submit">Create</button>859- </fieldset>860- <label>861- <input id="credential-service-grants" type="checkbox">862- Allow this machine to issue endpoint-bound service grants863- </label>864- <small>This authority is server-enforced and can be removed by revoking the865- machine credential.</small>866- <small id="credential-note"></small>867- </form>868- <div id="new-machine-token"></div>869- <ul class="dashboard-list" id="machine-credentials"></ul>870- </details>871- </article>872-873- <article aria-labelledby="install-title">874- <header id="install-title">Install</header>875- ${installControlMarkup()}876- </article>877-878- <article>879- <header><strong>Session</strong></header>880- <button id="logout" type="button" class="secondary outline">Log out</button>881- </article>`;882-883- const resources = $('dashboard-resources');884- if (resourceListing.resources.length) {885- resourceListing.resources.forEach((resource) => resources.append(resourceControl(resource)));886- } else {887- resources.innerHTML = '<li><small>No resources yet. Add one with the CLI.</small></li>';888- }889-890- const incoming = $('incoming-shares');891- if (incomingListing.shares.length) {892- incomingListing.shares.forEach((share) => incoming.append(incomingShareControl(share)));893- } else {894- incoming.innerHTML = '<li><small>No incoming shares.</small></li>';895- }896-897- const credentials = $('machine-credentials');898- if (credentialListing.credentials.length) {899- credentialListing.credentials.forEach((credential) =>900- credentials.append(credentialControl(credential)));901- } else {902- credentials.innerHTML = '<li><small>No machine credentials yet.</small></li>';903- }904-905- if (newCredential) {906- $('new-machine-token').innerHTML = `907- <p><strong>${esc(newCredential.name)}</strong> is ready.</p>908- <div class="secret-command-slot"></div>`;909- renderSecretCommand(910- $('new-machine-token').querySelector('.secret-command-slot'),911- 'login',912- newCredential.ticket,913- );914- }915-916- $('logout').addEventListener('click', (event) => signOut(event.currentTarget));917- bindInstallControl();918-919- $('private-only').addEventListener('change', async (event) => {920- const input = event.currentTarget;921- const status = $('profile-settings-status');922- input.setAttribute('aria-busy', 'true');923- try {924- await api('/api/profile/settings', {925- method: 'PUT',926- body: JSON.stringify({ private_only: input.checked }),927- });928- status.innerHTML = '<small class="ok">Saved.</small>';929- } catch (err) {930- input.checked = !input.checked;931- status.innerHTML = `<small class="error">${esc(err.message)}</small>`;932- } finally {933- input.removeAttribute('aria-busy');934- }935- });936-937- $('credential-form').addEventListener('submit', async (event) => {938- event.preventDefault();939- const button = event.currentTarget.querySelector('button');940- const note = $('credential-note');941- button.setAttribute('aria-busy', 'true');942- try {943- const created = await api('/api/machine-credentials', {944- method: 'POST',945- body: JSON.stringify({946- name: $('credential-name').value,947- scopes: $('credential-service-grants').checked ? ['service_grants:issue'] : [],948- }),949- });950- await showDashboard({ name: created.credential.name, ticket: created.ticket });951- } catch (err) {952- button.removeAttribute('aria-busy');953- note.innerHTML = `<span class="error">${esc(err.message)}</span>`;954- }955- });956-957- main.removeAttribute('aria-busy');958-}959-960-// -- routing -------------------------------------------------------------------961-962-async function route() {963- const path = location.pathname;964- setPageTitle();965-966- try {967- me = await api('/api/me');968- } catch {969- me = null;970- }971- renderSession();972-973- const handleMatch = path.match(/^\/@([^/]+)$/);974- if (handleMatch) {975- await showProfile(decodeURIComponent(handleMatch[1]));976- return;977- }978-979- const serviceMatch = path.match(/^\/s\/(res_[a-z2-7]+)$/);980- if (serviceMatch) {981- clearTheme();982- main.innerHTML = `983- <article>984- <hgroup>985- <h1>Private TCP service</h1>986- <p>Access is checked for each connection.</p>987- </hgroup>988- <div id="service-connect"></div>989- <p id="service-status"></p>990- <p><small>The CLI opens a loopback port and carries its byte stream through Iroh.</small></p>991- </article>`;992- if (me) {993- renderServiceTicketPrompt(serviceMatch[1], $('service-connect'), $('service-status'));994- } else {995- $('service-connect').innerHTML = '<p>Sign in from the header to request access.</p>';996- }997- return;998- }999-1000- if (!me) { renderSignedOut(); return; }1001- if (!me.handle) { renderClaimHandle(); return; }1002- await showDashboard();1003-}1004-1005-async function showProfile(handle) {1006- try {1007- renderProfile(await api(`/api/profile/${encodeURIComponent(handle)}`));1008- } catch (err) {1009- clearTheme();1010- main.innerHTML = `1011- <article>1012- <hgroup>1013- <h1>No such profile.</h1>1014- <p>@${esc(handle)} ${esc(err.message === '404' ? 'does not exist' : err.message)}</p>1015- </hgroup>1016- </article>`;1017- }1018-}1019-1020-$('service-ticket-close').addEventListener('click', closeServiceTicketDialog);1021-$('service-ticket-dialog').addEventListener('click', (event) => {1022- if (event.target === $('service-ticket-dialog')) closeServiceTicketDialog();1023-});1024-document.addEventListener('keydown', (event) => {1025- if (event.key === 'Escape') closeServiceTicketDialog();1026-});1027-1028-updateLogoContrast();1029-matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () =>1030- requestAnimationFrame(updateLogoContrast));1031-main.removeAttribute('aria-busy');1032-await route();
A web/app.spec.ts +136 -0
@@ -0,0 +1,136 @@
1+import { readFile } from 'node:fs/promises';2+import { extname, join } from 'node:path';3+import { expect, test, type Route } from '@playwright/test';4+5+const webRoot = join(process.cwd(), 'web');6+7+const contentTypes: Record<string, string> = {8+ '.css': 'text/css',9+ '.html': 'text/html',10+ '.js': 'text/javascript',11+ '.woff2': 'font/woff2',12+};13+14+async function staticResponse(route: Route, pathname: string) {15+ const relativePath = pathname === '/' || pathname.startsWith('/@')16+ ? 'index.html' : pathname.replace(/^\//, '');17+ if (relativePath.includes('..')) {18+ await route.fulfill({ status: 404 });19+ return;20+ }21+ const body = await readFile(join(webRoot, relativePath));22+ await route.fulfill({23+ body,24+ contentType: contentTypes[extname(relativePath)] ?? 'application/octet-stream',25+ });26+}27+28+function sseEvent(data: string, event?: string, id?: string): string {29+ return `${event ? `event: ${event}\n` : ''}${id ? `id: ${id}\n` : ''}`30+ + `${data.split('\n').map((line) => `data: ${line}\n`).join('')}\n`;31+}32+33+test('an incoming share uses the Fixi UI endpoint', async ({ page }) => {34+ let acceptRequests = 0;35+ let removeRequests = 0;36+ await page.route('https://dev.site.test/**', async (route) => {37+ const request = route.request();38+ const pathname = new URL(request.url()).pathname;39+ const json = async (value: unknown) => route.fulfill({ json: value });40+41+ if (pathname === '/api/me') {42+ await json({ account_id: 'acct_test', handle: 'alice' });43+ } else if (pathname === '/api/resources') {44+ await json({ resources: [] });45+ } else if (pathname === '/api/share-invitations') {46+ await json({47+ shares: [{48+ kind: 'link',49+ name: 'runbook',50+ owner_handle: 'bob',51+ resource_id: 'res_test',52+ status: 'pending',53+ url: 'https://example.com/runbook',54+ }],55+ });56+ } else if (pathname === '/api/machine-credentials') {57+ await json({ credentials: [] });58+ } else if (pathname === '/api/profile/settings') {59+ await json({ private_only: false });60+ } else if (pathname === '/ui/share-invitations/res_test/accept') {61+ acceptRequests += 1;62+ expect(request.method()).toBe('POST');63+ expect(request.headers()['fx-request']).toBe('true');64+ await route.fulfill({65+ body: `<li><div><strong>runbook</strong><small>from @bob - link</small></div>66+ <div class="dashboard-actions"><button class="secondary outline" type="button"67+ fx-action="/ui/share-invitations/res_test" fx-method="DELETE"68+ fx-target="#incoming-shares" fx-swap="innerHTML">Remove</button></div></li>`,69+ contentType: 'text/html',70+ });71+ } else if (pathname === '/ui/share-invitations/res_test') {72+ removeRequests += 1;73+ expect(request.method()).toBe('DELETE');74+ expect(request.headers()['fx-request']).toBe('true');75+ await route.fulfill({76+ body: '<li><small>No incoming shares.</small></li>',77+ contentType: 'text/html',78+ });79+ } else {80+ await staticResponse(route, pathname);81+ }82+ });83+84+ await page.goto('https://dev.site.test/');85+ await expect(page.getByRole('heading', { name: '@alice' })).toBeVisible();86+ await page.getByText('Incoming shares', { exact: true }).click();87+ await page.getByRole('button', { name: 'Accept' }).click();88+ await expect(page.getByRole('button', { name: 'Remove' })).toBeVisible();89+ expect(acceptRequests).toBe(1);90+91+ await page.getByRole('button', { name: 'Remove' }).click();92+93+ await expect(page.getByText('No incoming shares.')).toBeVisible();94+ expect(removeRequests).toBe(1);95+});96+97+test('a profile hydrates and updates from one SSEXi response', async ({ page }) => {98+ let streamRequests = 0;99+ await page.route('https://dev.site.test/**', async (route) => {100+ const pathname = new URL(route.request().url()).pathname;101+ if (pathname === '/api/me') {102+ await route.fulfill({ status: 204 });103+ } else if (pathname === '/ui/profile/alice/stream') {104+ streamRequests += 1;105+ const themeEvent = JSON.stringify({ target: '#profile-theme', swap: 'textContent' });106+ const initial = '<main class="container" id="main" data-profile-handle="alice" '107+ + 'data-profile-scheme="dark"><article id="profile"><hgroup><h1>@alice</h1>'108+ + '<p>1 site</p></hgroup><ul class="entries"><li class="entry" id="site-res_one" '109+ + 'data-kind="link" data-visibility="public"><a href="https://one.example">one</a>'110+ + '</li></ul></article></main>';111+ const updated = '<main class="container" id="main" data-profile-handle="alice" '112+ + 'data-profile-scheme="dark"><article id="profile"><hgroup><h1>@alice</h1>'113+ + '<p>2 sites</p></hgroup><ul class="entries"><li class="entry" id="site-res_one" '114+ + 'data-kind="link" data-visibility="public"><a href="https://one.example">one</a>'115+ + '</li><li class="entry" id="site-res_two" data-kind="link" '116+ + 'data-visibility="public"><a href="https://two.example">two</a></li></ul>'117+ + '</article></main>';118+ await route.fulfill({119+ body: sseEvent(':root[data-profile="alice"] { --pico-primary: rebeccapurple; }', themeEvent, '1')120+ + sseEvent(initial, undefined, '1')121+ + sseEvent(updated, undefined, '2'),122+ contentType: 'text/event-stream',123+ });124+ } else {125+ await staticResponse(route, pathname);126+ }127+ });128+129+ await page.goto('https://dev.site.test/@alice');130+ await expect(page.getByRole('heading', { name: '@alice' })).toBeVisible();131+ await expect(page.getByText('2 sites')).toBeVisible();132+ await expect(page.getByRole('link', { name: 'two' })).toBeVisible();133+ await expect(page.locator('html[data-profile="alice"][data-theme="dark"]')).toBeAttached();134+ expect(await page.locator('#profile-theme').textContent()).toContain('rebeccapurple');135+ expect(streamRequests).toBe(1);136+});
A web/app.ts +75 -0
@@ -0,0 +1,75 @@
1+import { renderClaimHandle, showDashboard } from './dashboard';2+import { renderSignedOut } from './home';3+import { showProfile } from './profile';4+import { bindServiceDialogs, showServiceRoute } from './services';5+import { api, element, escapeHtml, type Me, setPageTitle } from './shared';6+import { clearTheme, watchLogoContrast } from './theme';7+8+const main = element('main');9+let me: Me | null = null;10+11+function startSignIn() {12+ const returnTo = `${location.pathname}${location.search}`;13+ location.assign(`/auth/start?return_to=${encodeURIComponent(returnTo)}`);14+}15+16+function renderSession() {17+ const session = element('session');18+ if (!me) {19+ session.innerHTML = '<li><a href="#" id="signin" role="button" class="outline">Sign in</a></li>';20+ element('signin').addEventListener('click', (event) => {21+ event.preventDefault();22+ startSignIn();23+ });24+ return;25+ }26+ session.innerHTML = me.handle27+ ? `<li><a href="/@${escapeHtml(me.handle)}">@${escapeHtml(me.handle)}</a></li>`28+ : '<li><small>no handle yet</small></li>';29+}30+31+function showSignedOutHome() {32+ me = null;33+ history.replaceState({}, '', '/');34+ clearTheme();35+ renderSession();36+ renderSignedOut();37+}38+39+async function route() {40+ const path = location.pathname;41+ setPageTitle();42+ try {43+ me = await api<Me | null>('/api/me');44+ } catch {45+ me = null;46+ }47+ renderSession();48+49+ const handleMatch = path.match(/^\/@([^/]+)$/);50+ if (handleMatch) {51+ showProfile(decodeURIComponent(handleMatch[1] ?? ''));52+ return;53+ }54+55+ const serviceMatch = path.match(/^\/s\/(res_[a-z2-7]+)$/);56+ if (serviceMatch) {57+ showServiceRoute(serviceMatch[1] ?? '', me !== null);58+ return;59+ }60+61+ if (!me) {62+ renderSignedOut();63+ return;64+ }65+ if (!me.handle) {66+ renderClaimHandle();67+ return;68+ }69+ await showDashboard(me.handle, showSignedOutHome);70+}71+72+bindServiceDialogs(main);73+watchLogoContrast();74+main.removeAttribute('aria-busy');75+await route();
M web/concepts.html +5 -7
A web/dashboard.ts +453 -0
@@ -0,0 +1,453 @@
1+import { bindInstallControl, installControlMarkup } from './install';2+import { renderSecretCommand } from './secrets';3+import {4+ api,5+ element,6+ errorMessage,7+ escapeHtml,8+ query,9+ setPageTitle,10+} from './shared';11+import { clearTheme } from './theme';12+13+type Visibility = 'public' | 'private' | 'shared';14+type SiteKind = 'link' | 'service';15+16+interface ShareRecipient {17+ handle: string;18+ status: string;19+}20+21+interface Resource {22+ resource_id: string;23+ name: string;24+ kind: SiteKind;25+ visibility: Visibility;26+ shares?: ShareRecipient[];27+ shared_with?: string[];28+}29+30+interface IncomingShare {31+ resource_id: string;32+ name: string;33+ kind: SiteKind;34+ url?: string;35+ owner_handle: string;36+ status: string;37+}38+39+interface MachineCredential {40+ id: string;41+ name: string;42+ last_used_at: number | null;43+ scopes?: string[];44+}45+46+interface NewCredential {47+ name: string;48+ ticket: string;49+}50+51+interface FixiEventDetail {52+ cfg: {53+ response: Response;54+ text: string;55+ };56+ error?: unknown;57+}58+59+const main = element('main');60+61+export function renderClaimHandle() {62+ setPageTitle('dashboard');63+ clearTheme();64+ main.innerHTML = `65+ <article>66+ <hgroup>67+ <h1>Choose a handle.</h1>68+ <p>It becomes the address of your profile. Letters, digits, hyphens and underscores.</p>69+ </hgroup>70+ <form id="claim-form">71+ <fieldset role="group">72+ <input id="handle" name="handle" placeholder="yourhandle" autocomplete="off"73+ spellcheck="false" aria-label="Handle" required>74+ <button type="submit">Claim</button>75+ </fieldset>76+ <small id="claim-note"></small>77+ </form>78+ </article>`;79+80+ element('claim-form').addEventListener('submit', async (event) => {81+ event.preventDefault();82+ const field = element<HTMLInputElement>('handle');83+ const note = element('claim-note');84+ try {85+ await api<{ handle: string }>('/api/profile', {86+ method: 'POST',87+ body: JSON.stringify({ handle: field.value }),88+ });89+ location.assign('/');90+ } catch (error) {91+ field.setAttribute('aria-invalid', 'true');92+ note.innerHTML = `<span class="error">${escapeHtml(errorMessage(error))}</span>`;93+ }94+ });95+}96+97+function formatTime(seconds: number | null): string {98+ return seconds ? new Date(seconds * 1000).toLocaleString() : 'never';99+}100+101+function resourceControl(resource: Resource): HTMLLIElement {102+ const item = document.createElement('li');103+ const shares = resource.shares || (resource.shared_with || [])104+ .map((handle) => ({ handle, status: 'accepted' }));105+ item.innerHTML = `106+ <div>107+ <strong>${escapeHtml(resource.name)}</strong>108+ <small>${escapeHtml(resource.kind)} - ${escapeHtml(resource.visibility)}</small>109+ </div>110+ <div class="dashboard-actions"></div>`;111+ const actions = query<HTMLElement>('.dashboard-actions', item);112+ for (const share of shares) {113+ const button = document.createElement('button');114+ button.className = 'secondary outline revoke-share';115+ button.type = 'button';116+ button.textContent = `Revoke @${share.handle} - ${share.status}`;117+ button.addEventListener('click', async () => {118+ button.setAttribute('aria-busy', 'true');119+ try {120+ await api(`/api/resources/${encodeURIComponent(resource.resource_id)}/shares`, {121+ method: 'PUT',122+ body: JSON.stringify({123+ share_with: shares124+ .filter((value) => value.handle !== share.handle)125+ .map((value) => value.handle),126+ }),127+ });128+ await showDashboard();129+ } catch (error) {130+ button.removeAttribute('aria-busy');131+ button.setAttribute('aria-invalid', 'true');132+ button.title = errorMessage(error);133+ }134+ });135+ actions.append(button);136+ }137+ if (!shares.length) actions.innerHTML = '<small>No individual shares</small>';138+ return item;139+}140+141+function configureFixiShareAction(142+ button: HTMLButtonElement,143+ action: string,144+ method: 'POST' | 'DELETE',145+) {146+ button.setAttribute('fx-action', action);147+ button.setAttribute('fx-method', method);148+ button.setAttribute('fx-target', '#incoming-shares');149+ button.setAttribute('fx-swap', 'innerHTML');150+}151+152+function shareActionButton(event: Event): HTMLButtonElement | null {153+ const button = event.target;154+ if (!(button instanceof HTMLButtonElement)) return null;155+ return button.getAttribute('fx-action')?.startsWith('/ui/share-invitations/')156+ ? button157+ : null;158+}159+160+document.addEventListener('fx:before', (event) => {161+ shareActionButton(event)?.setAttribute('aria-busy', 'true');162+});163+164+document.addEventListener('fx:after', (event) => {165+ const button = shareActionButton(event);166+ if (!button) return;167+ const detail = (event as CustomEvent<FixiEventDetail>).detail;168+ if (detail.cfg.response.ok) return;169+ event.preventDefault();170+ button.setAttribute('aria-invalid', 'true');171+ try {172+ const body = JSON.parse(detail.cfg.text) as { error?: string };173+ button.title = body.error ?? `${detail.cfg.response.status}`;174+ } catch {175+ button.title = `${detail.cfg.response.status}`;176+ }177+});178+179+document.addEventListener('fx:error', (event) => {180+ const button = shareActionButton(event);181+ if (!button) return;182+ const detail = (event as CustomEvent<FixiEventDetail>).detail;183+ button.setAttribute('aria-invalid', 'true');184+ button.title = errorMessage(detail.error);185+});186+187+document.addEventListener('fx:finally', (event) => {188+ shareActionButton(event)?.removeAttribute('aria-busy');189+});190+191+function incomingShareControl(share: IncomingShare): HTMLLIElement {192+ const item = document.createElement('li');193+ const destination = share.url194+ ? ` - <span data-selectable>${escapeHtml(share.url)}</span>`195+ : '';196+ item.innerHTML = `197+ <div>198+ <strong>${escapeHtml(share.name)}</strong>199+ <small>from @${escapeHtml(share.owner_handle)} - ${escapeHtml(share.kind)}${destination}</small>200+ </div>201+ <div class="dashboard-actions"></div>`;202+ const actions = query<HTMLElement>('.dashboard-actions', item);203+204+ if (share.status === 'pending') {205+ const accept = document.createElement('button');206+ accept.type = 'button';207+ accept.textContent = 'Accept';208+ configureFixiShareAction(209+ accept,210+ `/ui/share-invitations/${encodeURIComponent(share.resource_id)}/accept`,211+ 'POST',212+ );213+ actions.append(accept);214+ }215+216+ const decline = document.createElement('button');217+ decline.className = 'secondary outline';218+ decline.type = 'button';219+ decline.textContent = share.status === 'pending' ? 'Decline' : 'Remove';220+ configureFixiShareAction(221+ decline,222+ `/ui/share-invitations/${encodeURIComponent(share.resource_id)}`,223+ 'DELETE',224+ );225+ actions.append(decline);226+ return item;227+}228+229+function credentialControl(credential: MachineCredential): HTMLLIElement {230+ const item = document.createElement('li');231+ const scopes = credential.scopes?.length232+ ? ` - ${credential.scopes.map((scope) => escapeHtml(scope)).join(', ')}`233+ : '';234+ item.innerHTML = `235+ <div>236+ <strong>${escapeHtml(credential.name)}</strong>237+ <small>last used ${escapeHtml(formatTime(credential.last_used_at))}${scopes}</small>238+ </div>`;239+ const button = document.createElement('button');240+ button.className = 'secondary outline';241+ button.type = 'button';242+ button.textContent = 'Revoke';243+ button.addEventListener('click', async () => {244+ button.setAttribute('aria-busy', 'true');245+ try {246+ await api(`/api/machine-credentials/${encodeURIComponent(credential.id)}`, {247+ method: 'DELETE',248+ });249+ await showDashboard();250+ } catch (error) {251+ button.removeAttribute('aria-busy');252+ button.setAttribute('aria-invalid', 'true');253+ button.title = errorMessage(error);254+ }255+ });256+ item.append(button);257+ return item;258+}259+260+let currentHandle = '';261+let signOutCallback: (() => void) | undefined;262+263+async function signOut(button: HTMLButtonElement) {264+ button.setAttribute('aria-busy', 'true');265+ try {266+ await api('/api/auth/session', { method: 'DELETE' });267+ } catch (error) {268+ button.removeAttribute('aria-busy');269+ button.setAttribute('aria-invalid', 'true');270+ button.title = errorMessage(error);271+ return;272+ }273+ signOutCallback?.();274+}275+276+export async function showDashboard(277+ handle = currentHandle,278+ onSignOut = signOutCallback,279+ newCredential: NewCredential | null = null,280+) {281+ if (!handle) throw new Error('the dashboard needs a profile handle');282+ currentHandle = handle;283+ signOutCallback = onSignOut;284+ setPageTitle('dashboard');285+ clearTheme();286+ main.setAttribute('aria-busy', 'true');287+ const [resourceListing, incomingListing, credentialListing, settings] = await Promise.all([288+ api<{ resources: Resource[] }>('/api/resources'),289+ api<{ shares: IncomingShare[] }>('/api/share-invitations'),290+ api<{ credentials: MachineCredential[] }>('/api/machine-credentials'),291+ api<{ private_only: boolean }>('/api/profile/settings'),292+ ]);293+294+ main.innerHTML = `295+ <hgroup>296+ <h1>@${escapeHtml(handle)}</h1>297+ <p>Manage what your profile reveals and which machines may change it.</p>298+ </hgroup>299+300+ <article>301+ <header><strong>Profile</strong></header>302+ <p><a href="/@${escapeHtml(handle)}">View your profile</a></p>303+ <label>304+ <input id="private-only" type="checkbox" role="switch"305+ ${settings.private_only ? 'checked' : ''}>306+ Private-only profile307+ </label>308+ <small>When enabled, only you can open the profile page. Services accepted by other309+ people still appear as ordinary entries on their own profiles.</small>310+ <p id="profile-settings-status"></p>311+ </article>312+313+ <article>314+ <details>315+ <summary><strong>Incoming shares</strong></summary>316+ <p>Nothing is added to your profile until you accept it. Link destinations are shown317+ as text so you can inspect them without opening them.</p>318+ <ul class="dashboard-list" id="incoming-shares"></ul>319+ </details>320+ </article>321+322+ <article>323+ <details>324+ <summary><strong>Shared by you</strong></summary>325+ <ul class="dashboard-list" id="dashboard-resources"></ul>326+ </details>327+ </article>328+329+ <article>330+ <details${newCredential ? ' open' : ''}>331+ <summary><strong>Machine credentials</strong></summary>332+ <p>Create a single-use ticket that enrolls one Ed25519 machine identity.</p>333+ <form id="credential-form">334+ <fieldset role="group">335+ <input id="credential-name" name="name" maxlength="60"336+ placeholder="This MacBook" aria-label="Machine name" required>337+ <button type="submit">Create</button>338+ </fieldset>339+ <label>340+ <input id="credential-service-grants" type="checkbox">341+ Allow this machine to issue endpoint-bound service grants342+ </label>343+ <small>This authority is server-enforced and can be removed by revoking the344+ machine credential.</small>345+ <small id="credential-note"></small>346+ </form>347+ <div id="new-machine-token"></div>348+ <ul class="dashboard-list" id="machine-credentials"></ul>349+ </details>350+ </article>351+352+ <article aria-labelledby="install-title">353+ <header id="install-title">Install</header>354+ ${installControlMarkup()}355+ </article>356+357+ <article>358+ <header><strong>Session</strong></header>359+ <button id="logout" type="button" class="secondary outline">Log out</button>360+ </article>`;361+362+ const resources = element('dashboard-resources');363+ if (resourceListing.resources.length) {364+ resourceListing.resources.forEach((resource) => {365+ resources.append(resourceControl(resource));366+ });367+ } else {368+ resources.innerHTML = '<li><small>No resources yet. Add one with the CLI.</small></li>';369+ }370+371+ const incoming = element('incoming-shares');372+ if (incomingListing.shares.length) {373+ incomingListing.shares.forEach((share) => {374+ incoming.append(incomingShareControl(share));375+ });376+ } else {377+ incoming.innerHTML = '<li><small>No incoming shares.</small></li>';378+ }379+380+ const credentials = element('machine-credentials');381+ if (credentialListing.credentials.length) {382+ credentialListing.credentials.forEach((credential) => {383+ credentials.append(credentialControl(credential));384+ });385+ } else {386+ credentials.innerHTML = '<li><small>No machine credentials yet.</small></li>';387+ }388+389+ if (newCredential) {390+ element('new-machine-token').innerHTML = `391+ <p><strong>${escapeHtml(newCredential.name)}</strong> is ready.</p>392+ <div class="secret-command-slot"></div>`;393+ renderSecretCommand(394+ query('.secret-command-slot', element('new-machine-token')),395+ 'login',396+ newCredential.ticket,397+ );398+ }399+400+ element('logout').addEventListener('click', (event) =>401+ signOut(event.currentTarget as HTMLButtonElement));402+ bindInstallControl();403+404+ element('private-only').addEventListener('change', async (event) => {405+ const input = event.currentTarget as HTMLInputElement;406+ const status = element('profile-settings-status');407+ input.setAttribute('aria-busy', 'true');408+ try {409+ await api('/api/profile/settings', {410+ method: 'PUT',411+ body: JSON.stringify({ private_only: input.checked }),412+ });413+ status.innerHTML = '<small class="ok">Saved.</small>';414+ } catch (error) {415+ input.checked = !input.checked;416+ status.innerHTML = `<small class="error">${escapeHtml(errorMessage(error))}</small>`;417+ } finally {418+ input.removeAttribute('aria-busy');419+ }420+ });421+422+ element('credential-form').addEventListener('submit', async (event) => {423+ event.preventDefault();424+ const form = event.currentTarget as HTMLFormElement;425+ const button = query<HTMLButtonElement>('button', form);426+ const note = element('credential-note');427+ button.setAttribute('aria-busy', 'true');428+ try {429+ const created = await api<{ credential: { name: string }; ticket: string }>(430+ '/api/machine-credentials',431+ {432+ method: 'POST',433+ body: JSON.stringify({434+ name: element<HTMLInputElement>('credential-name').value,435+ scopes: element<HTMLInputElement>('credential-service-grants').checked436+ ? ['service_grants:issue']437+ : [],438+ }),439+ },440+ );441+ await showDashboard(442+ currentHandle,443+ signOutCallback,444+ { name: created.credential.name, ticket: created.ticket },445+ );446+ } catch (error) {447+ button.removeAttribute('aria-busy');448+ note.innerHTML = `<span class="error">${escapeHtml(errorMessage(error))}</span>`;449+ }450+ });451+452+ main.removeAttribute('aria-busy');453+}
A web/home.ts +56 -0
@@ -0,0 +1,56 @@
1+import { bindInstallControl, installControlMarkup } from './install';2+import { element, query, setPageTitle } from './shared';3+import { clearTheme } from './theme';4+5+const CLI_VERBS = new Set([6+ 'access', 'connect', 'daemon', 'grant', 'host', 'link', 'login', 'request',7+ 'resolve', 'run', 'service', 'set',8+]);9+10+function highlightCliExamples(container: Element) {11+ container.querySelectorAll('pre > code').forEach((code) => {12+ const source = code.textContent;13+ if (!source.trimStart().startsWith('devsite ')) return;14+15+ const highlighted = document.createDocumentFragment();16+ let wordIndex = 0;17+ source.split(/(\s+)/).forEach((token) => {18+ if (!token || /^\s+$/.test(token)) {19+ highlighted.append(token);20+ if (token.includes('\n')) wordIndex = 0;21+ return;22+ }23+24+ const span = document.createElement('span');25+ if (wordIndex === 0 && token === 'devsite') span.className = 'command-executable';26+ else if (wordIndex <= 2 && CLI_VERBS.has(token)) span.className = 'command-verb';27+ else if (token.startsWith('--')) span.className = 'command-option';28+ else if (/^(?:dmt|dsp|dss|dst)_/.test(token)) span.className = 'command-secret';29+ else span.className = 'command-value';30+ span.textContent = token;31+ highlighted.append(span);32+ wordIndex += 1;33+ });34+ code.replaceChildren(highlighted);35+ });36+}37+38+export function renderSignedOut() {39+ setPageTitle('home');40+ clearTheme();41+ query<HTMLElement>('.brand strong').hidden = true;42+ const main = element('main');43+ const template = element<HTMLTemplateElement>('home-template');44+ main.replaceChildren(template.content.cloneNode(true));45+ query<HTMLElement>('[data-install-control]', main).innerHTML = installControlMarkup();46+47+ const footer = element('page-footer');48+ footer.innerHTML = `49+ <nav aria-label="Footer">50+ <ul><li><a href="https://github.com/FelineStateMachine/devsite"51+ target="_blank" rel="noopener">GitHub</a></li></ul>52+ </nav>`;53+ footer.hidden = false;54+ bindInstallControl();55+ highlightCliExamples(main);56+}
M web/index.html +99 -3
@@ -7,8 +7,8 @@
77 <!-- Pico first, this site's few additions second, and the profile's own theme88 last. Last is necessary but not sufficient: Pico's palette sits on99 :root:not([data-theme=dark]), which is specificity (0,2,0), so the theme10- block matches it with :root[data-profile="…"] and wins on order. See11- applyTheme in app.js. -->10+ block matches it with :root[data-profile="…"] and wins on order. The profile11+ stream writes this block before it morphs the profile markup. -->1212 <link rel="stylesheet" href="/vendor/pico.min.css">1313 <link rel="stylesheet" href="/site.css">1414 <style id="profile-theme"></style>
@@ -35,6 +35,102 @@
3535 </nav>3636 </header>3737 38+<template id="home-template">39+ <article>40+ <hgroup>41+ <h1 aria-label="dev.site"><span aria-hidden="true"> █ ▀ ▄42+ ▄▄▄█ ▄▄▄ ▄ ▄ ▄▄▄ ▄▄▄ ▄▄█▄▄ ▄▄▄43+█▀ ▀█ █▀ █ ▀▄ ▄▀ █ ▀ █ █ █▀ █44+█ █ █▀▀▀▀ █▄█ ▀▀▀▄ █ █ █▀▀▀▀45+▀█▄██ ▀█▄▄▀ █ █ ▀▄▄▄▀ ▄▄█▄▄ ▀▄▄ ▀█▄▄▀</span></h1>46+ <p>links and services across all of your development sites</p>47+ </hgroup>48+ <figure class="sequence">49+ <figcaption>authorization only - no service bytes</figcaption>50+ <header aria-hidden="true">51+ <strong>host cli</strong>52+ <strong>dev.site</strong>53+ <strong>approved user</strong>54+ </header>55+ <div>56+ <i aria-hidden="true"></i><i aria-hidden="true"></i><i aria-hidden="true"></i>57+ <ol aria-label="A host proves control of and registers its bound endpoint, then receives current service authorizations from dev.site. An approved user redeems a one-use ticket into a client-bound session, requests a signed capability for each connection, then opens an end-to-end encrypted Iroh QUIC stream to the host service.">58+ <li><span>prove + register bound endpoint</span></li>59+ <li><span>sync authorization snapshot</span></li>60+ <li><span>redeem one-use ticket + client key</span></li>61+ <li><span>client-bound session</span></li>62+ <li><span>request one-stream capability</span></li>63+ <li><span>signed capability + daemon id</span></li>64+ <li><span>end-to-end encrypted Iroh QUIC</span></li>65+ </ol>66+ </div>67+ </figure>68+ </article>69+70+ <section aria-labelledby="install-title">71+ <h2 id="install-title">Install</h2>72+ <div data-install-control></div>73+ </section>74+75+ <hr>76+77+ <details>78+ <summary>Overview</summary>79+ <p>Store useful URLs, shared services, and temporary access</p>80+ <article>81+ <h3>Machine Enrollment</h3>82+ <pre><code>devsite login dmt_***</code></pre>83+ <blockquote>A single-use ticket creates a revocable, identity-bound machine credential</blockquote>84+ </article>85+ <article>86+ <h3>Add Links to your site</h3>87+ <pre><code>devsite link set --name docs --url https://docs.example.com --public</code></pre>88+ <blockquote>List a public, private, shared URLs on your page</blockquote>89+ </article>90+ <article>91+ <h3>Share your Services</h3>92+ <pre><code>devsite service host 5432 --name postgres</code></pre>93+ <blockquote>94+ <p>Access any local TCP port. never publicly accessible</p>95+ <p>Service is accessed anywhere with a single use ticket.</p>96+ </blockquote>97+ <pre><code>devsite connect dst_***</code></pre>98+ </article>99+ <article>100+ <h3>Delegate Granular Access</h3>101+ <blockquote><p>A sandboxed agent can request a service by keyword. The request is matched to an accessible service, and a scoped grant is presented for approval.</p>102+ <p>The requester receives only a short-lived grant bound to the selected103+ service and its temporary endpoint.</p></blockquote>104+ <pre><code>devsite access request postgres --request request.json --key requester.key</code></pre>105+ <blockquote>Resolution is limited to services available to the approving account. Grants106+ are short-lived and revocable.</blockquote>107+ <pre><code>devsite access resolve postgres108+devsite access grant --request request.json --plan109+devsite access grant --request request.json --approved-plan dsp_***</code></pre>110+ <blockquote>The requester opens an on-demand, endpoint-bound connection to the selected service.</blockquote>111+ <pre><code>devsite access connect dss_*** --key requester.key</code></pre>112+ </article>113+ <article>114+ <h3>Persist the Daemon</h3>115+ <pre><code>devsite daemon run</code></pre>116+ <blockquote>autorun it with Homebrew services or systemd to keep services available for117+ on-demand connections</blockquote>118+ </article>119+ </details>120+121+ <details>122+ <summary>Important Concepts</summary>123+ <p>Tickets, keys, and the ALPN tell the story of how it works</p>124+ <nav aria-label="Security concept guides">125+ <ul>126+ <li><a href="/concepts.html#tickets">Tickets</a></li>127+ <li><a href="/concepts.html#keys">Keys</a></li>128+ <li><a href="/concepts.html#protocol">ALPN</a></li>129+ </ul>130+ </nav>131+ </details>132+</template>133+38134 <main class="container" id="main" aria-busy="true"></main>39135 40136 <footer class="container" id="page-footer" hidden></footer>
@@ -50,6 +146,6 @@
50146 </article>51147 </dialog>52148 53-<script type="module" src="/app.js"></script>149+<script type="module" src="/pkg/app.js"></script>54150 </body>55151 </html>
A web/install.ts +46 -0
@@ -0,0 +1,46 @@
1+interface NavigatorWithUserAgentData extends Navigator {2+ userAgentData?: { platform?: string };3+}4+5+function prefersHomebrewInstall() {6+ const platform = (navigator as NavigatorWithUserAgentData).userAgentData?.platform7+ || navigator.platform || '';8+ return /mac/i.test(platform) || /Macintosh|Mac OS X/i.test(navigator.userAgent);9+}10+11+export function installControlMarkup() {12+ return prefersHomebrewInstall()13+ ? `<fieldset role="group">14+ <input id="install-command" aria-label="Homebrew install command"15+ value="brew install FelineStateMachine/tap/devsite" readonly>16+ <button id="copy-install" type="button">Copy</button>17+ </fieldset>`18+ : `<fieldset role="group">19+ <input aria-label="Latest binary release"20+ value="GitHub - latest devsite binary release" readonly>21+ <a href="https://github.com/FelineStateMachine/devsite/releases/latest"22+ role="button">Download</a>23+ </fieldset>`;24+}25+26+export function bindInstallControl() {27+ const installCommand = document.getElementById('install-command');28+ const copyInstall = document.getElementById('copy-install');29+ if (!(installCommand instanceof HTMLInputElement)30+ || !(copyInstall instanceof HTMLButtonElement)) return;31+32+ let copyReset: ReturnType<typeof setTimeout> | undefined;33+ copyInstall.addEventListener('click', async () => {34+ try {35+ await navigator.clipboard.writeText(installCommand.value);36+ copyInstall.textContent = 'Copied';37+ clearTimeout(copyReset);38+ copyReset = setTimeout(() => {39+ copyInstall.textContent = 'Copy';40+ }, 1400);41+ } catch {42+ copyInstall.textContent = 'Select';43+ installCommand.select();44+ }45+ });46+}
A web/profile.ts +117 -0
@@ -0,0 +1,117 @@
1+import 'fixi-js';2+import '@bigskysoftware/paxi-js';3+import '@bigskysoftware/ssexi-js';4+5+import { element, escapeHtml, setPageTitle } from './shared';6+import { clearTheme, updateLogoContrast } from './theme';7+8+interface FixiConfig {9+ action: string;10+ response: Response;11+ ssePauseOnHidden?: boolean;12+ sseReconnect?: boolean;13+ sseSwap?: (target: Element, html: string) => void;14+ text: string;15+ target: Element;16+}17+18+interface FixiEventDetail {19+ cfg: FixiConfig;20+}21+22+interface SsexiEventDetail extends FixiEventDetail {23+ message: {24+ data: string;25+ event: string;26+ id: string;27+ };28+}29+30+declare global {31+ interface Window {32+ morph(target: Element, html: string): void;33+ }34+}35+36+const main = element('main');37+let folderState = new Map<string, boolean>();38+39+function profileRequest(config: FixiConfig): boolean {40+ return config.action.startsWith('/ui/profile/');41+}42+43+function renderMissingProfile(handle: string, message = 'does not exist') {44+ clearTheme();45+ main.removeAttribute('data-profile-handle');46+ main.removeAttribute('data-profile-scheme');47+ main.removeAttribute('aria-busy');48+ main.innerHTML = `49+ <article>50+ <hgroup>51+ <h1>No such profile.</h1>52+ <p>@${escapeHtml(handle)} ${escapeHtml(message)}</p>53+ </hgroup>54+ </article>`;55+}56+57+document.addEventListener('fx:config', (event) => {58+ const config = (event as CustomEvent<FixiEventDetail>).detail.cfg;59+ if (!profileRequest(config)) return;60+ config.sseReconnect = true;61+ config.ssePauseOnHidden = true;62+ config.sseSwap = (target, html) => window.morph(target, html);63+});64+65+document.addEventListener('fx:sse:message', (event) => {66+ const detail = (event as CustomEvent<SsexiEventDetail>).detail;67+ if (!profileRequest(detail.cfg)) return;68+ folderState = new Map(69+ [...main.querySelectorAll<HTMLDetailsElement>('details[data-folder]')]70+ .map((folder) => [folder.dataset.folder ?? '', folder.open]),71+ );72+});73+74+document.addEventListener('fx:sse:swapped', (event) => {75+ const detail = (event as CustomEvent<SsexiEventDetail>).detail;76+ if (!profileRequest(detail.cfg) || event.target !== main) return;77+ const handle = main.dataset.profileHandle;78+ const scheme = main.dataset.profileScheme;79+ if (!handle) return;80+ document.documentElement.dataset.profile = handle;81+ if (scheme === 'light' || scheme === 'dark') {82+ document.documentElement.dataset.theme = scheme;83+ } else {84+ delete document.documentElement.dataset.theme;85+ }86+ for (const folder of main.querySelectorAll<HTMLDetailsElement>('details[data-folder]')) {87+ const open = folderState.get(folder.dataset.folder ?? '');88+ if (open !== undefined) folder.open = open;89+ }90+ folderState.clear();91+ setPageTitle(`@${handle}`);92+ requestAnimationFrame(updateLogoContrast);93+});94+95+document.addEventListener('fx:after', (event) => {96+ const detail = (event as CustomEvent<FixiEventDetail>).detail;97+ if (!profileRequest(detail.cfg) || detail.cfg.response.ok) return;98+ event.preventDefault();99+ renderMissingProfile(decodeURIComponent(location.pathname.slice(2)));100+});101+102+document.addEventListener('fx:sse:unavailable', () => {103+ renderMissingProfile(decodeURIComponent(location.pathname.slice(2)));104+});105+106+export function showProfile(handle: string) {107+ setPageTitle(`@${handle}`);108+ clearTheme();109+ main.setAttribute('aria-busy', 'true');110+ main.innerHTML = '<article><p>Loading profile.</p></article>';111+ main.setAttribute('fx-action', `/ui/profile/${encodeURIComponent(handle)}/stream`);112+ main.setAttribute('fx-method', 'GET');113+ main.setAttribute('fx-swap', 'morph');114+ main.setAttribute('fx-trigger', 'profile:load');115+ main.dispatchEvent(new CustomEvent('fx:process', { bubbles: true, composed: true }));116+ main.dispatchEvent(new Event('profile:load'));117+}
A web/secrets.ts +38 -0
A web/services.ts +105 -0
@@ -0,0 +1,105 @@
1+import { api, element, errorMessage, query } from './shared';2+import { renderSecretCommand } from './secrets';3+import { clearTheme } from './theme';4+5+interface ServicePrompt {6+ resource_id: string;7+ name: string;8+ owner_handle?: string;9+}10+11+function openServiceTicketDialog() {12+ document.documentElement.classList.add('modal-is-open', 'modal-is-opening');13+ element('service-ticket-dialog').setAttribute('open', '');14+ setTimeout(() => document.documentElement.classList.remove('modal-is-opening'), 400);15+}16+17+function closeServiceTicketDialog() {18+ document.documentElement.classList.remove('modal-is-open', 'modal-is-opening');19+ element('service-ticket-dialog').removeAttribute('open');20+ element('service-ticket-body').replaceChildren();21+}22+23+export function renderServiceTicketPrompt(24+ resourceId: string,25+ container: HTMLElement,26+ status: HTMLElement,27+) {28+ container.innerHTML = `29+ <p>This is a private TCP service. Mint a short-lived ticket, then connect it to a loopback port with the CLI.</p>30+ <button type="button" class="get-ticket">Get ticket</button>`;31+ status.textContent = '';32+33+ query<HTMLButtonElement>('.get-ticket', container).addEventListener('click', async (event) => {34+ const button = event.currentTarget as HTMLButtonElement;35+ button.setAttribute('aria-busy', 'true');36+ status.textContent = '';37+ try {38+ const result = await api<{ ticket: string }>(39+ `/api/services/${encodeURIComponent(resourceId)}/ticket`,40+ { method: 'POST', body: '{}' },41+ );42+ container.innerHTML = `43+ <p>This ticket can be redeemed once and expires shortly.</p>44+ <div class="secret-command-slot"></div>`;45+ renderSecretCommand(query('.secret-command-slot', container), 'connect', result.ticket);46+ } catch (error) {47+ button.removeAttribute('aria-busy');48+ status.textContent = errorMessage(error);49+ }50+ });51+}52+53+function showServiceTicketDialog(item: ServicePrompt) {54+ element('service-ticket-title').textContent = item.owner_handle55+ ? `${item.name} - @${item.owner_handle}`56+ : item.name;57+ renderServiceTicketPrompt(58+ item.resource_id,59+ element('service-ticket-body'),60+ element('service-ticket-status'),61+ );62+ openServiceTicketDialog();63+}64+65+export function showServiceRoute(resourceId: string, signedIn: boolean) {66+ clearTheme();67+ const main = element('main');68+ main.innerHTML = `69+ <article>70+ <hgroup>71+ <h1>Private TCP service</h1>72+ <p>Access is checked for each connection.</p>73+ </hgroup>74+ <div id="service-connect"></div>75+ <p id="service-status"></p>76+ <p><small>The CLI opens a loopback port and carries its byte stream through Iroh.</small></p>77+ </article>`;78+ if (signedIn) {79+ renderServiceTicketPrompt(resourceId, element('service-connect'), element('service-status'));80+ } else {81+ element('service-connect').innerHTML = '<p>Sign in from the header to request access.</p>';82+ }83+}84+85+export function bindServiceDialogs(main: HTMLElement) {86+ main.addEventListener('click', (event) => {87+ const target = event.target;88+ if (!(target instanceof Element)) return;89+ const button = target.closest<HTMLButtonElement>('button[data-service-id]');90+ if (!button?.dataset.serviceId || !button.dataset.serviceName) return;91+ showServiceTicketDialog({92+ resource_id: button.dataset.serviceId,93+ name: button.dataset.serviceName,94+ ...(button.dataset.serviceOwner ? { owner_handle: button.dataset.serviceOwner } : {}),95+ });96+ });97+98+ element('service-ticket-close').addEventListener('click', closeServiceTicketDialog);99+ element('service-ticket-dialog').addEventListener('click', (event) => {100+ if (event.target === element('service-ticket-dialog')) closeServiceTicketDialog();101+ });102+ document.addEventListener('keydown', (event) => {103+ if (event.key === 'Escape') closeServiceTicketDialog();104+ });105+}
A web/shared.ts +45 -0
@@ -0,0 +1,45 @@
1+export interface Me {2+ account_id: string;3+ handle: string | null;4+}5+6+export function element<T extends HTMLElement = HTMLElement>(id: string): T {7+ const found = document.getElementById(id);8+ if (!(found instanceof HTMLElement)) throw new Error(`#${id} is missing`);9+ return found as T;10+}11+12+export function query<T extends Element = HTMLElement>(13+ selector: string,14+ root: ParentNode = document,15+): T {16+ const found = root.querySelector(selector);17+ if (!found) throw new Error(`${selector} is missing`);18+ return found as T;19+}20+21+export const escapeHtml = (value: unknown) =>22+ String(value).replace(/[&<>"']/g, (character) =>23+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character]24+ ?? character));25+26+export function setPageTitle(suffix = '') {27+ document.title = suffix ? `dev.site - ${suffix}` : 'dev.site';28+}29+30+export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {31+ const response = await fetch(path, {32+ credentials: 'same-origin',33+ headers: { 'Content-Type': 'application/json' },34+ ...options,35+ });36+ if (!response.ok) {37+ const body = await response.json().catch(() => ({})) as { error?: string };38+ throw new Error(body.error ?? `${response.status}`);39+ }40+ return (response.status === 204 ? null : await response.json()) as T;41+}42+43+export function errorMessage(error: unknown): string {44+ return error instanceof Error ? error.message : String(error);45+}
A web/theme.ts +66 -0
@@ -0,0 +1,66 @@
1+import { element, query } from './shared';2+3+function parseComputedColor(4+ value: string,5+): { rgb: [number, number, number]; alpha: number } | null {6+ const match = value.match(7+ /^rgba?\(\s*([\d.]+)[, ]+\s*([\d.]+)[, ]+\s*([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i,8+ );9+ if (!match) return null;10+ return {11+ rgb: [Number(match[1]), Number(match[2]), Number(match[3])],12+ alpha: match[4] === undefined ? 1 : Number(match[4]),13+ };14+}15+16+function logoBackground() {17+ let node = document.querySelector('.brand');18+ let alpha = 0;19+ const premultiplied: [number, number, number] = [0, 0, 0];20+ while (node instanceof Element) {21+ const layer = parseComputedColor(getComputedStyle(node).backgroundColor);22+ if (layer && layer.alpha > 0) {23+ for (const channel of [0, 1, 2] as const) {24+ premultiplied[channel] += (layer.rgb[channel] ?? 0) * layer.alpha * (1 - alpha);25+ }26+ alpha += layer.alpha * (1 - alpha);27+ if (alpha >= 0.999) break;28+ }29+ node = node.parentElement;30+ }31+ return premultiplied.map((channel) => channel + 255 * (1 - alpha));32+}33+34+export function updateLogoContrast() {35+ if (!document.getElementById('dev-logo-ring')) return;36+ const luminance = logoBackground()37+ .map((channel) => channel / 255)38+ .map((channel) => channel <= 0.0404539+ ? channel / 12.9240+ : ((channel + 0.055) / 1.055) ** 2.4)41+ .reduce(42+ (sum, channel, index) => sum + channel * ([0.2126, 0.7152, 0.0722][index] ?? 0),43+ 0,44+ );45+ document.documentElement.style.setProperty(46+ '--devsite-logo-ring',47+ luminance < 0.179 ? 'rgb(255,255,255)' : 'rgb(0,0,0)',48+ );49+}50+51+export function clearTheme() {52+ query<HTMLElement>('.brand strong').hidden = false;53+ delete document.documentElement.dataset.profile;54+ delete document.documentElement.dataset.theme;55+ element('profile-theme').textContent = '';56+ const footer = element('page-footer');57+ footer.replaceChildren();58+ footer.hidden = true;59+ requestAnimationFrame(updateLogoContrast);60+}61+62+export function watchLogoContrast() {63+ updateLogoContrast();64+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () =>65+ requestAnimationFrame(updateLogoContrast));66+}