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>
| M | .github/ | 41 | +- |
| M | .github/ | 16 | +- |
| M | .gitignore | 1 | + |
| M | Cargo.lock | 14 | +- |
| M | Cargo.toml | 2 | +- |
| M | Dockerfile | 17 | +- |
| M | README.md | 27 | +- |
| A | biome.json | 15 | + |
| M | crates/ | 17 | +- |
| M | crates/ | 27 | +- |
| M | crates/ | 2 | + |
| M | crates/ | 533 | +++++++++++- |
| M | crates/ | 3 | + |
| M | crates/ | 2 | +- |
| M | deploy/ | 4 | +- |
| M | docs/ | 6 | +- |
| M | docs/ | 10 | +- |
| M | docs/ | 21 | +- |
| A | package-lock.json | 1412 | ++++++++++++++++++++++++++++++ |
| A | package.json | 25 | + |
| A | playwright.config.ts | 12 | + |
| M | plugins/ | 2 | +- |
| A | tsconfig.json | 14 | + |
| D | web/ | 1032 | ---------------------- |
| A | web/ | 136 | +++ |
| A | web/ | 75 | ++ |
| M | web/ | 12 | +- |
| A | web/ | 453 | ++++++++++ |
| A | web/ | 56 | + |
| M | web/ | 102 | ++- |
| A | web/ | 46 | + |
| A | web/ | 117 | ++ |
| A | web/ | 38 | + |
| A | web/ | 105 | ++ |
| A | web/ | 45 | + |
| A | web/ | 66 | + |
@@ -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 @@ -48,8 +48,22 @@ jobs:
4848 rustup update stable4949 rustup default stable5050 51+ - name: Install Node.js52+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.1.053+ with:54+ node-version: 2455+ cache: npm56+57+ - name: Install web dependencies58+ run: |59+ npm ci60+ npx playwright install --with-deps chromium61+5162 - name: Test52- run: cargo test --workspace --locked63+ run: |64+ cargo test --workspace --locked65+ npm run check:web66+ npm run test:web5367 5468 build:5569 name: Build ${{ matrix.target }}@@ -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",@@ -5,25 +5,28 @@ FROM node:24-bookworm-slim AS web
55 66 WORKDIR /src77 8-# Versions are exact so rebuilding a commit cannot silently change its assets.9-RUN npm install --global esbuild@0.25.12 html-minifier-terser@7.2.08+# Versions are exact in the lock file.9+COPY package.json package-lock.json tsconfig.json biome.json ./10+RUN npm ci1011 1112 # Tool installation is independent of the source, so ordinary web edits only1213 # invalidate the small minification layer below.1314 COPY web ./web14-RUN mkdir /out \15+RUN npm run check:web \16+ && mkdir /out \1517 && cp -R web/. /out/ \16- && esbuild web/app.js \18+ && rm /out/*.ts /out/pkg/app.js.map \19+ && npx --no-install esbuild web/pkg/app.js \1720 --format=esm \1821 --legal-comments=none \1922 --minify \20- --outfile=/out/app.js \23+ --outfile=/out/pkg/app.js \2124 --target=es2022 \22- && esbuild web/site.css \25+ && npx --no-install esbuild web/site.css \2326 --legal-comments=none \2427 --minify \2528 --outfile=/out/site.css \26- && html-minifier-terser web/index.html \29+ && npx --no-install html-minifier-terser web/index.html \2730 --collapse-whitespace \2831 --remove-comments \2932 --remove-redundant-attributes \@@ -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 @@ -2173,20 +2173,21 @@ async fn doctor(paths: &Paths, output: Output) -> Result<()> {
21732173 }21742174 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {21752175 let legacy = paths.root.join("identity.key");2176- let message = if legacy.exists() {2177- "Legacy identity.key and identity.pub support ends in version 0.6.0. Run `devsite login` or `devsite daemon run` before you upgrade. The command moves these files."2176+ let (status, message) = if legacy.exists() {2177+ (2178+ "failure",2179+ "Version 0.6.0 does not read identity.key or identity.pub. Start version 0.5.1 once to move these files.",2180+ )2181+ } else if config.machine_credential.is_some() {2182+ ("failure", "No endpoint identity exists yet.")21782183 } else {2179- "No endpoint identity exists yet."2184+ ("skipped", "No endpoint identity exists yet.")21802185 };21812186 push_doctor_check(21822187 &mut checks,21832188 &mut counts,21842189 "identity.private_key",2185- if config.machine_credential.is_some() {2186- "failure"2187- } else {2188- "skipped"2189- },2190+ status,21902191 message,21912192 );21922193 None@@ -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, ¤t).with_context(|| {206- format!("moving {} to {}", legacy.display(), current.display())207- })?;208- }209- }210- Ok(())211- }212193 }213194 214195 @@ -308,7 +289,7 @@ mod tests {
308289 }309290 310291 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 }@@ -14,6 +14,8 @@ devsite-iroh.workspace = true
1414 anyhow.workspace = true1515 axum = { version = "0.8", features = ["macros"] }1616 tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }17+tokio-stream = { version = "0.1", features = ["sync"] }18+futures-util = "0.3"1719 tower-http = { version = "0.6", features = [1820 "compression-br",1921 "compression-gzip",@@ -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+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 @@ -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("&"),1264+ '<' => escaped.push_str("<"),1265+ '>' => escaped.push_str(">"),1266+ '"' => escaped.push_str("""),1267+ '\'' => escaped.push_str("'"),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>{} </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+ 2531+ fn html_fragments_escape_text_and_attributes() {2532+ assert_eq!(escape_html("<&>\"'"), "<&>"'");2533+ }2534+2535+ 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 <stable>"));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 21182597 fn links_support_every_visibility_and_tcp_services_are_not_public() {21192598 assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Public).is_ok());@@ -114,6 +114,7 @@ async fn main() -> Result<()> {
114114 issuer.public_key_hex()115115 );116116 117+ let (profile_changes, _) = tokio::sync::broadcast::channel(64);117118 let state = Arc::new(AppState {118119 db: Mutex::new(db),119120 rate_limits: Mutex::new(RateLimits::default()),@@ -121,6 +122,8 @@ async fn main() -> Result<()> {
121122 identity_namespace: oidc.issuer().to_string(),122123 public_origin: config.public_origin.clone(),123124 relay_issuer,125+ profile_changes,126+ profile_revision: std::sync::atomic::AtomicU64::new(0),124127 });125128 126129 let index = config.web_root.join("index.html");@@ -533,7 +533,7 @@ fn login_failed(status: StatusCode) -> Response {
533533 Html(534534 "<!doctype html><meta charset=utf-8><title>Sign-in failed</title>\535535 <main><h1>Sign-in failed.</h1><p>The login could not be verified.</p>\536- <p><a href=/auth/start>Try again</a> · <a href=/>Return home</a></p></main>",536+ <p><a href=/auth/start>Try again</a> - <a href=/>Return home</a></p></main>",537537 ),538538 )539539 .into_response()@@ -76,8 +76,8 @@ fly certs show dev.site # until it reports Ready
7676 fly deploy7777 ```7878 79-Fly builds the image remotely — there is no need for a local Docker daemon. The build runs80-`cargo build --release -p devsite-server` and copies the small static site into the image.79+Fly builds the image remotely. You do not need a local Docker daemon. The build checks and80+compiles the web source. It then builds the server and copies both outputs into the image.8181 8282 A deploy replaces the machine, which means a few seconds where the site is down. That is8383 the cost of one volume, and it is the right trade at this size.@@ -119,7 +119,7 @@ security mechanisms appropriate to the new protocol. The implementation path is:
119119 5. Call `api::establish_browser_session` with that identity.120120 6. Put the returned token into `api::browser_session_cookie` and redirect to a local path.121121 7. Wire the adapter router in `main.rs`, replacing `oidc::router(...)`.122-8. Keep `/auth/start` as the browser entrypoint so `web/app.js` remains provider-neutral.122+8. Keep `/auth/start` as the browser entrypoint so `web/app.ts` remains provider-neutral.123123 124124 A callback ends approximately like this:125125 @@ -191,7 +191,7 @@ let app = api::router(Arc::clone(&state))
191191 192192 For one replacement provider, change the module and router construction in one place. The current193193 UI advertises one `auth.start_url`, so supporting several providers simultaneously additionally194-requires a provider list in `/api/config` and a provider picker in `web/app.js`. The account table194+requires a provider list in `/api/config` and a provider picker in `web/app.ts`. The account table195195 already supports multiple namespaces.196196 197197 ### Security contract for a new adapter@@ -231,5 +231,5 @@ Run the repository checks before deploying:
231231 cargo test --workspace232232 cargo clippy --workspace --all-targets -- -D warnings233233 cargo build --release --locked -p devsite-server234-node --check web/app.js234+npm run check:web235235 ```@@ -42,16 +42,16 @@ same key to sign the chosen resource, requester endpoint, request id, and grant
4242 Capabilities sent to this daemon name its public endpoint key as the audience. The daemon4343 rejects a capability issued for another endpoint even if every other claim is valid.4444 45-### Prepare for version 0.6.045+### Legacy files in version 0.6.04646 47-Before you upgrade to version 0.6.0, run `devsite login` or `devsite daemon run` with an48-earlier version. That version moves `identity.key` and `identity.pub` to the new names.47+Version 0.6.0 does not read or move `identity.key` and `identity.pub`. Start version 0.5.148+once before the upgrade if these legacy files remain.4949 50-The earlier version checks only these exact names in the resolved dev.site config directory.50+Version 0.5.1 checks only these exact names in the resolved dev.site config directory.5151 It does not scan the current directory, home directory, SSH directory, or filesystem for5252 similar names.5353 54-The earlier version moves a legacy file only when the target file does not exist:54+Version 0.5.1 moves a legacy file only when the target file does not exist:5555 5656 ```text5757 identity.key → devsite-endpoint.key@@ -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 </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 |@@ -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+}@@ -0,0 +1,25 @@
1+{2+ "name": "devsite-web",3+ "private": true,4+ "type": "module",5+ "scripts": {6+ "build:web": "esbuild web/app.ts --bundle --format=esm --legal-comments=none --outfile=web/pkg/app.js --sourcemap --target=es2022",7+ "check:web": "npm run typecheck:web && npm run lint:web && npm run build:web",8+ "lint:web": "biome lint web",9+ "test:web": "npm run build:web && playwright test",10+ "typecheck:web": "tsc --noEmit"11+ },12+ "dependencies": {13+ "@bigskysoftware/paxi-js": "0.0.1",14+ "@bigskysoftware/ssexi-js": "0.0.1",15+ "fixi-js": "0.9.4"16+ },17+ "devDependencies": {18+ "@biomejs/biome": "2.5.8",19+ "@playwright/test": "1.62.1",20+ "@types/node": "26.2.0",21+ "esbuild": "0.28.2",22+ "html-minifier-terser": "7.2.0",23+ "typescript": "7.0.2"24+ }25+}@@ -0,0 +1,14 @@
1+{2+ "compilerOptions": {3+ "exactOptionalPropertyTypes": true,4+ "lib": ["DOM", "DOM.Iterable", "ESNext"],5+ "module": "ESNext",6+ "moduleResolution": "Bundler",7+ "noEmit": true,8+ "noUncheckedIndexedAccess": true,9+ "strict": true,10+ "types": ["node"],11+ "target": "ES2022"12+ },13+ "include": ["web/**/*.ts"]14+}@@ -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- ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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} </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();@@ -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+});@@ -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();@@ -146,13 +146,11 @@
146146 mode <code>0600</code>. Its public half is written to147147 <code>devsite-endpoint.pub</code>, registered with the control plane, and used as the148148 daemon's Iroh endpoint id.</p>149- <p>Before you upgrade to version 0.6.0, run <code>devsite login</code> or150- <code>devsite daemon run</code> with an earlier version. That version moves151- <code>identity.key</code> and <code>identity.pub</code> only from the dev.site config152- directory. It does not scan the current directory, home directory, SSH directory, or153- filesystem. It moves a legacy file only when its target file does not exist. Version154- 0.6.0 uses <code>devsite-endpoint.key</code> and155- <code>devsite-endpoint.pub</code>. It does not read or move the legacy files.</p>149+ <p>Version 0.6.0 uses <code>devsite-endpoint.key</code> and150+ <code>devsite-endpoint.pub</code>. It does not read or move151+ <code>identity.key</code> or <code>identity.pub</code>. Start version 0.5.1 once152+ before the upgrade if these legacy files remain. Version 0.5.1 checks only the153+ dev.site config directory. It does not scan another directory or the filesystem.</p>156154 </details>157155 158156 <details class="concept-detail">@@ -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+}@@ -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+}@@ -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>@@ -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+}@@ -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+}@@ -0,0 +1,38 @@
1+function maskedSecret(secret: string): string {2+ const prefix = String(secret).match(/^[a-z]+_/)?.[0] || '';3+ return `${prefix}***`;4+}5+6+export function renderSecretCommand(container: Element, verb: string, secret: string) {7+ const command = `devsite ${verb} ${secret}`;8+ const group = document.createElement('div');9+ group.className = 'secret-command';10+ group.setAttribute('role', 'group');11+ group.setAttribute('aria-label', `devsite ${verb} command`);12+13+ const code = document.createElement('code');14+ const executable = document.createElement('span');15+ executable.className = 'command-executable';16+ executable.textContent = 'devsite';17+ const action = document.createElement('span');18+ action.className = 'command-verb';19+ action.textContent = verb;20+ const masked = document.createElement('span');21+ masked.className = 'command-secret';22+ masked.textContent = maskedSecret(secret);23+ code.append(executable, ' ', action, ' ', masked);24+25+ const button = document.createElement('button');26+ button.type = 'button';27+ button.textContent = 'Copy';28+ const status = document.createElement('small');29+ status.className = 'secret-copy-status';30+ status.setAttribute('aria-live', 'polite');31+ button.addEventListener('click', async () => {32+ await navigator.clipboard.writeText(command);33+ status.textContent = 'Copied.';34+ });35+36+ group.append(code, button);37+ container.replaceChildren(group, status);38+}@@ -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+}@@ -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+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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+}@@ -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+}