From 2a85c3f7e4995b9af4d6be7d4bcda3f2286aa260 Mon Sep 17 00:00:00 2001 From: hude Date: Wed, 3 Jun 2026 16:05:04 +0900 Subject: [PATCH 1/7] Add wallet-funded create database flow --- crates/vfs_cli_app/src/cli.rs | 10 +- crates/vfs_cli_app/src/commands_fs_tests.rs | 4 + crates/vfs_cli_app/src/main.rs | 38 ++- crates/vfs_cli_core/src/cli.rs | 1 - crates/vfs_cli_core/src/commands.rs | 210 ++++---------- docs/CLI.md | 6 +- docs/payment.md | 5 +- wikibrowser/app/create-database-dialog.tsx | 4 +- wikibrowser/app/cycles/cycles-client.tsx | 8 +- wikibrowser/app/cycles/page.tsx | 3 +- .../app/dashboard/dashboard-client.tsx | 91 ++++-- wikibrowser/app/dashboard/dashboard-ui.tsx | 88 +++--- wikibrowser/app/home-ui.tsx | 155 +++++++++-- wikibrowser/app/page.tsx | 260 ++++++++++++++---- .../app/skills/skill-registry-client.tsx | 51 ++-- wikibrowser/components/admin-header.tsx | 26 ++ wikibrowser/components/document-pane.tsx | 83 ++++-- wikibrowser/components/wiki-browser.tsx | 42 +-- wikibrowser/lib/cycles-wallet.ts | 102 ++++++- wikibrowser/package.json | 2 +- wikibrowser/scripts/check-cycles-wallet.mjs | 209 ++++++++++++++ wikibrowser/scripts/check-cycles.mjs | 127 +-------- wikibrowser/scripts/check-dashboard.mjs | 93 ++++++- wikibrowser/scripts/check-skill-registry.mjs | 4 + wikibrowser/scripts/check-ui-helpers.mjs | 7 +- 25 files changed, 1116 insertions(+), 513 deletions(-) create mode 100644 wikibrowser/components/admin-header.tsx create mode 100644 wikibrowser/scripts/check-cycles-wallet.mjs diff --git a/crates/vfs_cli_app/src/cli.rs b/crates/vfs_cli_app/src/cli.rs index ed2589b0..e8bc0344 100644 --- a/crates/vfs_cli_app/src/cli.rs +++ b/crates/vfs_cli_app/src/cli.rs @@ -1104,7 +1104,6 @@ mod tests { "database", "cycles", "db_alpha", - "1.25", "--browser-origin", "http://127.0.0.1:3000", ]); @@ -1112,7 +1111,6 @@ mod tests { command: DatabaseCommand::Cycles { database_id, - kinic, browser_origin, }, } = cli.command @@ -1120,8 +1118,11 @@ mod tests { panic!("expected database cycles command"); }; assert_eq!(database_id, "db_alpha"); - assert_eq!(kinic, "1.25"); assert_eq!(browser_origin.as_deref(), Some("http://127.0.0.1:3000")); + assert!( + Cli::try_parse_from(["kinic-vfs-cli", "database", "cycles", "db_alpha", "1.25"]) + .is_err() + ); let cli = Cli::parse_from(["kinic-vfs-cli", "database", "cycles-history", "db_alpha"]); let Command::Database { @@ -1278,8 +1279,7 @@ mod tests { Cli::parse_from(["kinic-vfs-cli", "database", "cycles-history", "db_alpha"]); assert!(database_cycles_history.command.requires_identity()); - let database_cycles = - Cli::parse_from(["kinic-vfs-cli", "database", "cycles", "db_alpha", "1.25"]); + let database_cycles = Cli::parse_from(["kinic-vfs-cli", "database", "cycles", "db_alpha"]); assert!(!database_cycles.command.requires_identity()); } diff --git a/crates/vfs_cli_app/src/commands_fs_tests.rs b/crates/vfs_cli_app/src/commands_fs_tests.rs index c20b221c..63d4fcd6 100644 --- a/crates/vfs_cli_app/src/commands_fs_tests.rs +++ b/crates/vfs_cli_app/src/commands_fs_tests.rs @@ -67,6 +67,10 @@ impl VfsApi for MockClient { }) } + async fn check_database_write_cycles(&self, _database_id: &str) -> Result<()> { + Ok(()) + } + async fn list_databases(&self) -> Result> { Ok(vec![DatabaseSummary { database_id: "default".to_string(), diff --git a/crates/vfs_cli_app/src/main.rs b/crates/vfs_cli_app/src/main.rs index d85d9ca1..b3377345 100644 --- a/crates/vfs_cli_app/src/main.rs +++ b/crates/vfs_cli_app/src/main.rs @@ -3,7 +3,9 @@ // Why: Wiki operations and Skill Registry operations share connection, identity, and DB selection. use anyhow::Result; use clap::Parser; -use vfs_cli::commands::{print_database_current, run_database_unlink}; +use vfs_cli::commands::{ + database_cycles_url, open_browser_url, print_database_current, run_database_unlink, +}; use vfs_cli::connection::{ ResolvedConnection, resolve_connection, resolve_connection_optional_canister, }; @@ -37,6 +39,15 @@ async fn main() -> Result<()> { run_database_unlink()?; return Ok(()); } + DatabaseCommand::Cycles { + database_id, + browser_origin, + } => { + let url = database_cycles_url(browser_origin.as_deref(), database_id)?; + println!("{url}"); + open_browser_url(&url)?; + return Ok(()); + } _ => {} } } @@ -146,3 +157,28 @@ async fn new_identity_client( ) .await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn database_cycles_url_resolves_without_connection_or_client() { + let command = DatabaseCommand::Cycles { + database_id: "db_alpha".to_string(), + browser_origin: Some("http://127.0.0.1:3000".to_string()), + }; + let DatabaseCommand::Cycles { + database_id, + browser_origin, + } = command + else { + panic!("expected cycles command"); + }; + + let url = database_cycles_url(browser_origin.as_deref(), &database_id) + .expect("cycles URL should build without canister client"); + + assert_eq!(url, "http://127.0.0.1:3000/cycles?database_id=db_alpha"); + } +} diff --git a/crates/vfs_cli_core/src/cli.rs b/crates/vfs_cli_core/src/cli.rs index 3550c209..7d2d7bc2 100644 --- a/crates/vfs_cli_core/src/cli.rs +++ b/crates/vfs_cli_core/src/cli.rs @@ -286,7 +286,6 @@ pub enum DatabaseCommand { #[command(about = "Open the browser cycles purchase page for one database")] Cycles { database_id: String, - kinic: String, #[arg(long)] browser_origin: Option, }, diff --git a/crates/vfs_cli_core/src/commands.rs b/crates/vfs_cli_core/src/commands.rs index 2271e6c9..9bf852ba 100644 --- a/crates/vfs_cli_core/src/commands.rs +++ b/crates/vfs_cli_core/src/commands.rs @@ -7,19 +7,18 @@ use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Result, anyhow}; use serde::Deserialize; use sha2::{Digest, Sha256}; use vfs_client::VfsApi; use vfs_types::{ AppendNodeRequest, CyclesBillingConfig, DatabaseCyclesPurchaseRequest, - DatabaseRestoreChunkRequest, DatabaseSummary, DeleteNodeRequest, DeleteNodeResult, - EditNodeRequest, GlobNodesRequest, GraphLinksRequest, GraphNeighborhoodRequest, - IncomingLinksRequest, KINIC_DECIMALS, KINIC_LEDGER_FEE_E8S, LinkEdge, ListChildrenRequest, - ListNodesRequest, MkdirNodeRequest, MoveNodeRequest, MultiEdit, MultiEditNodeRequest, - NodeContextRequest, NodeEntryKind, NodeKind, OutgoingLinksRequest, SearchNodePathsRequest, - SearchNodesRequest, WriteNodeItem, WriteNodeRequest, WriteNodesRequest, - kinic_base_units_per_token, + DatabaseRestoreChunkRequest, DeleteNodeRequest, DeleteNodeResult, EditNodeRequest, + GlobNodesRequest, GraphLinksRequest, GraphNeighborhoodRequest, IncomingLinksRequest, + KINIC_DECIMALS, KINIC_LEDGER_FEE_E8S, LinkEdge, ListChildrenRequest, ListNodesRequest, + MkdirNodeRequest, MoveNodeRequest, MultiEdit, MultiEditNodeRequest, NodeContextRequest, + NodeEntryKind, NodeKind, OutgoingLinksRequest, SearchNodePathsRequest, SearchNodesRequest, + WriteNodeItem, WriteNodeRequest, WriteNodesRequest, kinic_base_units_per_token, }; use wiki_domain::validate_source_path_for_kind; @@ -489,42 +488,7 @@ fn command_requires_write_cycles_available(command: &VfsCommand) -> bool { } async fn require_write_cycles_available(client: &impl VfsApi, database_id: &str) -> Result<()> { - let config = client - .get_cycles_billing_config() - .await - .context("cycles config unavailable")?; - let databases = client - .list_databases() - .await - .context("database list unavailable for cycles check")?; - let database = databases - .iter() - .find(|database| database.database_id == database_id) - .ok_or_else(|| anyhow!("database cycles state unavailable: {database_id}"))?; - if let Some(reason) = database_cycles_disabled_reason(database, &config) { - return Err(anyhow!("{reason}")); - } - Ok(()) -} - -fn database_cycles_disabled_reason( - database: &DatabaseSummary, - config: &CyclesBillingConfig, -) -> Option { - let balance = database.cycles_balance.unwrap_or(0); - if database.cycles_suspended_at_ms.is_some() { - return Some(format!( - "database cycles are suspended: {}", - database.database_id - )); - } - if balance < config.min_update_cycles { - return Some(format!( - "database cycles balance is below minimum: {} balance_cycles={} min_update_cycles={}", - database.database_id, balance, config.min_update_cycles - )); - } - None + client.check_database_write_cycles(database_id).await } fn print_links(links: Vec, json: bool) -> Result<()> { @@ -740,12 +704,9 @@ async fn run_database_command( } DatabaseCommand::Cycles { database_id, - kinic, browser_origin, } => { - let url = database_cycles_url(browser_origin.as_deref(), &database_id, &kinic)?; - open_browser_url(&url)?; - println!("{url}"); + open_database_cycles_page(browser_origin.as_deref(), &database_id)?; } DatabaseCommand::Link { database_id } => { let path = link_workspace_database(connection, &database_id)?; @@ -828,13 +789,13 @@ async fn run_database_command( Ok(()) } -fn database_cycles_url( - browser_origin: Option<&str>, - database_id: &str, - kinic: &str, -) -> Result { - let kinic = kinic.trim(); - parse_kinic_amount_e8s(kinic)?; +pub fn open_database_cycles_page(browser_origin: Option<&str>, database_id: &str) -> Result<()> { + let url = database_cycles_url(browser_origin, database_id)?; + println!("{url}"); + open_browser_url(&url) +} + +pub fn database_cycles_url(browser_origin: Option<&str>, database_id: &str) -> Result { let origin = browser_origin .map(str::to_string) .or_else(|| std::env::var("KINIC_WIKI_BROWSER_ORIGIN").ok()) @@ -844,9 +805,8 @@ fn database_cycles_url( return Err(anyhow!("browser origin must not be empty")); } Ok(format!( - "{origin}/cycles?databaseId={}&kinic={}", - query_encode(database_id), - query_encode(kinic) + "{origin}/cycles?database_id={}", + query_encode(database_id) )) } @@ -916,7 +876,7 @@ fn query_encode(value: &str) -> String { encoded } -fn open_browser_url(url: &str) -> Result<()> { +pub fn open_browser_url(url: &str) -> Result<()> { let status = if cfg!(target_os = "macos") { ProcessCommand::new("open").arg(url).status() } else if cfg!(target_os = "windows") { @@ -1427,6 +1387,8 @@ mod tests { database_summaries: Mutex>, cycles_configs: Mutex, fail_cycles_config: Mutex, + write_cycle_checks: Mutex>, + write_cycle_check_error: Mutex>, writes: Mutex>, write_batches: Mutex>, deletes: Mutex>, @@ -1492,23 +1454,6 @@ mod tests { } } - fn database_summary( - database_id: &str, - balance_cycles: Option, - suspended_at_ms: Option, - ) -> DatabaseSummary { - DatabaseSummary { - database_id: database_id.to_string(), - name: database_id.to_string(), - status: DatabaseStatus::Active, - role: DatabaseRole::Owner, - logical_size_bytes: 42, - cycles_balance: balance_cycles, - cycles_suspended_at_ms: suspended_at_ms, - archived_at_ms: None, - } - } - #[async_trait] impl VfsApi for MockClient { async fn status(&self, _database_id: &str) -> Result { @@ -1593,6 +1538,16 @@ mod tests { min_update_cycles: 1, }) } + async fn check_database_write_cycles(&self, database_id: &str) -> Result<()> { + self.write_cycle_checks + .lock() + .unwrap() + .push(database_id.to_string()); + if let Some(error) = self.write_cycle_check_error.lock().unwrap().take() { + return Err(anyhow!(error)); + } + Ok(()) + } async fn rename_database(&self, _database_id: &str, _name: &str) -> Result<()> { Ok(()) } @@ -1844,16 +1799,13 @@ mod tests { } #[tokio::test] - async fn write_node_rejects_low_cycles_balance_before_write() { + async fn mutating_command_checks_write_cycles_before_write() { let dir = tempdir().expect("temp dir should exist"); let input = PathBuf::from(dir.path()).join("source.md"); std::fs::write(&input, "# Source").expect("input should write"); - let client = MockClient { - database_summaries: Mutex::new(vec![database_summary("alpha", Some(0), None)]), - ..MockClient::default() - }; + let client = MockClient::default(); - let error = run_vfs_command( + run_vfs_command( &client, &test_connection(), VfsCommand::WriteNode { @@ -1866,16 +1818,19 @@ mod tests { }, ) .await - .expect_err("low balance should reject"); + .expect("write should pass after cycles check"); - assert!(error.to_string().contains("balance is below minimum")); - assert!(client.writes.lock().unwrap().is_empty()); + assert_eq!( + *client.write_cycle_checks.lock().unwrap(), + vec!["alpha".to_string()] + ); + assert_eq!(client.writes.lock().unwrap().len(), 1); } #[tokio::test] - async fn mkdir_node_rejects_suspended_cycles_before_write() { + async fn mutating_command_rejects_canister_write_cycles_error_before_write() { let client = MockClient { - database_summaries: Mutex::new(vec![database_summary("alpha", Some(20_000), Some(1))]), + write_cycle_check_error: Mutex::new(Some("database cycles are suspended".to_string())), ..MockClient::default() }; @@ -1888,66 +1843,13 @@ mod tests { }, ) .await - .expect_err("suspended cycles should reject"); + .expect_err("canister cycles check should reject"); assert!(error.to_string().contains("cycles are suspended")); - } - - #[tokio::test] - async fn mutating_commands_reject_missing_cycles_config_before_write() { - let dir = tempdir().expect("temp dir should exist"); - let input = PathBuf::from(dir.path()).join("source.md"); - std::fs::write(&input, "# Source").expect("input should write"); - let client = MockClient { - fail_cycles_config: Mutex::new(true), - ..MockClient::default() - }; - - let error = run_vfs_command( - &client, - &test_connection(), - VfsCommand::WriteNode { - path: "/Sources/raw/source/source.md".to_string(), - kind: NodeKindArg::Source, - input, - metadata_json: "{}".to_string(), - expected_etag: None, - json: false, - }, - ) - .await - .expect_err("missing config should reject"); - - assert!(error.to_string().contains("cycles config unavailable")); - assert!(client.writes.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn mutating_commands_reject_missing_database_summary_before_write() { - let dir = tempdir().expect("temp dir should exist"); - let input = PathBuf::from(dir.path()).join("source.md"); - std::fs::write(&input, "# Source").expect("input should write"); - let client = MockClient { - database_summaries: Mutex::new(vec![database_summary("other", Some(20_000), None)]), - ..MockClient::default() - }; - - let error = run_vfs_command( - &client, - &test_connection(), - VfsCommand::WriteNode { - path: "/Sources/raw/source/source.md".to_string(), - kind: NodeKindArg::Source, - input, - metadata_json: "{}".to_string(), - expected_etag: None, - json: false, - }, - ) - .await - .expect_err("missing summary should reject"); - - assert!(error.to_string().contains("cycles state unavailable")); + assert_eq!( + *client.write_cycle_checks.lock().unwrap(), + vec!["alpha".to_string()] + ); assert!(client.writes.lock().unwrap().is_empty()); } @@ -2416,23 +2318,17 @@ mod tests { #[test] fn database_cycles_url_uses_browser_origin() { - let url = super::database_cycles_url(Some("http://127.0.0.1:3000/"), "db alpha", "1.25") + let url = super::database_cycles_url(Some("http://127.0.0.1:3000/"), "db alpha") .expect("url should build"); - assert_eq!( - url, - "http://127.0.0.1:3000/cycles?databaseId=db%20alpha&kinic=1.25" - ); + assert_eq!(url, "http://127.0.0.1:3000/cycles?database_id=db%20alpha"); } #[test] - fn database_cycles_url_rejects_invalid_kinic_amount() { - for kinic in ["0", "0.000000001", "abc", "184467440737.09551616"] { - let error = - super::database_cycles_url(Some("http://127.0.0.1:3000/"), "db_alpha", kinic) - .expect_err("invalid KINIC amount should reject"); - assert!(error.to_string().contains("KINIC amount")); - } + fn database_cycles_url_rejects_empty_browser_origin() { + let error = + super::database_cycles_url(Some(""), "db_alpha").expect_err("empty origin should fail"); + assert!(error.to_string().contains("browser origin")); } #[tokio::test] diff --git a/docs/CLI.md b/docs/CLI.md index 9929994a..68f0b133 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -83,7 +83,7 @@ cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- --canister-id cy DB_ID="$(cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- --canister-id database create "")" cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- --canister-id database list cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- --canister-id database purchase-cycles "$DB_ID" 1.25 -cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- --canister-id database cycles "$DB_ID" 1.25 +cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- --canister-id database cycles "$DB_ID" cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- --canister-id database cycles-history "$DB_ID" cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- --canister-id database cycles-pending "$DB_ID" cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- --canister-id database grant "$DB_ID" reader @@ -95,11 +95,11 @@ cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- search-remote "budget" --prefi `cycles config` prints the KINIC ledger canister, billing authority principal, `cycles_per_kinic`, `min_update_cycles`, and fixed ledger transfer fee `100_000 e8s`. `database create ` creates a generated pending database ID with zero DB cycles balance and prints it on success. It does not allocate a DB mount until the first successful cycle purchase. `database purchase-cycles ` pulls the KINIC payment from the caller through the ledger allowance already approved outside the CLI and adds raw cycles to the DB cycles balance. Any authenticated payer can purchase cycles for an existing DB. The allowance must include the fixed ledger transfer fee. -`database cycles ` opens `https://wiki.kinic.xyz/cycles?...` for wallet-based OISY or Plug funding. This command does not use the CLI identity. The browser flow is limited to the configured canonical wiki canister, approves `payment_amount_e8s + ledger_fee_e8s` with a 30 minute expiry, and purchases cycles using the current canister config. The wallet also pays the approve transaction fee from its balance. The first successful purchase activates a pending DB. +`database cycles ` prints and opens `https://wiki.kinic.xyz/cycles?...` for wallet-based OISY or Plug funding. This command does not use the CLI identity or contact the canister, so it can still print the payment URL when the local replica is stopped. Pass `--browser-origin` or set `KINIC_WIKI_BROWSER_ORIGIN` for local or staging browser hosts. The purchase amount is entered in the browser flow. The browser flow is limited to the configured canonical wiki canister, approves `payment_amount_e8s + ledger_fee_e8s` with a 30 minute expiry, and purchases cycles using the current canister config. The wallet also pays the approve transaction fee from its balance. The first successful purchase activates a pending DB. `database cycles-history [--json]` lists DB cycles ledger entries. Reader and writer principals see payer/caller principals as `redacted`; DB owner and billing authority see full details. `database cycles-pending [--json]` lists pending purchase operations visible to the DB owner, billing authority, or payer. Output includes `operation_id`, `status`, and `required_action`. `database list` prints databases attached to the caller principal, including DB cycles balance and suspension time. -Successful DB updates consume DB cycles balance. Browser write surfaces disable writes when the DB is suspended, below `min_update_cycles`, or cycles config cannot be loaded. URL ingest and query-answer sessions are checked again before external Worker or DeepSeek execution, so a session issued before suspension can still fail after DB cycles balance changes. +Successful DB updates consume DB cycles balance. CLI write commands use the canister `check_database_write_cycles` preflight before mutation. Browser write surfaces disable writes when the DB is suspended, below `min_update_cycles`, or cycles config cannot be loaded. URL ingest and query-answer sessions are checked again before external Worker or DeepSeek execution, so a session issued before suspension can still fail after DB cycles balance changes. Database names are a breaking index-schema change. Existing local or canister index databases from older builds must be recreated; no automatic backfill is provided. diff --git a/docs/payment.md b/docs/payment.md index 5a8810a4..b6a2fba7 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -119,9 +119,10 @@ browser `/cycles` は approve 後の purchase failure でも通常の error 表 `/cycles` route は canister ID を URL から受け取らない。`NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID` を canister ID として使う。query から読む値は以下だけである。 - `database_id` または `databaseId` -- `kinic` -`database_id` は必須で、`[a-zA-Z0-9_-]+` のみ許可される。`kinic` は初期入力値であり、購入額は UI 上で編集できる。未指定時の初期入力は `1`。 +`database_id` は必須で、`[a-zA-Z0-9_-]+` のみ許可される。購入額は UI 上で編集できる。初期入力は `1`。 + +dashboard の `Create database` は OISY または Plug 接続済み、かつ接続 wallet の KINIC 残高が `1 KINIC` 以上の場合だけ押せる。作成後は同じ wallet で `1 KINIC` の approve と `purchase_database_cycles` を連続実行する。残高未取得、wallet 未接続、または `1 KINIC` 未満では DB 作成自体を開始しない。 KINIC 入力は正の数だけ許可する。小数は最大 8 桁、URL/UI parser 上の e8s 換算値は `u64::MAX` 以下でなければならない。購入直前の wallet flow は canister 実効上限として `payment_amount_e8s <= i64::MAX` と `amount_cycles <= i64::MAX` も確認する。 diff --git a/wikibrowser/app/create-database-dialog.tsx b/wikibrowser/app/create-database-dialog.tsx index e8d01c16..9fbddcd9 100644 --- a/wikibrowser/app/create-database-dialog.tsx +++ b/wikibrowser/app/create-database-dialog.tsx @@ -38,7 +38,9 @@ export function CreateDatabaseDialog({

Create database

-

A generated database ID will be used for routes and access. The database activates after its first purchase.

+

+ Connect a wallet with at least 1 KINIC before creating. New databases are created pending, not active, until the first purchase completes. +

+ ) : null + } + nav={ + <> + + Database dashboard - ) : null} -

{database?.name ?? "Database access"}

-

{databaseId || "unknown database"}

-
-
- {canisterId ? : null} - -
- + {databaseId && isActiveDatabase ? ( + + Skill Registry + + ) : null} + + } + actions={ + <> + {canisterId ? : null} + + + } + /> {error ? : null} {warning ? : null} {actionMessage ? : null} + {renameOpen && database ? ( + setRenameOpen(false)} + onChange={setRenameDraft} + onSubmit={(name) => void submitRename(name)} + /> + ) : null} {database ? : null} @@ -301,7 +351,6 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) principal={principal ?? "anonymous"} onDelete={deleteDatabase} onGrant={grantAccess} - onRename={renameDatabase} onRevoke={revokeAccess} /> ) : database.publicReadable ? ( diff --git a/wikibrowser/app/dashboard/dashboard-ui.tsx b/wikibrowser/app/dashboard/dashboard-ui.tsx index 297d9859..4ec683ce 100644 --- a/wikibrowser/app/dashboard/dashboard-ui.tsx +++ b/wikibrowser/app/dashboard/dashboard-ui.tsx @@ -25,7 +25,7 @@ export function AuthControls(props: { authReady: boolean; loading: boolean; prin if (!props.principal) { return ( - Login with Internet Identity + Internet Identity ); } @@ -108,11 +108,9 @@ export function OwnerPanel(props: { principal: string; onDelete: () => Promise; onGrant: (principalText: string, role: DatabaseRole) => void; - onRename: (name: string) => void; onRevoke: (principalText: string) => void; }) { const [pendingAction, setPendingAction] = useState(null); - const [databaseName, setDatabaseName] = useState(props.databaseName); const publicMember = props.members.find((member) => member.principal === ANONYMOUS_PRINCIPAL); const publicEnabled = Boolean(publicMember); const publicBusy = isBusyGrant(props.busyAction, ANONYMOUS_PRINCIPAL, "reader") || isBusyRevoke(props.busyAction, ANONYMOUS_PRINCIPAL); @@ -225,33 +223,14 @@ export function OwnerPanel(props: { } setPendingAction(null); } - function submitRename(event: FormEvent) { - event.preventDefault(); - const nextName = databaseName.trim(); - if (!nextName || nextName === props.databaseName || props.busy) return; - props.onRename(nextName); - } return (

Members

-
- - - Rename - -
- publicMember && requestRevoke(publicMember)} onEnable={() => requestGrant(ANONYMOUS_PRINCIPAL, "reader")} /> - llmWriterMember && requestRevoke(llmWriterMember)} onEnable={() => requestGrant(LLM_WRITER_PRINCIPAL, "writer")} /> +
+ publicMember && requestRevoke(publicMember)} onEnable={() => requestGrant(ANONYMOUS_PRINCIPAL, "reader")} /> + llmWriterMember && requestRevoke(llmWriterMember)} onEnable={() => requestGrant(LLM_WRITER_PRINCIPAL, "writer")} /> +

URL ingest trigger sessions are valid for 30 minutes. Revoking writer access does not immediately invalidate an already issued session ticket before it expires.

@@ -271,6 +250,50 @@ export function OwnerPanel(props: { ); } +export function RenameDatabaseDialog(props: { + busy: boolean; + busyAction: BusyAction | null; + databaseName: string; + draft: string; + onCancel: () => void; + onChange: (value: string) => void; + onSubmit: (name: string) => void; +}) { + const trimmed = props.draft.trim(); + const renameBusy = props.busyAction?.kind === "rename"; + const submitDisabled = props.busy || trimmed === "" || trimmed === props.databaseName; + function submit(event: FormEvent) { + event.preventDefault(); + if (submitDisabled) return; + props.onSubmit(trimmed); + } + return ( +
+
+

Rename database

+ +
+ + Cancel + + + Rename + +
+
+
+ ); +} + export function ReadonlyMembersPanel(props: { memberError: string | null; members: DatabaseMember[]; principal: string }) { return (
@@ -351,16 +374,11 @@ function ConfirmAclDialog(props: { action: PendingAclAction; busy: boolean; busy ); } -function AclQuickAction(props: { label: string; enabled: boolean; busy: boolean; actionBusy: boolean; enabledLabel: string; disabledLabel: string; onDisable: () => void; onEnable: () => void }) { +function AclQuickAction(props: { enabled: boolean; busy: boolean; actionBusy: boolean; enabledLabel: string; disabledLabel: string; onDisable: () => void; onEnable: () => void }) { return ( -
-

- {props.label}: {props.enabled ? "enabled" : "disabled"} -

- - {props.enabled ? props.enabledLabel : props.disabledLabel} - -
+ + {props.enabled ? props.enabledLabel : props.disabledLabel} + ); } diff --git a/wikibrowser/app/home-ui.tsx b/wikibrowser/app/home-ui.tsx index b9a82d13..913ace4d 100644 --- a/wikibrowser/app/home-ui.tsx +++ b/wikibrowser/app/home-ui.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { BookOpen, Settings, Share2, Wallet } from "lucide-react"; +import { BookOpen, PlugZap, PowerOff, Settings, Share2, TerminalSquare, Wallet } from "lucide-react"; import type { ReactNode } from "react"; import { formatCycles as formatCycleBalance } from "@/lib/cycles"; import { databaseCyclesView, databaseCyclesHref, type DatabaseCycleView } from "@/lib/cycles-state"; @@ -16,6 +16,113 @@ export type DatabaseRow = DatabaseSummary & { publicReadable: boolean; }; +export type HeaderWalletProvider = "oisy" | "plug"; + +export function WalletControls({ + balanceLoading, + busyProvider, + connectedBalanceLabel, + connectedLabel, + connectedProvider, + disabled, + onConnect, + onDisconnect +}: { + balanceLoading: boolean; + busyProvider: HeaderWalletProvider | null; + connectedBalanceLabel: string | null; + connectedLabel: string | null; + connectedProvider: HeaderWalletProvider | null; + disabled: boolean; + onConnect: (provider: HeaderWalletProvider) => void; + onDisconnect: (provider: HeaderWalletProvider) => void; +}) { + const oisyConnected = connectedProvider === "oisy"; + const plugConnected = connectedProvider === "plug"; + return ( +
+ : null} + icon={} + label="OISY" + onClick={() => (oisyConnected ? onDisconnect("oisy") : onConnect("oisy"))} + /> + : null} + icon={} + label="Plug" + onClick={() => (plugConnected ? onDisconnect("plug") : onConnect("plug"))} + /> +
+ ); +} + +function WalletConnectButton({ + ariaLabel, + balanceLabel, + balanceLoading, + busy, + connected, + connectedLabel, + disabled, + hoverIcon, + icon, + label, + onClick +}: { + ariaLabel?: string; + balanceLabel: string | null; + balanceLoading: boolean; + busy: boolean; + connected: boolean; + connectedLabel: string | null; + disabled: boolean; + hoverIcon: ReactNode | null; + icon: ReactNode; + label: string; + onClick: () => void; +}) { + const classes = connected + ? "border-action bg-action text-white hover:border-accent hover:bg-accent" + : "border-line bg-white text-ink hover:border-accent hover:text-accent"; + const primaryLabel = busy ? "Connecting..." : connectedLabel ?? label; + const secondaryLabel = balanceLoading ? "Loading KINIC" : balanceLabel; + return ( + + ); +} + export function AuthControls({ authReady, principal, @@ -34,13 +141,13 @@ export function AuthControls({ if (!principal) { return ( ); } @@ -58,6 +165,7 @@ export function AuthControls({ } export function DatabaseBody({ + createDatabaseAction, cyclesConfig, loading, myDatabases, @@ -65,6 +173,7 @@ export function DatabaseBody({ publicError, publicDatabases }: { + createDatabaseAction?: ReactNode; cyclesConfig: CyclesBillingConfig | null; loading: boolean; myDatabases: DatabaseRow[]; @@ -78,7 +187,7 @@ export function DatabaseBody({ } return (
- +
); @@ -99,9 +208,9 @@ export function OfficialKinicWikiPanel() { Open - - - Manage + + + CLI
@@ -110,6 +219,7 @@ export function OfficialKinicWikiPanel() { } function DatabaseSection({ + action, cyclesConfig, description, emptyMessage, @@ -119,6 +229,7 @@ function DatabaseSection({ showTitle = true, title }: { + action?: ReactNode; cyclesConfig: CyclesBillingConfig | null; description?: string; emptyMessage: string; @@ -130,23 +241,16 @@ function DatabaseSection({ }) { if (rows.length === 0) { return ( -
- {showTitle ?

{title}

: null} - {showTitle && description ?

{description}

: null} - {publicError && mode === "public" ?

{publicError}

: null} -

{emptyMessage}

+
+ {showTitle ? : null} + {publicError && mode === "public" ?

{publicError}

: null} +

{emptyMessage}

); } return (
- {showTitle ? ( -
-

{title}

- {description ?

{description}

: null} - {publicError && mode === "public" ?

{publicError}

: null} -
- ) : null} + {showTitle ? : null} {!showTitle && publicError && mode === "public" ?

{publicError}

: null}
{rows.map((database) => ( @@ -179,6 +283,19 @@ function DatabaseSection({ ); } +function DatabaseSectionHeader({ action, description, publicError = null, title }: { action?: ReactNode; description?: string; publicError?: string | null; title: string }) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} + {publicError ?

{publicError}

: null} +
+ {action ?
{action}
: null} +
+ ); +} + function DatabaseTableRow({ cyclesConfig, database, mode }: { cyclesConfig: CyclesBillingConfig | null; database: DatabaseRow; mode: "member" | "public" }) { const active = isActiveRoutableDatabase(database); const cycles = databaseCyclesView(database, cyclesConfig); diff --git a/wikibrowser/app/page.tsx b/wikibrowser/app/page.tsx index c68255dd..f3f7719f 100644 --- a/wikibrowser/app/page.tsx +++ b/wikibrowser/app/page.tsx @@ -2,21 +2,27 @@ import { AuthClient } from "@icp-sdk/auth/client"; import { useCallback, useEffect, useRef, useState } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { Plus, TerminalSquare } from "lucide-react"; +import { Plus } from "lucide-react"; import { CreateDatabaseDialog } from "./create-database-dialog"; -import { AuthControls, CreatedDatabasePanel, DatabaseBody, OfficialKinicWikiPanel, StatusPanel } from "./home-ui"; +import { AuthControls, DatabaseBody, OfficialKinicWikiPanel, StatusPanel, WalletControls, type HeaderWalletProvider } from "./home-ui"; +import { AdminHeader } from "@/components/admin-header"; import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; +import { parseKinicAmountE8sInput } from "@/lib/cycles-url"; +import { connectOisyWallet, connectPlugWallet, getConnectedWalletKinicBalance, purchaseCyclesWithOisy, purchaseCyclesWithPlug, type ConnectedKinicWallet } from "@/lib/cycles-wallet"; +import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; import type { CyclesBillingConfig, DatabaseSummary } from "@/lib/types"; import { createDatabaseAuthenticated, getCyclesBillingConfig, listDatabasesAuthenticated, listDatabasesPublic } from "@/lib/vfs-client"; import type { DatabaseRow } from "./home-ui"; type LoadState = "idle" | "loading" | "ready" | "error"; +type ConnectedHeaderWallet = ConnectedKinicWallet; + +const CREATE_DATABASE_PURCHASE_KINIC = "1"; export default function HomePage() { const canisterId = process.env.NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID ?? ""; const refreshSeqRef = useRef(0); + const walletBalanceSeqRef = useRef(0); const [authClient, setAuthClient] = useState(null); const [principal, setPrincipal] = useState(null); const [databases, setDatabases] = useState([]); @@ -25,7 +31,12 @@ export default function HomePage() { const [error, setError] = useState(null); const [publicError, setPublicError] = useState(null); const [warning, setWarning] = useState(null); - const [createdDatabase, setCreatedDatabase] = useState<{ databaseId: string; name: string } | null>(null); + const [walletMessage, setWalletMessage] = useState(null); + const [wallet, setWallet] = useState(null); + const [walletBalance, setWalletBalance] = useState(null); + const [walletBalanceLoading, setWalletBalanceLoading] = useState(false); + const [walletBalanceError, setWalletBalanceError] = useState(null); + const [walletBusyProvider, setWalletBusyProvider] = useState(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newDatabaseName, setNewDatabaseName] = useState(""); const [creating, setCreating] = useState(false); @@ -116,14 +127,74 @@ export default function HomePage() { await authClient.logout(); setPrincipal(null); setCyclesBillingConfig(null); - setCreatedDatabase(null); setCreateDialogOpen(false); setNewDatabaseName(""); setError(null); setPublicError(null); + setWalletMessage(null); + walletBalanceSeqRef.current += 1; + setWallet(null); + setWalletBalance(null); + setWalletBalanceLoading(false); + setWalletBalanceError(null); await refreshDatabases(null); } + async function refreshWalletBalance(nextWallet: ConnectedHeaderWallet) { + const balanceSeq = (walletBalanceSeqRef.current += 1); + const isCurrentBalance = () => balanceSeq === walletBalanceSeqRef.current; + setWalletBalance(null); + setWalletBalanceLoading(true); + setWalletBalanceError(null); + try { + const balance = await getConnectedWalletKinicBalance(canisterId, nextWallet); + if (!isCurrentBalance()) return; + setWalletBalance(balance); + } catch (cause) { + if (!isCurrentBalance()) return; + setWalletBalance(null); + setWalletBalanceError(`KINIC balance unavailable: ${errorMessage(cause)}`); + } finally { + if (!isCurrentBalance()) return; + setWalletBalanceLoading(false); + } + } + + async function connectWallet(provider: HeaderWalletProvider) { + if (creating || walletBusyProvider) return; + setWalletBusyProvider(provider); + setError(null); + setWalletMessage(null); + try { + if (provider === "oisy") { + const connection = await connectOisyWallet(); + const nextWallet: ConnectedHeaderWallet = { provider, connection }; + setWallet(nextWallet); + void refreshWalletBalance(nextWallet); + } else { + const connection = await connectPlugWallet(); + const nextWallet: ConnectedHeaderWallet = { provider, connection }; + setWallet(nextWallet); + void refreshWalletBalance(nextWallet); + } + } catch (cause) { + setError(errorMessage(cause)); + setLoadState("error"); + } finally { + setWalletBusyProvider(null); + } + } + + function disconnectWallet(provider: HeaderWalletProvider) { + if (creating || walletBusyProvider || wallet?.provider !== provider) return; + walletBalanceSeqRef.current += 1; + setWallet(null); + setWalletBalance(null); + setWalletBalanceLoading(false); + setWalletBalanceError(null); + setWalletMessage(null); + } + async function createDatabase() { if (!authClient || !canisterId) return; const databaseNameInput = newDatabaseName.trim(); @@ -133,16 +204,44 @@ export default function HomePage() { setLoadState("error"); return; } + if (!wallet) { + setError(`Connect OISY or Plug with at least ${formatTokenAmountFromE8s(createDatabasePurchaseAmountE8s())} before creating a database.`); + setLoadState("error"); + return; + } + if (!walletCanFundCreate(walletBalance)) { + setError(`Create database requires at least ${formatTokenAmountFromE8s(createDatabasePurchaseAmountE8s())} in the connected wallet.`); + setLoadState("error"); + return; + } setCreating(true); setError(null); + setWalletMessage(null); + let createdDatabaseId: string | null = null; try { const result = await createDatabaseAuthenticated(canisterId, authClient.getIdentity(), databaseNameInput); - setCreatedDatabase({ databaseId: result.database_id, name: result.name }); + createdDatabaseId = result.database_id; setCreateDialogOpen(false); setNewDatabaseName(""); + const paymentAmountE8s = createDatabasePurchaseAmountE8s(); + setWalletMessage(`Database created pending. Requesting ${walletLabel(wallet.provider)} approval for ${formatTokenAmountFromE8s(paymentAmountE8s)}.`); + const purchaseResult = + wallet.provider === "oisy" + ? await purchaseCyclesWithOisy({ canisterId, databaseId: result.database_id, paymentAmountE8s }, wallet.connection) + : await purchaseCyclesWithPlug({ canisterId, databaseId: result.database_id, paymentAmountE8s }, wallet.connection); + setWalletMessage( + `${walletLabel(wallet.provider)} purchased cycles ${purchaseResult.purchasedCycles}; paid ${formatTokenAmountFromE8s(purchaseResult.paymentAmountE8s)}; database activation can complete.` + ); + await refreshWalletBalance(wallet); await refreshDatabases(authClient); } catch (cause) { - setError(errorMessage(cause)); + const message = errorMessage(cause); + if (createdDatabaseId) { + await refreshDatabases(authClient); + setError(`Database created pending, but initial cycles purchase failed: ${message}`); + } else { + setError(message); + } setLoadState("error"); } finally { setCreating(false); @@ -153,40 +252,50 @@ export default function HomePage() { const publicDatabases = databases.filter((database) => !database.member && database.publicReadable); const trimmedDatabaseName = newDatabaseName.trim(); const databaseNameValidationError = databaseNameError(trimmedDatabaseName); - const createDisabled = creating || loadState === "loading" || databaseNameValidationError !== null; + const walletReadyToFundCreate = walletCanFundCreate(walletBalance); + const createUnavailable = loadState === "loading" || walletBusyProvider !== null || walletBalanceLoading || !walletReadyToFundCreate; + const createDisabled = creating || createUnavailable || databaseNameValidationError !== null; + const connectedWalletLabel = wallet ? `${walletLabel(wallet.provider)} ${shortPrincipal(walletPrincipal(wallet))}` : null; + const connectedWalletBalanceLabel = walletBalance ? formatTokenAmountFromE8s(walletBalance) : null; + const createButtonLabel = databaseCreateButtonLabel({ creating, walletConnected: Boolean(wallet), walletBalanceLoading, walletReadyToFundCreate }); return (
-
-
- -
-

Kinic Wiki

-

Database dashboard

-
-
-
- - - CLI - - { - if (authClient) void refreshDatabases(authClient); - }} - /> -
-
+ + { + void connectWallet(provider); + }} + onDisconnect={disconnectWallet} + /> + { + if (authClient) void refreshDatabases(authClient); + }} + /> + + } + /> {error ? : null} + {walletBalanceError ? : null} {warning ? : null} - {createdDatabase ? : null} + {walletMessage ? : null} {principal ? ( - <> -
-
-
-

Database dashboard

-

{principal}

-
- -
-
- - + setCreateDialogOpen(true)} + > + + {createButtonLabel} + + } + cyclesConfig={cyclesConfig} + loading={loadState === "loading"} + myDatabases={myDatabases} + principal={principal} + publicDatabases={publicDatabases} + publicError={publicError} + /> ) : (
@@ -258,6 +366,48 @@ function listWarning(memberResult: PromiseSettledResult): str return null; } +function createDatabasePurchaseAmountE8s(): bigint { + const parsed = parseKinicAmountE8sInput(CREATE_DATABASE_PURCHASE_KINIC); + if (typeof parsed === "string") throw new Error(parsed); + return parsed; +} + +function walletLabel(provider: HeaderWalletProvider): string { + return provider === "oisy" ? "OISY" : "Plug"; +} + +function walletPrincipal(wallet: ConnectedHeaderWallet): string { + return wallet.provider === "oisy" ? wallet.connection.owner : wallet.connection.principal; +} + +function walletCanFundCreate(balanceE8s: string | null): boolean { + if (!balanceE8s || !/^\d+$/.test(balanceE8s)) return false; + return BigInt(balanceE8s) >= createDatabasePurchaseAmountE8s(); +} + +function databaseCreateButtonLabel({ + creating, + walletConnected, + walletBalanceLoading, + walletReadyToFundCreate +}: { + creating: boolean; + walletConnected: boolean; + walletBalanceLoading: boolean; + walletReadyToFundCreate: boolean; +}): string { + if (creating) return "Creating..."; + if (!walletConnected) return "Connect wallet first"; + if (walletBalanceLoading) return "Checking balance..."; + if (!walletReadyToFundCreate) return "Insufficient KINIC"; + return "Create and fund database"; +} + +function shortPrincipal(value: string): string { + if (value.length <= 16) return value; + return `${value.slice(0, 8)}...${value.slice(-5)}`; +} + function errorMessage(cause: unknown): string { return cause instanceof Error ? cause.message : "Unexpected error"; } diff --git a/wikibrowser/app/skills/skill-registry-client.tsx b/wikibrowser/app/skills/skill-registry-client.tsx index e46bcebe..6554c148 100644 --- a/wikibrowser/app/skills/skill-registry-client.tsx +++ b/wikibrowser/app/skills/skill-registry-client.tsx @@ -8,6 +8,7 @@ import { RefreshCw, Search } from "lucide-react"; import { PackageManager, RoleBanner } from "@/app/skills/skill-registry-management-ui"; import { usePackageManager } from "@/app/skills/skill-registry-package-state"; import { EmptyState, SkillCard, StatusPanel, SummaryStrip } from "@/app/skills/skill-registry-ui"; +import { AdminHeader } from "@/components/admin-header"; import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; import { databaseCyclesDisabledReason, databaseCanWrite } from "@/lib/cycles-state"; import { filterSkills, loadSkillCatalog, summarizeSkills, type CatalogSkill, type StatusFilter } from "@/lib/skill-registry-catalog"; @@ -211,37 +212,37 @@ export function SkillRegistryClient({ databaseId }: { databaseId: string }) { return (
-
-
-
+ - Dashboard + Database dashboard - / Wiki -
-

Skill Registry

-

{databaseId || "unknown database"}

-
-
- {principal ? {principal} : null} - - {principal ? ( - - ) : ( - - )} -
-
+ {principal ? ( + + ) : ( + + )} + + } + />
diff --git a/wikibrowser/components/admin-header.tsx b/wikibrowser/components/admin-header.tsx new file mode 100644 index 00000000..981fd205 --- /dev/null +++ b/wikibrowser/components/admin-header.tsx @@ -0,0 +1,26 @@ +// Where: shared admin pages in wikibrowser. +// What: renders the common Kinic Wiki admin header shell. +// Why: dashboard, database management, and Skill Registry should present one management UI shape. +import Image from "next/image"; +import type { ReactNode } from "react"; + +export function AdminHeader({ actions, nav, title, titleAction }: { actions?: ReactNode; nav?: ReactNode; title: string; titleAction?: ReactNode }) { + return ( +
+
+ {nav ? : null} +
+ +
+

Kinic Wiki

+
+

{title}

+ {titleAction ?
{titleAction}
: null} +
+
+
+
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/wikibrowser/components/document-pane.tsx b/wikibrowser/components/document-pane.tsx index afe7b616..3dc5e835 100644 --- a/wikibrowser/components/document-pane.tsx +++ b/wikibrowser/components/document-pane.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; import dynamic from "next/dynamic"; +import { Fragment, useState } from "react"; import type { ReactNode } from "react"; -import { useState } from "react"; import type { Identity } from "@icp-sdk/core/agent"; import { FileCode, FileText, Folder, Loader2, Route } from "lucide-react"; import { hrefForPath, hrefForSearch } from "@/lib/paths"; @@ -37,7 +37,8 @@ export function DocumentHeader({ isDirectory, canEditDirectory, editState, - rawContent + rawContent, + readMode = null }: { canisterId: string; databaseId: string; @@ -48,6 +49,7 @@ export function DocumentHeader({ canEditDirectory: boolean; editState: DocumentEditState; rawContent: string | null; + readMode?: "anonymous" | null; }) { const [copyStatus, setCopyStatus] = useState(null); async function copyText(label: string, value: string) { @@ -60,13 +62,9 @@ export function DocumentHeader({ } return (
-
-
- onViewChange("preview")} /> - onViewChange("raw")} /> - {!isDirectory || canEditDirectory ? onViewChange("edit")} /> : null} -
-
+
+ +
- {rawContent !== null ? ( +
+
+
+
+ onViewChange("preview")} /> + onViewChange("raw")} /> + {!isDirectory || canEditDirectory ? onViewChange("edit")} /> : null} +
+ {rawContent !== null ? ( +
- ) : null} +
+ ) : null} +
+ {view === "edit" ? : null} + {view === "edit" && editState.dirty ? : null} + {view === "edit" && editState.saveState === "saving" ? : null} + {view === "edit" && editState.saveState === "saved" ? : null} + {copyStatus ? : null}
-
- {view === "edit" ? : null} - {view === "edit" && editState.dirty ? : null} - {view === "edit" && editState.saveState === "saving" ? : null} - {view === "edit" && editState.saveState === "saved" ? : null} - {copyStatus ? : null} -
); } +function DocumentHeaderPath({ + canisterId, + databaseId, + path, + readMode +}: { + canisterId: string; + databaseId: string; + path: string; + readMode: "anonymous" | null; +}) { + const segments = path.split("/").filter(Boolean); + if (segments.length === 0) { + return
/
; + } + return ( + + ); +} + export function DocumentPane({ databaseId, node, diff --git a/wikibrowser/components/wiki-browser.tsx b/wikibrowser/components/wiki-browser.tsx index 01138b7c..40a905d3 100644 --- a/wikibrowser/components/wiki-browser.tsx +++ b/wikibrowser/components/wiki-browser.tsx @@ -745,6 +745,7 @@ export function WikiBrowser() { view={view} editState={activeEditState} rawContent={currentNode.data?.kind === "file" ? currentNode.data.content : null} + readMode={readMode} onViewChange={(nextView) => { if (nextView !== "edit" && !canLeaveDirtyEdit()) { return; @@ -754,7 +755,6 @@ export function WikiBrowser() { isDirectory={currentNode.data?.kind === "folder" || (!currentNode.data && Boolean(currentChildren.data))} canEditDirectory={currentNode.data?.kind === "folder" && isWikiPath(selectedPath)} /> - ({ - segment, - path: `/${segments.slice(0, index + 1).join("/")}`, - last: index === segments.length - 1 - })); - return ( - - ); -} - function tabTitle(tab: ModeTab): string { if (tab === "query") return "Query"; if (tab === "ingest") return "Ingest"; diff --git a/wikibrowser/lib/cycles-wallet.ts b/wikibrowser/lib/cycles-wallet.ts index db8fee55..88ea273c 100644 --- a/wikibrowser/lib/cycles-wallet.ts +++ b/wikibrowser/lib/cycles-wallet.ts @@ -7,6 +7,7 @@ import { Principal } from "@icp-sdk/core/principal"; import { getCyclesBillingConfig, type DatabaseCyclesPurchaseRequest } from "@/lib/vfs-client"; import { idlFactory } from "@/lib/vfs-idl"; import { formatRawCycles, KINIC_LEDGER_FEE_E8S, kinicBaseUnitsPerToken } from "@/lib/cycles"; +import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; type WalletProvider = "oisy" | "plug"; @@ -82,6 +83,7 @@ type PlugVfsActor = { }; type LedgerActor = { + icrc1_balance_of: (request: LedgerAccount) => Promise; icrc2_allowance: (request: LedgerAllowanceArgs) => Promise; icrc2_approve: (request: LedgerApproveArgs) => Promise<{ Ok: bigint } | { Err: unknown }>; }; @@ -122,6 +124,8 @@ export type ConnectedPlugWallet = { principal: string; }; +export type ConnectedKinicWallet = { provider: "oisy"; connection: ConnectedOisyWallet } | { provider: "plug"; connection: ConnectedPlugWallet }; + declare global { interface Window { ic?: { @@ -203,6 +207,13 @@ export async function connectPlugWallet(): Promise { return { principal: principal.toText() }; } +export async function getConnectedWalletKinicBalance(canisterId: string, wallet: ConnectedKinicWallet): Promise { + assertConfiguredCyclesCanister(canisterId); + const config = await getCyclesBillingConfig(canisterId); + const balance = await getLedgerBalance(config.kinicLedgerCanisterId, connectedWalletPrincipal(wallet)); + return balance.toString(); +} + export async function purchaseCyclesWithOisy(request: CyclesPurchaseRequest, connection: ConnectedOisyWallet): Promise { const prepared = await prepareCyclesPurchase(request, connection.owner); const wallet = await openOisyWallet(); @@ -255,7 +266,7 @@ export async function purchaseCyclesWithPlug(request: CyclesPurchaseRequest, con const approve = await ledgerActor.icrc2_approve( rawApproveArgs(request.canisterId, prepared.approvedAllowanceE8s, prepared.currentAllowanceE8s, prepared.expiresAt) ); - if ("Err" in approve) throw new Error(`ledger approve failed: ${JSON.stringify(approve.Err)}`); + if ("Err" in approve) throw new Error(`ledger approve failed: ${formatLedgerApproveError(approve.Err)}`); const vfsActor = await plug.createActor({ canisterId: request.canisterId, interfaceFactory: idlFactory @@ -367,13 +378,32 @@ async function getLedgerAllowance(ledgerCanisterId: string, owner: string, spend return result.allowance; } +async function getLedgerBalance(ledgerCanisterId: string, owner: string): Promise { + const host = process.env.NEXT_PUBLIC_WIKI_IC_HOST ?? "https://icp0.io"; + const agent = HttpAgent.createSync({ identity: new AnonymousIdentity(), host }); + if (agent.isLocal()) await agent.fetchRootKey(); + const actor = Actor.createActor(ledgerIdlFactory, { + agent, + canisterId: Principal.fromText(ledgerCanisterId) + }); + return actor.icrc1_balance_of(defaultAccount(owner)); +} + function allowanceArgs(owner: string, spender: string): LedgerAllowanceArgs { return { - account: { owner: Principal.fromText(owner), subaccount: [] }, - spender: { owner: Principal.fromText(spender), subaccount: [] } + account: defaultAccount(owner), + spender: defaultAccount(spender) }; } +function defaultAccount(owner: string): LedgerAccount { + return { owner: Principal.fromText(owner), subaccount: [] }; +} + +function connectedWalletPrincipal(wallet: ConnectedKinicWallet): string { + return wallet.provider === "oisy" ? wallet.connection.owner : wallet.connection.principal; +} + async function oisyCallCyclesPurchase( wallet: CyclesPurchaseIcrcWallet, owner: string, @@ -491,6 +521,71 @@ function purchaseResultType() { }); } +function formatLedgerApproveError(error: unknown): string { + const known = formatKnownLedgerApproveError(error); + return known ?? safeJsonWithBigInts(error); +} + +function formatKnownLedgerApproveError(error: unknown): string | null { + if (!isObject(error)) return null; + if (hasOwn(error, "InsufficientFunds")) { + return formatApproveErrorField(error, "InsufficientFunds", "balance", "kinic"); + } + if (hasOwn(error, "BadFee")) { + return formatApproveErrorField(error, "BadFee", "expected_fee", "kinic"); + } + if (hasOwn(error, "AllowanceChanged")) { + return formatApproveErrorField(error, "AllowanceChanged", "current_allowance", "kinic"); + } + if (hasOwn(error, "Duplicate")) { + return formatApproveErrorField(error, "Duplicate", "duplicate_of", null); + } + if (hasOwn(error, "CreatedInFuture")) { + return formatApproveErrorField(error, "CreatedInFuture", "ledger_time", null); + } + if (hasOwn(error, "Expired")) { + return formatApproveErrorField(error, "Expired", "ledger_time", null); + } + if (hasOwn(error, "GenericError")) { + const generic = Reflect.get(error, "GenericError"); + if (!isObject(generic)) return "GenericError"; + const message = Reflect.get(generic, "message"); + const errorCode = Reflect.get(generic, "error_code"); + const messageText = typeof message === "string" ? message : "unknown ledger error"; + const codeText = scalarText(errorCode); + return codeText ? `GenericError: ${messageText} (code ${codeText})` : `GenericError: ${messageText}`; + } + if (hasOwn(error, "TemporarilyUnavailable")) return "TemporarilyUnavailable"; + if (hasOwn(error, "TooOld")) return "TooOld"; + return null; +} + +function formatApproveErrorField(error: object, variant: string, field: string, unit: "kinic" | null): string { + const details = Reflect.get(error, variant); + if (!isObject(details)) return variant; + const value = Reflect.get(details, field); + const text = scalarText(value); + if (!text) return variant; + const display = unit === "kinic" ? formatTokenAmountFromE8s(text) : text; + return `${variant}: ${field} ${display}`; +} + +function scalarText(value: unknown): string | null { + if (typeof value === "bigint" || typeof value === "number" || typeof value === "string") { + return value.toString(); + } + return null; +} + +function safeJsonWithBigInts(value: unknown): string { + try { + const serialized = JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item)); + return serialized ?? String(value); + } catch { + return String(value); + } +} + function bytesFromUnknown(value: unknown, label: string): Uint8Array { if (value instanceof Uint8Array) return value; throw new Error(`${label} mismatch`); @@ -534,6 +629,7 @@ const ledgerIdlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { InsufficientFunds: idl.Record({ balance: idl.Nat }) }); return idl.Service({ + icrc1_balance_of: idl.Func([account], [idl.Nat], ["query"]), icrc2_allowance: idl.Func([allowanceArgs], [allowance], ["query"]), icrc2_approve: idl.Func([approveArgs], [idl.Variant({ Ok: idl.Nat, Err: approveError })], []) }); diff --git a/wikibrowser/package.json b/wikibrowser/package.json index 0abb76e1..686b0118 100644 --- a/wikibrowser/package.json +++ b/wikibrowser/package.json @@ -11,7 +11,7 @@ "smoke:errors": "node scripts/smoke-errors.mjs", "smoke:public": "node scripts/smoke-public.mjs", "generate:vfs-idl": "node scripts/generate-vfs-idl.mjs", - "test": "node scripts/generate-vfs-idl.mjs --check && node scripts/check-candid-drift.mjs && node scripts/check-paths.mjs && node scripts/check-ui-helpers.mjs && node scripts/check-api-errors.mjs && node scripts/check-auth.mjs && node scripts/check-smoke-url.mjs && node scripts/check-dashboard.mjs && node scripts/check-cycles.mjs && node scripts/check-skill-registry.mjs && node scripts/check-url-security.mjs", + "test": "node scripts/generate-vfs-idl.mjs --check && node scripts/check-candid-drift.mjs && node scripts/check-paths.mjs && node scripts/check-ui-helpers.mjs && node scripts/check-api-errors.mjs && node scripts/check-auth.mjs && node scripts/check-smoke-url.mjs && node scripts/check-dashboard.mjs && node scripts/check-cycles.mjs && node scripts/check-cycles-wallet.mjs && node scripts/check-skill-registry.mjs && node scripts/check-url-security.mjs", "typecheck": "next typegen && node scripts/normalize-typegen-tsconfig.mjs && tsc --noEmit", "audit:moderate": "pnpm audit --audit-level moderate", "build:worker": "opennextjs-cloudflare build", diff --git a/wikibrowser/scripts/check-cycles-wallet.mjs b/wikibrowser/scripts/check-cycles-wallet.mjs new file mode 100644 index 00000000..b87aabe8 --- /dev/null +++ b/wikibrowser/scripts/check-cycles-wallet.mjs @@ -0,0 +1,209 @@ +// Where: wikibrowser/scripts/check-cycles-wallet.mjs +// What: exercises cycles wallet helpers through a small TypeScript VM harness. +// Why: keep wallet behavior checks separate from /cycles page structure guards. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import vm from "node:vm"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); + +const cborMock = { decoded: {} }; +let lastBalanceAccount = null; +let lastConfigCanister = null; +const ledgerActorMock = { + icrc1_balance_of: async (account) => { + lastBalanceAccount = account; + return 123_456_789n; + }, + icrc2_allowance: async () => ({ allowance: 0n, expires_at: [] }), + icrc2_approve: async () => ({ Ok: 1n }) +}; + +const walletModule = loadTsModule( + "../lib/cycles-wallet.ts", + { + "@dfinity/oisy-wallet-signer/icrc-wallet": { IcrcWallet: class {} }, + "@dfinity/utils": { + base64ToUint8Array: (value) => new Uint8Array(Buffer.from(value, "base64")), + uint8ArrayToBase64: (value) => Buffer.from(value).toString("base64") + }, + "@icp-sdk/core/agent": { + Actor: { createActor: () => ledgerActorMock }, + AnonymousIdentity: class {}, + Cbor: { decode: () => cborMock.decoded }, + Certificate: { create: async () => ({}) }, + HttpAgent: { createSync: () => ({ isLocal: () => false, rootKey: new Uint8Array([1]) }) }, + lookupResultToBuffer: () => null, + requestIdOf: () => new Uint8Array([1]) + }, + "@icp-sdk/core/candid": { IDL: {} }, + "@icp-sdk/core/principal": { + Principal: { + fromText: (value) => ({ toText: () => value }), + fromUint8Array: (value) => ({ toText: () => `bytes:${Array.from(value).join(",")}` }) + } + }, + "@/lib/vfs-client": { + getCyclesBillingConfig: async (canisterId) => { + lastConfigCanister = canisterId; + return { kinicLedgerCanisterId: "ledger", cyclesPerKinic: "1000" }; + } + }, + "@/lib/vfs-idl": { idlFactory: () => ({}) }, + "@/lib/cycles": { formatRawCycles: (value) => value.toString(), KINIC_LEDGER_FEE_E8S: 100_000n, kinicBaseUnitsPerToken: () => 100_000_000n }, + "@/lib/kinic-amount": { formatTokenAmountFromE8s } + }, + "Object.assign(exports, { __test: { allowanceForCyclesPurchase, assertCanisterPaymentAmountE8s, assertConfiguredCyclesCanister, cyclesForPaymentAmountE8s, purchaseAfterApprove, decodeOisyCyclesPurchaseResult, formatLedgerApproveError } });" +); +const walletTest = walletModule.__test; + +assert.equal(walletTest.allowanceForCyclesPurchase(100_000_000n, 100_000n), 100_100_000n); +assert.throws(() => walletTest.assertCanisterPaymentAmountE8s(9_223_372_036_854_775_808n), /KINIC amount e8s exceeds canister limit/); +assert.throws(() => walletTest.allowanceForCyclesPurchase(18_446_744_073_709_551_615n, 1n), /approved allowance exceeds u64::MAX/); +assert.throws( + () => walletTest.cyclesForPaymentAmountE8s(9_223_372_036_854_775_807n, 200_000_000n), + /cycles purchase amount exceeds canister limit/ +); +assert.throws(() => walletTest.assertConfiguredCyclesCanister("aaaaa-aa"), /NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID is not configured/); +walletModule.__context.process.env.NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID = "aaaaa-aa"; +assert.throws(() => walletTest.assertConfiguredCyclesCanister("bbbbb-bb"), /VFS canister does not match NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID/); +await assert.rejects( + () => walletTest.purchaseAfterApprove(async () => { + throw new Error("purchase rejected"); + }, { approveBlockIndex: "11", expiresAt: 1_700_000_000_000_000_000n }), + /cycles purchase failed after approve; approval remains until .*purchase rejected/ +); +await walletTest.purchaseAfterApprove(async () => "ok", { approveBlockIndex: "11", expiresAt: 1_700_000_000_000_000_000n }); +assert.equal( + walletTest.formatLedgerApproveError({ InsufficientFunds: { balance: 100_000n } }), + "InsufficientFunds: balance 0.001 KINIC" +); +assert.equal( + walletTest.formatLedgerApproveError({ BadFee: { expected_fee: 100_000n } }), + "BadFee: expected_fee 0.001 KINIC" +); +assert.equal( + walletTest.formatLedgerApproveError({ AllowanceChanged: { current_allowance: 100_000n } }), + "AllowanceChanged: current_allowance 0.001 KINIC" +); +assert.doesNotThrow(() => walletTest.formatLedgerApproveError({ Unknown: { nested: { value: 42n } } })); +assert.match(walletTest.formatLedgerApproveError({ Unknown: { nested: { value: 42n } } }), /"value":"42"/); + +assert.equal( + await walletModule.getConnectedWalletKinicBalance("aaaaa-aa", { provider: "oisy", connection: { owner: "oisy-principal" } }), + "123456789" +); +assert.equal(lastConfigCanister, "aaaaa-aa"); +assert.equal(lastBalanceAccount.owner.toText(), "oisy-principal"); +assert.equal(Array.isArray(lastBalanceAccount.subaccount), true); +assert.equal(lastBalanceAccount.subaccount.length, 0); +assert.equal( + await walletModule.getConnectedWalletKinicBalance("aaaaa-aa", { provider: "plug", connection: { principal: "plug-principal" } }), + "123456789" +); +assert.equal(lastBalanceAccount.owner.toText(), "plug-principal"); +assert.equal(Array.isArray(lastBalanceAccount.subaccount), true); +assert.equal(lastBalanceAccount.subaccount.length, 0); + +cborMock.decoded = { method_name: "write_node" }; +await assert.rejects( + () => walletTest.decodeOisyCyclesPurchaseResult({ + canisterId: "aaaaa-aa", + sender: "bytes:7", + method: "purchase_database_cycles", + arg: Buffer.from([1]).toString("base64"), + result: { contentMap: "unused", certificate: "unused" } + }), + /wallet response method mismatch/ +); +cborMock.decoded = { + method_name: "purchase_database_cycles", + canister_id: new Uint8Array([2]), + sender: new Uint8Array([7]), + arg: new Uint8Array([1]) +}; +await assert.rejects( + () => walletTest.decodeOisyCyclesPurchaseResult({ + canisterId: "aaaaa-aa", + sender: "bytes:7", + method: "purchase_database_cycles", + arg: Buffer.from([1]).toString("base64"), + result: { contentMap: "unused", certificate: "unused" } + }), + /wallet response canister mismatch/ +); +cborMock.decoded = { + method_name: "purchase_database_cycles", + canister_id: new Uint8Array([]), + sender: new Uint8Array([7]), + arg: new Uint8Array([9]) +}; +await assert.rejects( + () => walletTest.decodeOisyCyclesPurchaseResult({ + canisterId: "bytes:", + sender: "bytes:7", + method: "purchase_database_cycles", + arg: Buffer.from([1]).toString("base64"), + result: { contentMap: "unused", certificate: "unused" } + }), + /wallet response argument mismatch/ +); +cborMock.decoded = { + method_name: "purchase_database_cycles", + canister_id: new Uint8Array([]), + sender: new Uint8Array([8]), + arg: new Uint8Array([1]) +}; +await assert.rejects( + () => walletTest.decodeOisyCyclesPurchaseResult({ + canisterId: "bytes:", + sender: "bytes:7", + method: "purchase_database_cycles", + arg: Buffer.from([1]).toString("base64"), + result: { contentMap: "unused", certificate: "unused" } + }), + /wallet response sender mismatch/ +); + +console.log("Cycles wallet checks OK"); + +function formatTokenAmountFromE8s(value) { + const e8s = typeof value === "bigint" ? value : BigInt(value); + if (e8s === 0n) return "0.000 KINIC"; + const whole = e8s / 100_000_000n; + const thousandths = (e8s % 100_000_000n) / 100_000n; + if (whole === 0n && thousandths === 0n) return "<0.001 KINIC"; + return `${whole.toString()}.${thousandths.toString().padStart(3, "0")} KINIC`; +} + +function loadTsModule(relativePath, mocks, append = "") { + const source = readFileSync(new URL(relativePath, import.meta.url), "utf8"); + const transpiled = ts.transpileModule(`${source}\n${append}`, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true + } + }).outputText; + const commonjsModule = { exports: {} }; + const context = { + Buffer, + Date, + TextEncoder, + Uint8Array, + URLSearchParams, + console, + exports: commonjsModule.exports, + module: commonjsModule, + process: { env: {} }, + require: (id) => { + if (Object.prototype.hasOwnProperty.call(mocks, id)) return mocks[id]; + throw new Error(`unexpected module import: ${id}`); + } + }; + vm.runInNewContext(transpiled, context, { filename: relativePath }); + return Object.assign(commonjsModule.exports, { __context: context }); +} diff --git a/wikibrowser/scripts/check-cycles.mjs b/wikibrowser/scripts/check-cycles.mjs index 24ab903f..ab1e79b6 100644 --- a/wikibrowser/scripts/check-cycles.mjs +++ b/wikibrowser/scripts/check-cycles.mjs @@ -13,14 +13,15 @@ const url = readFileSync(new URL("../lib/cycles-url.ts", import.meta.url), "utf8 const idl = readFileSync(new URL("../lib/vfs-idl.ts", import.meta.url), "utf8"); const vfsClient = readFileSync(new URL("../lib/vfs-client.ts", import.meta.url), "utf8"); const connectOisy = sliceBetween(wallet, "export async function connectOisyWallet", "export async function connectPlugWallet"); -const connectPlug = sliceBetween(wallet, "export async function connectPlugWallet", "export async function purchaseCyclesWithOisy"); +const connectPlug = sliceBetween(wallet, "export async function connectPlugWallet", "export async function getConnectedWalletKinicBalance"); +const walletBalance = sliceBetween(wallet, "export async function getConnectedWalletKinicBalance", "export async function purchaseCyclesWithOisy"); const purchaseOisy = sliceBetween(wallet, "export async function purchaseCyclesWithOisy", "export async function purchaseCyclesWithPlug"); const purchasePlug = sliceBetween(wallet, "export async function purchaseCyclesWithPlug", "function approveParams"); assert.match(page, /\/cycles/); assert.doesNotMatch(page, /canister_id \?\? params\.canisterId/); assert.doesNotMatch(page, /amount_e8s \?\? params\.amountE8s/); -assert.match(page, /initialKinic=\{first\(params\.kinic\)\}/); +assert.doesNotMatch(page, /params\.kinic|initialKinic/); assert.match(client, /purchaseCyclesWithOisy/); assert.match(client, /purchaseCyclesWithPlug/); assert.match(client, /connectOisyWallet/); @@ -37,8 +38,8 @@ assert.match(client, /onClick=\{\(\) => void purchase\(\)\}/); assert.doesNotMatch(client, /onCycles/); assert.match(client, /parseKinicAmountE8sInput/); assert.match(client, /parseCyclesTarget/); -assert.match(client, /initialKinic\?: string/); -assert.match(client, /useState\(\(\) => \(initialKinic\?\.trim\(\) \? initialKinic : "1"\)\)/); +assert.doesNotMatch(client, /initialKinic/); +assert.match(client, /useState\("1"\)/); assert.match(client, /KINIC/); assert.doesNotMatch(client, /Login with Internet Identity|Notify identity/); assert.match(wallet, /export async function connectOisyWallet/); @@ -55,10 +56,15 @@ assert.match(connectOisy, /safeDisconnectOisyWallet\(wallet\)/); assert.doesNotMatch(connectOisy, /getCyclesBillingConfig|previewDatabaseCyclesPurchase|whitelist/); assert.match(connectPlug, /plug\.requestConnect\(\{\s*host:/); assert.doesNotMatch(connectPlug, /getCyclesBillingConfig|previewDatabaseCyclesPurchase|whitelist/); +assert.match(walletBalance, /getCyclesBillingConfig\(canisterId\)/); +assert.match(walletBalance, /getLedgerBalance\(config\.kinicLedgerCanisterId, connectedWalletPrincipal\(wallet\)\)/); assert.match(purchaseOisy, /prepareCyclesPurchase\(request, connection\.owner\)/); assert.match(purchasePlug, /prepareCyclesPurchase\(request, connection\.principal\)/); assert.match(purchasePlug, /whitelist: \[request\.canisterId, prepared\.kinicLedgerCanisterId\]/); assert.match(wallet, /icrc2_approve/); +assert.match(wallet, /icrc1_balance_of/); +assert.doesNotMatch(purchasePlug, /JSON\.stringify\(approve\.Err\)/); +assert.match(purchasePlug, /formatLedgerApproveError\(approve\.Err\)/); assert.match(wallet, /icrc2_allowance/); assert.doesNotMatch(wallet, /icrc1_fee/); assert.match(wallet, /async function prepareCyclesPurchase/); @@ -95,7 +101,7 @@ assert.match(wallet, /contentMap|Certificate|requestIdOf/); assert.match(wallet, /purchase_database_cycles\(prepared\.purchaseRequest\)/); assert.match(wallet, /encodeCyclesPurchaseArgs\(request: DatabaseCyclesPurchaseRequest\)/); assert.match(wallet, /whitelist: \[request\.canisterId, prepared\.kinicLedgerCanisterId\]/); -assert.match(wallet, /spender: \{ owner: Principal\.fromText\(canisterId\), subaccount: \[\] \}/); +assert.match(wallet, /function defaultAccount\(owner: string\): LedgerAccount/); assert.match(wallet, /DEFAULT_OISY_SIGNER_URL/); assert.match(wallet, /cycles purchase failed after approve; approval remains until/); assert.match(wallet, /class CyclesPurchaseAfterApproveError extends Error/); @@ -104,6 +110,7 @@ assert.match(client, /purchasedCycles/); assert.match(client, /approved allowance/); assert.doesNotMatch(client, /Wallet approval uses the DB cycle amount plus the ledger transfer fee/); assert.match(client, /transfer fee/); +assert.match(client, /A newly created database is pending, not active, until this first cycles purchase completes\./); assert.match(client, /Any authenticated wallet can purchase non-refundable cycles/); assert.doesNotMatch(client, new RegExp("extractCycles" + "RepairTarget")); assert.doesNotMatch(client, new RegExp("saveCycles" + "Repair" + "Record")); @@ -147,56 +154,6 @@ assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("0"), "KINIC amount must b assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("1.000000001"), "KINIC must be a positive number with up to 8 decimals"); assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("184467440737.09551616"), "KINIC amount e8s must be <= u64::MAX"); -const cborMock = { decoded: {} }; -const walletModule = loadTsModule( - "../lib/cycles-wallet.ts", - { - "@dfinity/oisy-wallet-signer/icrc-wallet": { IcrcWallet: class {} }, - "@dfinity/utils": { - base64ToUint8Array: (value) => new Uint8Array(Buffer.from(value, "base64")), - uint8ArrayToBase64: (value) => Buffer.from(value).toString("base64") - }, - "@icp-sdk/core/agent": { - Actor: { createActor: () => ({}) }, - AnonymousIdentity: class {}, - Cbor: { decode: () => cborMock.decoded }, - Certificate: { create: async () => ({}) }, - HttpAgent: { createSync: () => ({ isLocal: () => false, rootKey: new Uint8Array([1]) }) }, - lookupResultToBuffer: () => null, - requestIdOf: () => new Uint8Array([1]) - }, - "@icp-sdk/core/candid": { IDL: {} }, - "@icp-sdk/core/principal": { - Principal: { - fromText: (value) => ({ toText: () => value }), - fromUint8Array: (value) => ({ toText: () => `bytes:${Array.from(value).join(",")}` }) - } - }, - "@/lib/vfs-client": { getCyclesBillingConfig: async () => ({ kinicLedgerCanisterId: "ledger", cyclesPerKinic: "1000" }) }, - "@/lib/vfs-idl": { idlFactory: () => ({}) }, - "@/lib/cycles": { formatRawCycles: (value) => value.toString(), KINIC_LEDGER_FEE_E8S: 100_000n, kinicBaseUnitsPerToken: () => 100_000_000n } - }, - "Object.assign(exports, { __test: { allowanceForCyclesPurchase, assertCanisterPaymentAmountE8s, assertConfiguredCyclesCanister, cyclesForPaymentAmountE8s, purchaseAfterApprove, decodeOisyCyclesPurchaseResult } });" -); -const walletTest = walletModule.__test; -assert.equal(walletTest.allowanceForCyclesPurchase(100_000_000n, 100_000n), 100_100_000n); -assert.throws(() => walletTest.assertCanisterPaymentAmountE8s(9_223_372_036_854_775_808n), /KINIC amount e8s exceeds canister limit/); -assert.throws(() => walletTest.allowanceForCyclesPurchase(18_446_744_073_709_551_615n, 1n), /approved allowance exceeds u64::MAX/); -assert.throws( - () => walletTest.cyclesForPaymentAmountE8s(9_223_372_036_854_775_807n, 200_000_000n), - /cycles purchase amount exceeds canister limit/ -); -assert.throws(() => walletTest.assertConfiguredCyclesCanister("aaaaa-aa"), /NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID is not configured/); -walletModule.__context.process.env.NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID = "aaaaa-aa"; -assert.throws(() => walletTest.assertConfiguredCyclesCanister("bbbbb-bb"), /VFS canister does not match NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID/); -await assert.rejects( - () => walletTest.purchaseAfterApprove(async () => { - throw new Error("purchase rejected"); - }, { approveBlockIndex: "11", expiresAt: 1_700_000_000_000_000_000n }), - /cycles purchase failed after approve; approval remains until .*purchase rejected/ -); -await walletTest.purchaseAfterApprove(async () => "ok", { approveBlockIndex: "11", expiresAt: 1_700_000_000_000_000_000n }); - const clientModule = loadTsModule( "../app/cycles/cycles-client.tsx", { @@ -222,71 +179,13 @@ const clientModule = loadTsModule( "@/lib/cycles-wallet": { connectOisyWallet: async () => ({}), connectPlugWallet: async () => ({}), + getConnectedWalletKinicBalance: async () => "0", purchaseCyclesWithOisy: async () => ({}), purchaseCyclesWithPlug: async () => ({}) }, "@/lib/kinic-amount": { formatTokenAmountFromE8s: (value) => String(value) } } ); -cborMock.decoded = { method_name: "write_node" }; -await assert.rejects( - () => walletTest.decodeOisyCyclesPurchaseResult({ - canisterId: "aaaaa-aa", - sender: "bytes:7", - method: "purchase_database_cycles", - arg: Buffer.from([1]).toString("base64"), - result: { contentMap: "unused", certificate: "unused" } - }), - /wallet response method mismatch/ -); -cborMock.decoded = { - method_name: "purchase_database_cycles", - canister_id: new Uint8Array([2]), - sender: new Uint8Array([7]), - arg: new Uint8Array([1]) -}; -await assert.rejects( - () => walletTest.decodeOisyCyclesPurchaseResult({ - canisterId: "aaaaa-aa", - sender: "bytes:7", - method: "purchase_database_cycles", - arg: Buffer.from([1]).toString("base64"), - result: { contentMap: "unused", certificate: "unused" } - }), - /wallet response canister mismatch/ -); -cborMock.decoded = { - method_name: "purchase_database_cycles", - canister_id: new Uint8Array([]), - sender: new Uint8Array([7]), - arg: new Uint8Array([9]) -}; -await assert.rejects( - () => walletTest.decodeOisyCyclesPurchaseResult({ - canisterId: "bytes:", - sender: "bytes:7", - method: "purchase_database_cycles", - arg: Buffer.from([1]).toString("base64"), - result: { contentMap: "unused", certificate: "unused" } - }), - /wallet response argument mismatch/ -); -cborMock.decoded = { - method_name: "purchase_database_cycles", - canister_id: new Uint8Array([]), - sender: new Uint8Array([8]), - arg: new Uint8Array([1]) -}; -await assert.rejects( - () => walletTest.decodeOisyCyclesPurchaseResult({ - canisterId: "bytes:", - sender: "bytes:7", - method: "purchase_database_cycles", - arg: Buffer.from([1]).toString("base64"), - result: { contentMap: "unused", certificate: "unused" } - }), - /wallet response sender mismatch/ -); console.log("Cycles checks OK"); diff --git a/wikibrowser/scripts/check-dashboard.mjs b/wikibrowser/scripts/check-dashboard.mjs index 6ddae8bd..b916a690 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -13,6 +13,7 @@ const dashboardMemberTable = readFileSync(new URL("../app/dashboard/member-table const vfsIdl = readFileSync(new URL("../lib/vfs-idl.ts", import.meta.url), "utf8"); const vfsClient = readFileSync(new URL("../lib/vfs-client.ts", import.meta.url), "utf8"); const createDatabaseDialog = readFileSync(new URL("../app/create-database-dialog.tsx", import.meta.url), "utf8"); +const adminHeader = readFileSync(new URL("../components/admin-header.tsx", import.meta.url), "utf8"); const cliPage = readFileSync(new URL("../app/cli/page.tsx", import.meta.url), "utf8"); const cliGuideBlock = readFileSync(new URL("../app/cli/cli-guide-block.tsx", import.meta.url), "utf8"); const homeUi = readFileSync(new URL("../app/home-ui.tsx", import.meta.url), "utf8"); @@ -32,7 +33,13 @@ const wikiBrowser = readFileSync(new URL("../components/wiki-browser.tsx", impor const wranglerConfig = readFileSync(new URL("../wrangler.jsonc", import.meta.url), "utf8"); assert.match(homeUi, /href=\{`\/dashboard\/\$\{encodeURIComponent\(database\.databaseId\)\}`\}/); -assert.match(homePage, /href="\/cli"/); +assert.match(adminHeader, /export function AdminHeader/); +assert.match(adminHeader, /titleAction\?: ReactNode/); +assert.match(adminHeader, /titleAction \?
/); +assert.match(adminHeader, /Kinic Wiki/); +assert.match(homePage, //); assert.match(dashboardRoute, /params: Promise<\{ databaseId: string \}>/); assert.match(dashboardRoute, //); assert.match(dashboardClient, /export function DashboardDatabaseClient\(\{ databaseId \}/); +assert.match(dashboardClient, /\(null\);/); +assert.match(homePage, /const connectedWalletBalanceLabel = walletBalance \? formatTokenAmountFromE8s\(walletBalance\) : null;/); +assert.match(homePage, /await refreshWalletBalance\(wallet\);/); +assert.match(homePage, /purchaseCyclesWithOisy/); +assert.match(homePage, /purchaseCyclesWithPlug/); +assert.match(homePage, /CREATE_DATABASE_PURCHASE_KINIC = "1"/); +assert.match(homePage, /const paymentAmountE8s = createDatabasePurchaseAmountE8s\(\);/); +assert.match(homePage, /Database created pending\. Requesting/); +assert.match(homePage, /Database created pending, but initial cycles purchase failed/); +assert.doesNotMatch(homePage, /purchaseQueryString\(\{ databaseId: result\.database_id \}\)/); +assert.doesNotMatch(homePage, /useRouter|router\.push/); +assert.match(homePage, /Connect OISY or Plug with at least \$\{formatTokenAmountFromE8s\(createDatabasePurchaseAmountE8s\(\)\)\} before creating a database\./); +assert.match(homePage, /Create database requires at least \$\{formatTokenAmountFromE8s\(createDatabasePurchaseAmountE8s\(\)\)\} in the connected wallet\./); +assert.doesNotMatch(homePage, /Checking KINIC balance|Create database will request the first cycles purchase|walletFundingMessage/); +assert.match(homePage, /await purchaseCyclesWithOisy\(\{ canisterId, databaseId: result\.database_id, paymentAmountE8s \}, wallet\.connection\)/); +assert.match(homePage, /await purchaseCyclesWithPlug\(\{ canisterId, databaseId: result\.database_id, paymentAmountE8s \}, wallet\.connection\)/); +assert.match(homePage, /const walletReadyToFundCreate = walletCanFundCreate\(walletBalance\);/); +assert.match(homePage, /const createUnavailable = loadState === "loading" \|\| walletBusyProvider !== null \|\| walletBalanceLoading \|\| !walletReadyToFundCreate;/); +assert.match(homePage, /function walletCanFundCreate\(balanceE8s: string \| null\): boolean/); +assert.match(homePage, /return BigInt\(balanceE8s\) >= createDatabasePurchaseAmountE8s\(\);/); +assert.match(homePage, /function databaseCreateButtonLabel/); +assert.match(homePage, /return "Connect wallet first"/); +assert.match(homePage, /return "Checking balance\.\.\."/); +assert.match(homePage, /return "Insufficient KINIC"/); +assert.match(homePage, /return "Create and fund database"/); +assert.match(homePage, /disabled=\{creating \|\| createUnavailable\}/); +assert.match(homePage, / void/); +assert.match(homeUi, /onClick=\{\(\) => \(oisyConnected \? onDisconnect\("oisy"\) : onConnect\("oisy"\)\)\}/); +assert.match(homeUi, /onClick=\{\(\) => \(plugConnected \? onDisconnect\("plug"\) : onConnect\("plug"\)\)\}/); +assert.match(homeUi, /ariaLabel=\{oisyConnected \? "Disconnect OISY" : undefined\}/); +assert.match(homeUi, /ariaLabel=\{plugConnected \? "Disconnect Plug" : undefined\}/); +assert.match(homeUi, /hoverIcon=\{oisyConnected \? : null\}/); +assert.match(homeUi, /hoverIcon=\{plugConnected \? : null\}/); +assert.match(homeUi, /group-hover:opacity-0/); +assert.match(homeUi, /group-hover:opacity-100/); +assert.match(homeUi, /size-\[15px\]/); +assert.match(homeUi, /connectedBalanceLabel: string \| null/); +assert.match(homeUi, /balanceLoading: boolean/); +assert.match(homeUi, /\/ \{secondaryLabel\}/); +assert.match(homeUi, /PlugZap/); +assert.match(homeUi, /disabled=\{disabled \|\| busyProvider !== null\}/); assert.match(homePage, /CLI<\/span>/); assert.match(homeUi, /My databases/); assert.match(homeUi, /Public databases/); assert.doesNotMatch(homeUi, /Databases where your signed-in principal has a direct role\./); @@ -294,6 +367,15 @@ assert.match(dashboardUi, /loadingLabel="Granting\.\.\."/); assert.match(dashboardUi, /Enable LLM writer/); assert.match(dashboardUi, /Disable LLM writer/); assert.match(dashboardUi, /Set LLM writer/); +assert.match(dashboardUi, /flex flex-col gap-2 sm:flex-row sm:items-center/); +assert.doesNotMatch(dashboardUi, /\{props\.label\}: \{props\.enabled \? "enabled" : "disabled"\}/); +assert.match(dashboardUi, /export function RenameDatabaseDialog/); +assert.match(dashboardUi, /maxLength=\{80\}/); +assert.match(dashboardUi, /const submitDisabled = props\.busy \|\| trimmed === "" \|\| trimmed === props\.databaseName;/); +assert.match(dashboardUi, /props\.busyAction\?\.kind === "rename"/); +assert.doesNotMatch(dashboardUi, /const \[databaseName, setDatabaseName\]/); +assert.doesNotMatch(dashboardUi, /onRename/); +assert.doesNotMatch(dashboardUi, /sm:flex-row sm:items-end/); assert.match(dashboardUi, /role or cycles state changes/); assert.match(dashboardUi, /databaseCyclesView/); assert.match(dashboardUi, /databaseCyclesHref/); @@ -329,7 +411,10 @@ assert.match(homePage, /refreshSeqRef/); assert.match(homePage, /isCurrentRefresh/); assert.match(dashboardClient, /refreshSeqRef/); assert.match(dashboardClient, /isCurrentRefresh/); -assert.match(homePage, /await authClient\.logout\(\);\n setPrincipal\(null\);\n setCyclesBillingConfig\(null\);\n setCreatedDatabase\(null\);\n setCreateDialogOpen\(false\);\n setNewDatabaseName\(""\);\n setError\(null\);\n setPublicError\(null\);\n await refreshDatabases\(null\);/); +assert.match(homePage, /await authClient\.logout\(\);\n setPrincipal\(null\);\n setCyclesBillingConfig\(null\);\n setCreateDialogOpen\(false\);\n setNewDatabaseName\(""\);\n setError\(null\);\n setPublicError\(null\);\n setWalletMessage\(null\);\n walletBalanceSeqRef\.current \+= 1;\n setWallet\(null\);\n setWalletBalance\(null\);\n setWalletBalanceLoading\(false\);\n setWalletBalanceError\(null\);\n await refreshDatabases\(null\);/); +assert.match(homePage, /createDatabaseAction=\{/); +assert.match(homeUi, //); assert.match(route, //); assert.match(client, /SkillRegistryClient/); +assert.match(adminHeader, /export function AdminHeader/); +assert.match(client, / Date: Wed, 3 Jun 2026 16:22:38 +0900 Subject: [PATCH 2/7] Reject invalid database IDs and require full create balance --- crates/vfs_cli_core/src/commands.rs | 27 ++++++++++++++++++++-- docs/CLI.md | 2 +- docs/payment.md | 2 +- wikibrowser/app/create-database-dialog.tsx | 4 +++- wikibrowser/app/page.tsx | 12 +++++++--- wikibrowser/scripts/check-dashboard.mjs | 12 +++++++--- 6 files changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/vfs_cli_core/src/commands.rs b/crates/vfs_cli_core/src/commands.rs index 9bf852ba..c0f0f682 100644 --- a/crates/vfs_cli_core/src/commands.rs +++ b/crates/vfs_cli_core/src/commands.rs @@ -804,12 +804,22 @@ pub fn database_cycles_url(browser_origin: Option<&str>, database_id: &str) -> R if origin.is_empty() { return Err(anyhow!("browser origin must not be empty")); } + if !is_browser_cycles_database_id(database_id) { + return Err(anyhow!("database_id contains unsupported characters")); + } Ok(format!( "{origin}/cycles?database_id={}", query_encode(database_id) )) } +fn is_browser_cycles_database_id(database_id: &str) -> bool { + !database_id.is_empty() + && database_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + fn parse_kinic_amount_e8s(value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -2318,10 +2328,23 @@ mod tests { #[test] fn database_cycles_url_uses_browser_origin() { - let url = super::database_cycles_url(Some("http://127.0.0.1:3000/"), "db alpha") + let url = super::database_cycles_url(Some("http://127.0.0.1:3000/"), "db_alpha") .expect("url should build"); - assert_eq!(url, "http://127.0.0.1:3000/cycles?database_id=db%20alpha"); + assert_eq!(url, "http://127.0.0.1:3000/cycles?database_id=db_alpha"); + } + + #[test] + fn database_cycles_url_rejects_unsupported_database_id() { + for database_id in ["db alpha", "bad/path", ""] { + let error = super::database_cycles_url(Some("http://127.0.0.1:3000/"), database_id) + .expect_err("unsupported database id should fail"); + assert!( + error + .to_string() + .contains("database_id contains unsupported characters") + ); + } } #[test] diff --git a/docs/CLI.md b/docs/CLI.md index 68f0b133..20ac44b0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -95,7 +95,7 @@ cargo run -p kinic-vfs-cli --bin kinic-vfs-cli -- search-remote "budget" --prefi `cycles config` prints the KINIC ledger canister, billing authority principal, `cycles_per_kinic`, `min_update_cycles`, and fixed ledger transfer fee `100_000 e8s`. `database create ` creates a generated pending database ID with zero DB cycles balance and prints it on success. It does not allocate a DB mount until the first successful cycle purchase. `database purchase-cycles ` pulls the KINIC payment from the caller through the ledger allowance already approved outside the CLI and adds raw cycles to the DB cycles balance. Any authenticated payer can purchase cycles for an existing DB. The allowance must include the fixed ledger transfer fee. -`database cycles ` prints and opens `https://wiki.kinic.xyz/cycles?...` for wallet-based OISY or Plug funding. This command does not use the CLI identity or contact the canister, so it can still print the payment URL when the local replica is stopped. Pass `--browser-origin` or set `KINIC_WIKI_BROWSER_ORIGIN` for local or staging browser hosts. The purchase amount is entered in the browser flow. The browser flow is limited to the configured canonical wiki canister, approves `payment_amount_e8s + ledger_fee_e8s` with a 30 minute expiry, and purchases cycles using the current canister config. The wallet also pays the approve transaction fee from its balance. The first successful purchase activates a pending DB. +`database cycles ` prints and opens `https://wiki.kinic.xyz/cycles?...` for wallet-based OISY or Plug funding. The database ID must match `[a-zA-Z0-9_-]+`, matching the browser `/cycles` route. This command does not use the CLI identity or contact the canister, so it can still print the payment URL when the local replica is stopped. Pass `--browser-origin` or set `KINIC_WIKI_BROWSER_ORIGIN` for local or staging browser hosts. The purchase amount is entered in the browser flow. The browser flow is limited to the configured canonical wiki canister, approves `payment_amount_e8s + ledger_fee_e8s` with a 30 minute expiry, and purchases cycles using the current canister config. The wallet also pays the approve transaction fee from its balance. The first successful purchase activates a pending DB. `database cycles-history [--json]` lists DB cycles ledger entries. Reader and writer principals see payer/caller principals as `redacted`; DB owner and billing authority see full details. `database cycles-pending [--json]` lists pending purchase operations visible to the DB owner, billing authority, or payer. Output includes `operation_id`, `status`, and `required_action`. `database list` prints databases attached to the caller principal, including DB cycles balance and suspension time. diff --git a/docs/payment.md b/docs/payment.md index b6a2fba7..23c56d25 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -122,7 +122,7 @@ browser `/cycles` は approve 後の purchase failure でも通常の error 表 `database_id` は必須で、`[a-zA-Z0-9_-]+` のみ許可される。購入額は UI 上で編集できる。初期入力は `1`。 -dashboard の `Create database` は OISY または Plug 接続済み、かつ接続 wallet の KINIC 残高が `1 KINIC` 以上の場合だけ押せる。作成後は同じ wallet で `1 KINIC` の approve と `purchase_database_cycles` を連続実行する。残高未取得、wallet 未接続、または `1 KINIC` 未満では DB 作成自体を開始しない。 +dashboard の `Create database` は OISY または Plug 接続済み、かつ接続 wallet の KINIC 残高が `1 KINIC + ledger transfer fee + approve fee` 以上の場合だけ押せる。作成後は同じ wallet で `1 KINIC` の approve と `purchase_database_cycles` を連続実行する。残高未取得、wallet 未接続、または必要額未満では DB 作成自体を開始しない。 KINIC 入力は正の数だけ許可する。小数は最大 8 桁、URL/UI parser 上の e8s 換算値は `u64::MAX` 以下でなければならない。購入直前の wallet flow は canister 実効上限として `payment_amount_e8s <= i64::MAX` と `amount_cycles <= i64::MAX` も確認する。 diff --git a/wikibrowser/app/create-database-dialog.tsx b/wikibrowser/app/create-database-dialog.tsx index 9fbddcd9..f04d7340 100644 --- a/wikibrowser/app/create-database-dialog.tsx +++ b/wikibrowser/app/create-database-dialog.tsx @@ -10,6 +10,7 @@ export function CreateDatabaseDialog({ creating, databaseName, open, + requiredBalanceLabel, validationError, onCancel, onChange, @@ -19,6 +20,7 @@ export function CreateDatabaseDialog({ creating: boolean; databaseName: string; open: boolean; + requiredBalanceLabel: string; validationError: string | null; onCancel: () => void; onChange: (value: string) => void; @@ -39,7 +41,7 @@ export function CreateDatabaseDialog({

Create database

- Connect a wallet with at least 1 KINIC before creating. New databases are created pending, not active, until the first purchase completes. + Connect a wallet with at least {requiredBalanceLabel} before creating. New databases are created pending, not active, until the first purchase completes.

{error ? : null} + {walletBalanceError ? : null} {status === "success" && message ? : null} {status === "error" && message ? : null}
@@ -176,54 +117,8 @@ export function CyclesClient({ canisterId, databaseId }: CyclesClientProps) { ); } -function WalletConnect({ - connectedLabel, - disabled, - icon, - label, - onConnect, - onSelect, - selected -}: { - connectedLabel: string | null; - disabled: boolean; - icon: ReactNode; - label: string; - onConnect: () => void; - onSelect: () => void; - selected: boolean; -}) { - return ( -
- {connectedLabel ? ( - - ) : ( - - )} -
- ); -} - -function purchaseButtonLabel(selectedProvider: CyclesProvider | null, status: CyclesStatus, activeProvider: CyclesProvider | null): string { - if (status === "running" && activeProvider === selectedProvider) { +function purchaseButtonLabel(selectedProvider: CyclesProvider | null, status: CyclesStatus): string { + if (status === "running") { if (selectedProvider === "oisy") return "Processing OISY"; if (selectedProvider === "plug") return "Processing Plug"; } @@ -232,6 +127,26 @@ function purchaseButtonLabel(selectedProvider: CyclesProvider | null, status: Cy return "Purchase cycles"; } +function cyclesPurchaseSuccessHref({ + cycles, + databaseId, + kinic, + provider +}: { + cycles: string; + databaseId: string; + kinic: string; + provider: CyclesProvider; +}): string { + const params = new URLSearchParams(); + params.set("funding", "success"); + params.set("database_id", databaseId); + params.set("provider", provider); + params.set("kinic", kinic); + params.set("cycles", cycles); + return `/?${params.toString()}`; +} + function Field({ label, value }: { label: string; value: string }) { return (
@@ -241,11 +156,6 @@ function Field({ label, value }: { label: string; value: string }) { ); } -function shortPrincipal(value: string): string { - if (value.length <= 16) return value; - return `${value.slice(0, 8)}...${value.slice(-5)}`; -} - function Notice({ tone, text }: { tone: "success" | "error" | "info" | "warning"; text: string }) { const Icon = tone === "success" ? CheckCircle2 : tone === "info" ? Info : CircleAlert; const classes = diff --git a/wikibrowser/app/cycles/page.tsx b/wikibrowser/app/cycles/page.tsx index b250ae8d..7895425d 100644 --- a/wikibrowser/app/cycles/page.tsx +++ b/wikibrowser/app/cycles/page.tsx @@ -2,6 +2,7 @@ // What: passes the configured canister and target database into the client. // Why: the purchase amount is UI state, and canister selection must not come from URL input. import type { Metadata } from "next"; +import type { DatabaseStatus } from "@/lib/types"; import { CyclesClient } from "./cycles-client"; export const metadata: Metadata = { @@ -17,6 +18,7 @@ export default async function CyclesPage({ searchParams }: { searchParams: PageS ); } @@ -25,3 +27,10 @@ function first(value: string | string[] | undefined): string { if (Array.isArray(value)) return value[0] ?? ""; return value ?? ""; } + +function parseDatabaseStatus(value: string): DatabaseStatus | null { + if (value === "pending" || value === "active" || value === "restoring" || value === "archiving" || value === "archived") { + return value; + } + return null; +} diff --git a/wikibrowser/app/home-page-client.tsx b/wikibrowser/app/home-page-client.tsx new file mode 100644 index 00000000..5dcf63c5 --- /dev/null +++ b/wikibrowser/app/home-page-client.tsx @@ -0,0 +1,311 @@ +"use client"; + +import type { AuthClient } from "@icp-sdk/auth/client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Plus } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { useAppSession } from "./app-session-provider"; +import { CreateDatabaseDialog } from "./create-database-dialog"; +import { DatabaseBody, OfficialKinicWikiPanel, StatusPanel } from "./home-ui"; +import { KINIC_LEDGER_FEE_E8S } from "@/lib/cycles"; +import { parseKinicAmountE8sInput } from "@/lib/cycles-url"; +import { purchaseCyclesWithOisy, purchaseCyclesWithPlug } from "@/lib/cycles-wallet"; +import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; +import type { CyclesBillingConfig, DatabaseSummary } from "@/lib/types"; +import { createDatabaseAuthenticated, getCyclesBillingConfig, listDatabasesAuthenticated, listDatabasesPublic } from "@/lib/vfs-client"; +import type { DatabaseRow } from "./home-ui"; + +type LoadState = "idle" | "loading" | "ready" | "error"; + +const CREATE_DATABASE_PURCHASE_KINIC = "1"; + +export function HomePageClient() { + const canisterId = process.env.NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID ?? ""; + const searchParams = useSearchParams(); + const refreshSeqRef = useRef(0); + const { + authClient, + authError, + authReady, + authRefreshSeq, + principal, + refreshWalletBalance, + setWalletControlsLocked, + wallet, + walletBalance, + walletBalanceError, + walletBalanceLoading, + walletBusyProvider + } = useAppSession(); + const [databases, setDatabases] = useState([]); + const [cyclesConfig, setCyclesBillingConfig] = useState(null); + const [loadState, setLoadState] = useState("loading"); + const [error, setError] = useState(null); + const [publicError, setPublicError] = useState(null); + const [warning, setWarning] = useState(null); + const [walletMessage, setWalletMessage] = useState(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [newDatabaseName, setNewDatabaseName] = useState(""); + const [creating, setCreating] = useState(false); + + const refreshDatabases = useCallback( + async (client: AuthClient | null) => { + const refreshSeq = (refreshSeqRef.current += 1); + const isCurrentRefresh = () => refreshSeq === refreshSeqRef.current; + if (!canisterId) { + setError("NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID is not configured."); + setLoadState("error"); + return; + } + setLoadState("loading"); + setError(null); + setPublicError(null); + setWarning(null); + try { + const identity = client?.getIdentity() ?? null; + const [cyclesResult, publicResult, memberResult] = await Promise.allSettled([ + getCyclesBillingConfig(canisterId), + listDatabasesPublic(canisterId), + identity ? listDatabasesAuthenticated(canisterId, identity) : Promise.resolve([]) + ]); + if (publicResult.status === "rejected" && memberResult.status === "rejected") { + throw new Error(`${errorMessage(publicResult.reason)}; ${errorMessage(memberResult.reason)}`); + } + const publicDatabases = publicResult.status === "fulfilled" ? publicResult.value : []; + const memberDatabases = memberResult.status === "fulfilled" ? memberResult.value : []; + const nextDatabases = mergeDatabaseRows(memberDatabases, publicDatabases); + if (!isCurrentRefresh()) return; + setDatabases(nextDatabases); + setCyclesBillingConfig(cyclesResult.status === "fulfilled" ? cyclesResult.value : null); + setPublicError(publicResult.status === "rejected" ? `Public database list unavailable: ${errorMessage(publicResult.reason)}` : null); + setWarning(listWarning(memberResult)); + setLoadState("ready"); + } catch (cause) { + if (!isCurrentRefresh()) return; + setError(errorMessage(cause)); + setLoadState("error"); + } + }, + [canisterId] + ); + + useEffect(() => { + if (!authReady) return; + void refreshDatabases(authClient); + }, [authClient, authReady, authRefreshSeq, refreshDatabases]); + + useEffect(() => { + setWalletControlsLocked(creating); + return () => setWalletControlsLocked(false); + }, [creating, setWalletControlsLocked]); + + useEffect(() => { + if (principal) return; + setCyclesBillingConfig(null); + setCreateDialogOpen(false); + setNewDatabaseName(""); + setWalletMessage(null); + }, [principal]); + + async function createDatabase() { + if (!authClient || !canisterId) return; + const databaseNameInput = newDatabaseName.trim(); + const validationError = databaseNameError(databaseNameInput); + if (validationError) { + setError(validationError); + setLoadState("error"); + return; + } + if (!wallet) { + setError(`Connect OISY or Plug with at least ${formatTokenAmountFromE8s(createDatabaseRequiredBalanceE8s())} before creating a database.`); + setLoadState("error"); + return; + } + if (!walletCanFundCreate(walletBalance)) { + setError(`Create database requires at least ${formatTokenAmountFromE8s(createDatabaseRequiredBalanceE8s())} in the connected wallet.`); + setLoadState("error"); + return; + } + setCreating(true); + setError(null); + setWalletMessage(null); + let createdDatabaseId: string | null = null; + try { + const result = await createDatabaseAuthenticated(canisterId, authClient.getIdentity(), databaseNameInput); + createdDatabaseId = result.database_id; + setCreateDialogOpen(false); + setNewDatabaseName(""); + const paymentAmountE8s = createDatabasePurchaseAmountE8s(); + setWalletMessage(`Database created pending. Requesting ${walletLabel(wallet.provider)} approval for ${formatTokenAmountFromE8s(paymentAmountE8s)}.`); + const purchaseResult = + wallet.provider === "oisy" + ? await purchaseCyclesWithOisy({ canisterId, databaseId: result.database_id, paymentAmountE8s }, wallet.connection) + : await purchaseCyclesWithPlug({ canisterId, databaseId: result.database_id, paymentAmountE8s }, wallet.connection); + setWalletMessage( + `${walletLabel(wallet.provider)} purchased cycles ${purchaseResult.purchasedCycles}; paid ${formatTokenAmountFromE8s(purchaseResult.paymentAmountE8s)}; database activation can complete.` + ); + await refreshWalletBalance(wallet); + await refreshDatabases(authClient); + } catch (cause) { + const message = errorMessage(cause); + if (createdDatabaseId) { + await refreshDatabases(authClient); + setError(`Database created pending, but initial cycles purchase failed: ${message}`); + } else { + setError(message); + } + setLoadState("error"); + } finally { + setCreating(false); + } + } + + const myDatabases = databases.filter((database) => database.member); + const publicDatabases = databases.filter((database) => !database.member && database.publicReadable); + const trimmedDatabaseName = newDatabaseName.trim(); + const databaseNameValidationError = databaseNameError(trimmedDatabaseName); + const walletReadyToFundCreate = walletCanFundCreate(walletBalance); + const createUnavailable = loadState === "loading" || walletBusyProvider !== null || walletBalanceLoading || !walletReadyToFundCreate; + const createDisabled = creating || createUnavailable || databaseNameValidationError !== null; + const createButtonLabel = databaseCreateButtonLabel({ creating, walletConnected: Boolean(wallet), walletBalanceLoading, walletReadyToFundCreate }); + const fundingSuccessMessage = dashboardFundingSuccessMessage(searchParams); + + return ( +
+
+ {authError ? : null} + {error ? : null} + {walletBalanceError ? : null} + {warning ? : null} + {fundingSuccessMessage ? : null} + {walletMessage ? : null} + { + if (creating) return; + setCreateDialogOpen(false); + setNewDatabaseName(""); + }} + onChange={setNewDatabaseName} + onSubmit={() => void createDatabase()} + /> + + + + {principal ? ( + setCreateDialogOpen(true)} + > + + {createButtonLabel} + + } + cyclesConfig={cyclesConfig} + loading={loadState === "loading"} + myDatabases={myDatabases} + principal={principal} + publicDatabases={publicDatabases} + publicError={publicError} + /> + ) : ( +
+
+
+

Public databases

+

Public databases open without login. Login with Internet Identity to show My databases linked to your principal.

+
+
+ +
+ )} +
+
+ ); +} + +function mergeDatabaseRows(memberDatabases: DatabaseSummary[], publicDatabases: DatabaseSummary[]): DatabaseRow[] { + const publicIds = new Set(publicDatabases.map((database) => database.databaseId)); + const rows = new Map(); + for (const database of publicDatabases) { + rows.set(database.databaseId, { ...database, member: false, publicReadable: true }); + } + for (const database of memberDatabases) { + rows.set(database.databaseId, { ...database, member: true, publicReadable: publicIds.has(database.databaseId) }); + } + return [...rows.values()].sort((left, right) => left.databaseId.localeCompare(right.databaseId)); +} + +function listWarning(memberResult: PromiseSettledResult): string | null { + if (memberResult.status === "rejected") return `Member database list unavailable: ${errorMessage(memberResult.reason)}`; + return null; +} + +function createDatabasePurchaseAmountE8s(): bigint { + const parsed = parseKinicAmountE8sInput(CREATE_DATABASE_PURCHASE_KINIC); + if (typeof parsed === "string") throw new Error(parsed); + return parsed; +} + +function createDatabaseRequiredBalanceE8s(): bigint { + return createDatabasePurchaseAmountE8s() + KINIC_LEDGER_FEE_E8S * 2n; +} + +function walletLabel(provider: "oisy" | "plug"): string { + return provider === "oisy" ? "OISY" : "Plug"; +} + +function walletCanFundCreate(balanceE8s: string | null): boolean { + if (!balanceE8s || !/^\d+$/.test(balanceE8s)) return false; + return BigInt(balanceE8s) >= createDatabaseRequiredBalanceE8s(); +} + +function databaseCreateButtonLabel({ + creating, + walletConnected, + walletBalanceLoading, + walletReadyToFundCreate +}: { + creating: boolean; + walletConnected: boolean; + walletBalanceLoading: boolean; + walletReadyToFundCreate: boolean; +}): string { + if (creating) return "Creating..."; + if (!walletConnected) return "Connect wallet first"; + if (walletBalanceLoading) return "Checking balance..."; + if (!walletReadyToFundCreate) return "Insufficient KINIC"; + return "Create and fund database"; +} + +function dashboardFundingSuccessMessage(params: { get(name: string): string | null }): string | null { + if (params.get("funding") !== "success") return null; + const databaseId = params.get("database_id") ?? ""; + const provider = params.get("provider") ?? ""; + const kinic = params.get("kinic") ?? ""; + const cycles = params.get("cycles") ?? ""; + if (!/^[a-zA-Z0-9_-]+$/.test(databaseId)) return null; + if (provider !== "oisy" && provider !== "plug") return null; + if (!/^(?:<0\.001|[0-9]+\.[0-9]{3}) KINIC$/.test(kinic)) return null; + if (!/^(?:[0-9]+|[0-9]{1,3}(?:,[0-9]{3})+)$/.test(cycles)) return null; + return `${walletLabel(provider)} purchased ${cycles} cycles for ${databaseId}; paid ${kinic}.`; +} + +function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : "Unexpected error"; +} + +function databaseNameError(databaseName: string): string | null { + if (databaseName.length === 0) return "Database name is required."; + if ([...databaseName].length > 80) return "Database name must be 1..80 characters."; + return /[\u0000-\u001f\u007f]/.test(databaseName) ? "Database name may not contain control characters." : null; +} diff --git a/wikibrowser/app/layout.tsx b/wikibrowser/app/layout.tsx index fdd13034..b668b42a 100644 --- a/wikibrowser/app/layout.tsx +++ b/wikibrowser/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; +import { AppHeader } from "./app-header"; +import { AppSessionProvider } from "./app-session-provider"; export const metadata: Metadata = { metadataBase: new URL("https://wiki.kinic.xyz"), @@ -21,7 +23,12 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + + + {children} + + ); } diff --git a/wikibrowser/app/page.tsx b/wikibrowser/app/page.tsx index d7f5fdea..054a03e9 100644 --- a/wikibrowser/app/page.tsx +++ b/wikibrowser/app/page.tsx @@ -1,425 +1,20 @@ -"use client"; - -import { AuthClient } from "@icp-sdk/auth/client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Plus } from "lucide-react"; -import { CreateDatabaseDialog } from "./create-database-dialog"; -import { AuthControls, DatabaseBody, OfficialKinicWikiPanel, StatusPanel, WalletControls, type HeaderWalletProvider } from "./home-ui"; -import { AdminHeader } from "@/components/admin-header"; -import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; -import { KINIC_LEDGER_FEE_E8S } from "@/lib/cycles"; -import { parseKinicAmountE8sInput } from "@/lib/cycles-url"; -import { connectOisyWallet, connectPlugWallet, getConnectedWalletKinicBalance, purchaseCyclesWithOisy, purchaseCyclesWithPlug, type ConnectedKinicWallet } from "@/lib/cycles-wallet"; -import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; -import type { CyclesBillingConfig, DatabaseSummary } from "@/lib/types"; -import { createDatabaseAuthenticated, getCyclesBillingConfig, listDatabasesAuthenticated, listDatabasesPublic } from "@/lib/vfs-client"; -import type { DatabaseRow } from "./home-ui"; - -type LoadState = "idle" | "loading" | "ready" | "error"; -type ConnectedHeaderWallet = ConnectedKinicWallet; - -const CREATE_DATABASE_PURCHASE_KINIC = "1"; +import { Suspense } from "react"; +import { HomePageClient } from "./home-page-client"; export default function HomePage() { - const canisterId = process.env.NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID ?? ""; - const refreshSeqRef = useRef(0); - const walletBalanceSeqRef = useRef(0); - const [authClient, setAuthClient] = useState(null); - const [principal, setPrincipal] = useState(null); - const [databases, setDatabases] = useState([]); - const [cyclesConfig, setCyclesBillingConfig] = useState(null); - const [loadState, setLoadState] = useState("idle"); - const [error, setError] = useState(null); - const [publicError, setPublicError] = useState(null); - const [warning, setWarning] = useState(null); - const [walletMessage, setWalletMessage] = useState(null); - const [wallet, setWallet] = useState(null); - const [walletBalance, setWalletBalance] = useState(null); - const [walletBalanceLoading, setWalletBalanceLoading] = useState(false); - const [walletBalanceError, setWalletBalanceError] = useState(null); - const [walletBusyProvider, setWalletBusyProvider] = useState(null); - const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [newDatabaseName, setNewDatabaseName] = useState(""); - const [creating, setCreating] = useState(false); - - const refreshDatabases = useCallback( - async (client: AuthClient | null) => { - const refreshSeq = (refreshSeqRef.current += 1); - const isCurrentRefresh = () => refreshSeq === refreshSeqRef.current; - if (!canisterId) { - setError("NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID is not configured."); - setLoadState("error"); - return; - } - setLoadState("loading"); - setError(null); - setPublicError(null); - setWarning(null); - try { - const identity = client?.getIdentity() ?? null; - const [cyclesResult, publicResult, memberResult] = await Promise.allSettled([ - getCyclesBillingConfig(canisterId), - listDatabasesPublic(canisterId), - identity ? listDatabasesAuthenticated(canisterId, identity) : Promise.resolve([]) - ]); - if (publicResult.status === "rejected" && memberResult.status === "rejected") { - throw new Error(`${errorMessage(publicResult.reason)}; ${errorMessage(memberResult.reason)}`); - } - const publicDatabases = publicResult.status === "fulfilled" ? publicResult.value : []; - const memberDatabases = memberResult.status === "fulfilled" ? memberResult.value : []; - const nextDatabases = mergeDatabaseRows(memberDatabases, publicDatabases); - if (!isCurrentRefresh()) return; - setDatabases(nextDatabases); - setCyclesBillingConfig(cyclesResult.status === "fulfilled" ? cyclesResult.value : null); - setPrincipal(identity?.getPrincipal().toText() ?? null); - setPublicError(publicResult.status === "rejected" ? `Public database list unavailable: ${errorMessage(publicResult.reason)}` : null); - setWarning(listWarning(memberResult)); - setLoadState("ready"); - } catch (cause) { - if (!isCurrentRefresh()) return; - setError(errorMessage(cause)); - setLoadState("error"); - } - }, - [canisterId] + return ( + }> + + ); +} - useEffect(() => { - let cancelled = false; - - AuthClient.create(AUTH_CLIENT_CREATE_OPTIONS) - .then(async (client) => { - if (cancelled) return; - setAuthClient(client); - if (await client.isAuthenticated()) { - await refreshDatabases(client); - } else { - await refreshDatabases(null); - } - }) - .catch((cause) => { - if (cancelled) return; - setError(errorMessage(cause)); - setLoadState("error"); - }); - - return () => { - cancelled = true; - }; - }, [refreshDatabases]); - - async function login() { - if (!authClient) return; - setError(null); - await authClient.login({ - ...authLoginOptions(), - onSuccess: () => { - void refreshDatabases(authClient); - }, - onError: (cause) => { - setError(errorMessage(cause)); - setLoadState("error"); - } - }); - } - - async function logout() { - if (!authClient) return; - await authClient.logout(); - setPrincipal(null); - setCyclesBillingConfig(null); - setCreateDialogOpen(false); - setNewDatabaseName(""); - setError(null); - setPublicError(null); - setWalletMessage(null); - walletBalanceSeqRef.current += 1; - setWallet(null); - setWalletBalance(null); - setWalletBalanceLoading(false); - setWalletBalanceError(null); - await refreshDatabases(null); - } - - async function refreshWalletBalance(nextWallet: ConnectedHeaderWallet) { - const balanceSeq = (walletBalanceSeqRef.current += 1); - const isCurrentBalance = () => balanceSeq === walletBalanceSeqRef.current; - setWalletBalance(null); - setWalletBalanceLoading(true); - setWalletBalanceError(null); - try { - const balance = await getConnectedWalletKinicBalance(canisterId, nextWallet); - if (!isCurrentBalance()) return; - setWalletBalance(balance); - } catch (cause) { - if (!isCurrentBalance()) return; - setWalletBalance(null); - setWalletBalanceError(`KINIC balance unavailable: ${errorMessage(cause)}`); - } finally { - if (!isCurrentBalance()) return; - setWalletBalanceLoading(false); - } - } - - async function connectWallet(provider: HeaderWalletProvider) { - if (creating || walletBusyProvider) return; - setWalletBusyProvider(provider); - setError(null); - setWalletMessage(null); - try { - if (provider === "oisy") { - const connection = await connectOisyWallet(); - const nextWallet: ConnectedHeaderWallet = { provider, connection }; - setWallet(nextWallet); - void refreshWalletBalance(nextWallet); - } else { - const connection = await connectPlugWallet(); - const nextWallet: ConnectedHeaderWallet = { provider, connection }; - setWallet(nextWallet); - void refreshWalletBalance(nextWallet); - } - } catch (cause) { - setError(errorMessage(cause)); - setLoadState("error"); - } finally { - setWalletBusyProvider(null); - } - } - - function disconnectWallet(provider: HeaderWalletProvider) { - if (creating || walletBusyProvider || wallet?.provider !== provider) return; - walletBalanceSeqRef.current += 1; - setWallet(null); - setWalletBalance(null); - setWalletBalanceLoading(false); - setWalletBalanceError(null); - setWalletMessage(null); - } - - async function createDatabase() { - if (!authClient || !canisterId) return; - const databaseNameInput = newDatabaseName.trim(); - const validationError = databaseNameError(databaseNameInput); - if (validationError) { - setError(validationError); - setLoadState("error"); - return; - } - if (!wallet) { - setError(`Connect OISY or Plug with at least ${formatTokenAmountFromE8s(createDatabaseRequiredBalanceE8s())} before creating a database.`); - setLoadState("error"); - return; - } - if (!walletCanFundCreate(walletBalance)) { - setError(`Create database requires at least ${formatTokenAmountFromE8s(createDatabaseRequiredBalanceE8s())} in the connected wallet.`); - setLoadState("error"); - return; - } - setCreating(true); - setError(null); - setWalletMessage(null); - let createdDatabaseId: string | null = null; - try { - const result = await createDatabaseAuthenticated(canisterId, authClient.getIdentity(), databaseNameInput); - createdDatabaseId = result.database_id; - setCreateDialogOpen(false); - setNewDatabaseName(""); - const paymentAmountE8s = createDatabasePurchaseAmountE8s(); - setWalletMessage(`Database created pending. Requesting ${walletLabel(wallet.provider)} approval for ${formatTokenAmountFromE8s(paymentAmountE8s)}.`); - const purchaseResult = - wallet.provider === "oisy" - ? await purchaseCyclesWithOisy({ canisterId, databaseId: result.database_id, paymentAmountE8s }, wallet.connection) - : await purchaseCyclesWithPlug({ canisterId, databaseId: result.database_id, paymentAmountE8s }, wallet.connection); - setWalletMessage( - `${walletLabel(wallet.provider)} purchased cycles ${purchaseResult.purchasedCycles}; paid ${formatTokenAmountFromE8s(purchaseResult.paymentAmountE8s)}; database activation can complete.` - ); - await refreshWalletBalance(wallet); - await refreshDatabases(authClient); - } catch (cause) { - const message = errorMessage(cause); - if (createdDatabaseId) { - await refreshDatabases(authClient); - setError(`Database created pending, but initial cycles purchase failed: ${message}`); - } else { - setError(message); - } - setLoadState("error"); - } finally { - setCreating(false); - } - } - - const myDatabases = databases.filter((database) => database.member); - const publicDatabases = databases.filter((database) => !database.member && database.publicReadable); - const trimmedDatabaseName = newDatabaseName.trim(); - const databaseNameValidationError = databaseNameError(trimmedDatabaseName); - const walletReadyToFundCreate = walletCanFundCreate(walletBalance); - const createUnavailable = loadState === "loading" || walletBusyProvider !== null || walletBalanceLoading || !walletReadyToFundCreate; - const createDisabled = creating || createUnavailable || databaseNameValidationError !== null; - const connectedWalletLabel = wallet ? `${walletLabel(wallet.provider)} ${shortPrincipal(walletPrincipal(wallet))}` : null; - const connectedWalletBalanceLabel = walletBalance ? formatTokenAmountFromE8s(walletBalance) : null; - const createButtonLabel = databaseCreateButtonLabel({ creating, walletConnected: Boolean(wallet), walletBalanceLoading, walletReadyToFundCreate }); - +function HomePageFallback() { return ( -
+
- - { - void connectWallet(provider); - }} - onDisconnect={disconnectWallet} - /> - { - if (authClient) void refreshDatabases(authClient); - }} - /> - - } - /> - - {error ? : null} - {walletBalanceError ? : null} - {warning ? : null} - {walletMessage ? : null} - { - if (creating) return; - setCreateDialogOpen(false); - setNewDatabaseName(""); - }} - onChange={setNewDatabaseName} - onSubmit={() => void createDatabase()} - /> - - - - {principal ? ( - setCreateDialogOpen(true)} - > - - {createButtonLabel} - - } - cyclesConfig={cyclesConfig} - loading={loadState === "loading"} - myDatabases={myDatabases} - principal={principal} - publicDatabases={publicDatabases} - publicError={publicError} - /> - ) : ( -
-
-
-

Public databases

-

Public databases open without login. Login with Internet Identity to show My databases linked to your principal.

-
-
- -
- )} +
Loading databases...
); } - -function mergeDatabaseRows(memberDatabases: DatabaseSummary[], publicDatabases: DatabaseSummary[]): DatabaseRow[] { - const publicIds = new Set(publicDatabases.map((database) => database.databaseId)); - const rows = new Map(); - for (const database of publicDatabases) { - rows.set(database.databaseId, { ...database, member: false, publicReadable: true }); - } - for (const database of memberDatabases) { - rows.set(database.databaseId, { ...database, member: true, publicReadable: publicIds.has(database.databaseId) }); - } - return [...rows.values()].sort((left, right) => left.databaseId.localeCompare(right.databaseId)); -} - -function listWarning(memberResult: PromiseSettledResult): string | null { - if (memberResult.status === "rejected") return `Member database list unavailable: ${errorMessage(memberResult.reason)}`; - return null; -} - -function createDatabasePurchaseAmountE8s(): bigint { - const parsed = parseKinicAmountE8sInput(CREATE_DATABASE_PURCHASE_KINIC); - if (typeof parsed === "string") throw new Error(parsed); - return parsed; -} - -function createDatabaseRequiredBalanceE8s(): bigint { - return createDatabasePurchaseAmountE8s() + KINIC_LEDGER_FEE_E8S * 2n; -} - -function walletLabel(provider: HeaderWalletProvider): string { - return provider === "oisy" ? "OISY" : "Plug"; -} - -function walletPrincipal(wallet: ConnectedHeaderWallet): string { - return wallet.provider === "oisy" ? wallet.connection.owner : wallet.connection.principal; -} - -function walletCanFundCreate(balanceE8s: string | null): boolean { - if (!balanceE8s || !/^\d+$/.test(balanceE8s)) return false; - return BigInt(balanceE8s) >= createDatabaseRequiredBalanceE8s(); -} - -function databaseCreateButtonLabel({ - creating, - walletConnected, - walletBalanceLoading, - walletReadyToFundCreate -}: { - creating: boolean; - walletConnected: boolean; - walletBalanceLoading: boolean; - walletReadyToFundCreate: boolean; -}): string { - if (creating) return "Creating..."; - if (!walletConnected) return "Connect wallet first"; - if (walletBalanceLoading) return "Checking balance..."; - if (!walletReadyToFundCreate) return "Insufficient KINIC"; - return "Create and fund database"; -} - -function shortPrincipal(value: string): string { - if (value.length <= 16) return value; - return `${value.slice(0, 8)}...${value.slice(-5)}`; -} - -function errorMessage(cause: unknown): string { - return cause instanceof Error ? cause.message : "Unexpected error"; -} - -function databaseNameError(databaseName: string): string | null { - if (databaseName.length === 0) return "Database name is required."; - if ([...databaseName].length > 80) return "Database name must be 1..80 characters."; - return /[\u0000-\u001f\u007f]/.test(databaseName) ? "Database name may not contain control characters." : null; -} diff --git a/wikibrowser/lib/cycles-state.ts b/wikibrowser/lib/cycles-state.ts index 352e8bfd..59534e3c 100644 --- a/wikibrowser/lib/cycles-state.ts +++ b/wikibrowser/lib/cycles-state.ts @@ -98,6 +98,7 @@ export function databaseCyclesDisabledReason(database: DatabaseSummary | null, c export function databaseCyclesHref(database: DatabaseSummary): string { const params = new URLSearchParams(); params.set("databaseId", database.databaseId); + params.set("status", database.status); return `/cycles?${params.toString()}`; } diff --git a/wikibrowser/lib/cycles-url.ts b/wikibrowser/lib/cycles-url.ts index 38b40568..aa5cffa7 100644 --- a/wikibrowser/lib/cycles-url.ts +++ b/wikibrowser/lib/cycles-url.ts @@ -1,13 +1,12 @@ // Where: purchase page URL and amount validation. // What: validates the target database from URL state and parses user-entered KINIC. // Why: purchase amount is UI state, not a query parameter contract. -import { KINIC_DECIMALS, kinicBaseUnitsPerToken } from "@/lib/cycles"; +import { KINIC_DECIMALS, MAX_CANISTER_I64, kinicBaseUnitsPerToken } from "@/lib/cycles"; export type CyclesTarget = { databaseId: string; }; -const MAX_U64 = 18_446_744_073_709_551_615n; const KINIC_AMOUNT_PATTERN = new RegExp(`^([0-9]+)(?:\\.([0-9]{1,${KINIC_DECIMALS}}))?$`); export function parseCyclesTarget(input: URLSearchParams): CyclesTarget | string { @@ -24,7 +23,7 @@ export function parseKinicAmountE8sInput(value: string): bigint | string { const amountE8s = BigInt(match[1]) * kinicBaseUnitsPerToken() + BigInt((match[2] ?? "").padEnd(KINIC_DECIMALS, "0") || "0"); if (amountE8s <= 0n) return "KINIC amount must be positive"; - if (amountE8s > MAX_U64) return "KINIC amount e8s must be <= u64::MAX"; + if (amountE8s > MAX_CANISTER_I64) return "KINIC amount e8s must be <= i64::MAX"; return amountE8s; } diff --git a/wikibrowser/lib/cycles-wallet.ts b/wikibrowser/lib/cycles-wallet.ts index 88ea273c..0effbccf 100644 --- a/wikibrowser/lib/cycles-wallet.ts +++ b/wikibrowser/lib/cycles-wallet.ts @@ -6,7 +6,7 @@ import { IDL } from "@icp-sdk/core/candid"; import { Principal } from "@icp-sdk/core/principal"; import { getCyclesBillingConfig, type DatabaseCyclesPurchaseRequest } from "@/lib/vfs-client"; import { idlFactory } from "@/lib/vfs-idl"; -import { formatRawCycles, KINIC_LEDGER_FEE_E8S, kinicBaseUnitsPerToken } from "@/lib/cycles"; +import { formatRawCycles, KINIC_LEDGER_FEE_E8S, MAX_CANISTER_I64, MAX_LEDGER_U64, kinicBaseUnitsPerToken } from "@/lib/cycles"; import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; type WalletProvider = "oisy" | "plug"; @@ -137,8 +137,6 @@ declare global { const DEFAULT_OISY_SIGNER_URL = "https://oisy.com/sign"; const CALL_TIMEOUT_MS = 120_000; const APPROVE_EXPIRES_IN_MS = 30 * 60 * 1000; -const MAX_I64 = 9_223_372_036_854_775_807n; -const MAX_U64 = 18_446_744_073_709_551_615n; type ActorInterfaceFactory = Parameters[0]; type CyclesPurchaseIcrcWalletOptions = { @@ -338,20 +336,20 @@ async function prepareCyclesPurchase(request: CyclesPurchaseRequest, payer: stri function allowanceForCyclesPurchase(amountE8s: bigint, transferFeeE8s: bigint): bigint { const allowance = amountE8s + transferFeeE8s; - if (allowance > MAX_U64) throw new Error("approved allowance exceeds u64::MAX"); + if (allowance > MAX_LEDGER_U64) throw new Error("approved allowance exceeds u64::MAX"); return allowance; } function cyclesForPaymentAmountE8s(amountE8s: bigint, cyclesPerKinic: bigint): bigint { const cycles = (amountE8s * cyclesPerKinic) / kinicBaseUnitsPerToken(); if (cycles <= 0n) throw new Error("KINIC amount is too small for a cycles purchase"); - if (cycles > MAX_I64) throw new Error("cycles purchase amount exceeds canister limit"); + if (cycles > MAX_CANISTER_I64) throw new Error("cycles purchase amount exceeds canister limit"); return cycles; } function assertCanisterPaymentAmountE8s(amountE8s: bigint): void { if (amountE8s <= 0n) throw new Error("KINIC amount must be positive"); - if (amountE8s > MAX_I64) throw new Error("KINIC amount e8s exceeds canister limit"); + if (amountE8s > MAX_CANISTER_I64) throw new Error("KINIC amount e8s exceeds canister limit"); } function approveExpiresAt(): bigint { diff --git a/wikibrowser/lib/cycles.ts b/wikibrowser/lib/cycles.ts index a95c37b7..4b2ef5ab 100644 --- a/wikibrowser/lib/cycles.ts +++ b/wikibrowser/lib/cycles.ts @@ -3,6 +3,8 @@ export type CycleTone = "blue" | "amber" | "red" | "gray"; export const KINIC_LEDGER_FEE_E8S = 100_000n; export const KINIC_DECIMALS = 8; export const CYCLES_PER_KINIC = 234_500_000_000n; +export const MAX_CANISTER_I64 = 9_223_372_036_854_775_807n; +export const MAX_LEDGER_U64 = 18_446_744_073_709_551_615n; export function kinicBaseUnitsPerToken(): bigint { return 10n ** BigInt(KINIC_DECIMALS); diff --git a/wikibrowser/scripts/check-cycles-wallet.mjs b/wikibrowser/scripts/check-cycles-wallet.mjs index b87aabe8..a5b4f0b7 100644 --- a/wikibrowser/scripts/check-cycles-wallet.mjs +++ b/wikibrowser/scripts/check-cycles-wallet.mjs @@ -52,7 +52,13 @@ const walletModule = loadTsModule( } }, "@/lib/vfs-idl": { idlFactory: () => ({}) }, - "@/lib/cycles": { formatRawCycles: (value) => value.toString(), KINIC_LEDGER_FEE_E8S: 100_000n, kinicBaseUnitsPerToken: () => 100_000_000n }, + "@/lib/cycles": { + formatRawCycles: (value) => value.toString(), + KINIC_LEDGER_FEE_E8S: 100_000n, + MAX_CANISTER_I64: 9_223_372_036_854_775_807n, + MAX_LEDGER_U64: 18_446_744_073_709_551_615n, + kinicBaseUnitsPerToken: () => 100_000_000n + }, "@/lib/kinic-amount": { formatTokenAmountFromE8s } }, "Object.assign(exports, { __test: { allowanceForCyclesPurchase, assertCanisterPaymentAmountE8s, assertConfiguredCyclesCanister, cyclesForPaymentAmountE8s, purchaseAfterApprove, decodeOisyCyclesPurchaseResult, formatLedgerApproveError } });" diff --git a/wikibrowser/scripts/check-cycles.mjs b/wikibrowser/scripts/check-cycles.mjs index ab1e79b6..5f0d89de 100644 --- a/wikibrowser/scripts/check-cycles.mjs +++ b/wikibrowser/scripts/check-cycles.mjs @@ -8,6 +8,8 @@ const ts = require("typescript"); const page = readFileSync(new URL("../app/cycles/page.tsx", import.meta.url), "utf8"); const client = readFileSync(new URL("../app/cycles/cycles-client.tsx", import.meta.url), "utf8"); +const appHeader = readFileSync(new URL("../app/app-header.tsx", import.meta.url), "utf8"); +const appSession = readFileSync(new URL("../app/app-session-provider.tsx", import.meta.url), "utf8"); const wallet = readFileSync(new URL("../lib/cycles-wallet.ts", import.meta.url), "utf8"); const url = readFileSync(new URL("../lib/cycles-url.ts", import.meta.url), "utf8"); const idl = readFileSync(new URL("../lib/vfs-idl.ts", import.meta.url), "utf8"); @@ -22,18 +24,32 @@ assert.match(page, /\/cycles/); assert.doesNotMatch(page, /canister_id \?\? params\.canisterId/); assert.doesNotMatch(page, /amount_e8s \?\? params\.amountE8s/); assert.doesNotMatch(page, /params\.kinic|initialKinic/); +assert.match(page, /parseDatabaseStatus\(first\(params\.status\)\)/); assert.match(client, /purchaseCyclesWithOisy/); assert.match(client, /purchaseCyclesWithPlug/); -assert.match(client, /connectOisyWallet/); -assert.match(client, /connectPlugWallet/); +assert.match(client, /useAppSession/); +assert.match(appHeader, /pathname !== "\/" && pathname !== "\/cycles"/); +assert.match(appHeader, /Database cycles purchase/); +assert.match(appHeader, / void purchase\(\)\}/); assert.doesNotMatch(client, /onCycles/); assert.match(client, /parseKinicAmountE8sInput/); @@ -73,8 +89,8 @@ assert.match(wallet, /icrc2_approve: idl\.Func\(\[approveArgs\], \[idl\.Variant\ assert.doesNotMatch(wallet, /purchaseDatabaseCyclesFrom|notifyIdentity|wallet as unknown|OisyCanisterCaller/); assert.doesNotMatch(wallet, /previewDatabaseCyclesPurchase/); assert.match(wallet, /KINIC_LEDGER_FEE_E8S/); -assert.match(wallet, /MAX_I64/); -assert.match(wallet, /MAX_U64/); +assert.match(wallet, /MAX_CANISTER_I64/); +assert.match(wallet, /MAX_LEDGER_U64/); assert.match(wallet, /function allowanceForCyclesPurchase\(amountE8s: bigint, transferFeeE8s: bigint\)/); assert.match(wallet, /approved allowance exceeds u64::MAX/); assert.match(wallet, /cycles purchase amount exceeds canister limit/); @@ -111,6 +127,7 @@ assert.match(client, /approved allowance/); assert.doesNotMatch(client, /Wallet approval uses the DB cycle amount plus the ledger transfer fee/); assert.match(client, /transfer fee/); assert.match(client, /A newly created database is pending, not active, until this first cycles purchase completes\./); +assert.match(client, /databaseStatus === "pending"/); assert.match(client, /Any authenticated wallet can purchase non-refundable cycles/); assert.doesNotMatch(client, new RegExp("extractCycles" + "RepairTarget")); assert.doesNotMatch(client, new RegExp("saveCycles" + "Repair" + "Record")); @@ -122,9 +139,10 @@ assert.doesNotMatch(client, new RegExp("Billing authority " + "repair " + "requi assert.doesNotMatch(client, /withdraw KINIC|database balance/); assert.doesNotMatch(client, /cycles canister does not match NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID/); assert.match(url, /KINIC_DECIMALS/); +assert.match(url, /MAX_CANISTER_I64/); assert.match(url, /kinicBaseUnitsPerToken/); assert.match(url, /KINIC must be a positive number with up to \$\{KINIC_DECIMALS\} decimals/); -assert.match(url, /KINIC amount e8s must be <= u64::MAX/); +assert.match(url, /KINIC amount e8s must be <= i64::MAX/); assert.match(url, /database_id is required/); assert.doesNotMatch(url, /params\.set\("amount_e8s"/); assert.match(idl, /get_cycles_billing_config/); @@ -140,6 +158,7 @@ assert.doesNotMatch(vfsClient, /purchaseDatabaseCyclesFrom/); const cyclesUrlModule = loadTsModule("../lib/cycles-url.ts", { "@/lib/cycles": { KINIC_DECIMALS: 8, + MAX_CANISTER_I64: 9_223_372_036_854_775_807n, kinicBaseUnitsPerToken: () => 100_000_000n } }); @@ -152,7 +171,8 @@ assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("0.00000001"), 1n); assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("1.23456789"), 123_456_789n); assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("0"), "KINIC amount must be positive"); assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("1.000000001"), "KINIC must be a positive number with up to 8 decimals"); -assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("184467440737.09551616"), "KINIC amount e8s must be <= u64::MAX"); +assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("92233720368.54775807"), 9_223_372_036_854_775_807n); +assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("92233720368.54775808"), "KINIC amount e8s must be <= i64::MAX"); const clientModule = loadTsModule( "../app/cycles/cycles-client.tsx", @@ -172,14 +192,19 @@ const clientModule = loadTsModule( useState: (initial) => [typeof initial === "function" ? initial() : initial, () => undefined] }, "react/jsx-runtime": { jsx: () => null, jsxs: () => null }, + "@/app/app-session-provider": { + useAppSession: () => ({ + refreshWalletBalance: async () => undefined, + wallet: null, + walletBalanceError: null, + walletBusyProvider: null + }) + }, "@/lib/cycles-url": { parseKinicAmountE8sInput: () => 100n, parseCyclesTarget: () => ({ databaseId: "db_alpha" }) }, "@/lib/cycles-wallet": { - connectOisyWallet: async () => ({}), - connectPlugWallet: async () => ({}), - getConnectedWalletKinicBalance: async () => "0", purchaseCyclesWithOisy: async () => ({}), purchaseCyclesWithPlug: async () => ({}) }, diff --git a/wikibrowser/scripts/check-dashboard.mjs b/wikibrowser/scripts/check-dashboard.mjs index b60dc8eb..f0053608 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -14,10 +14,14 @@ const vfsIdl = readFileSync(new URL("../lib/vfs-idl.ts", import.meta.url), "utf8 const vfsClient = readFileSync(new URL("../lib/vfs-client.ts", import.meta.url), "utf8"); const createDatabaseDialog = readFileSync(new URL("../app/create-database-dialog.tsx", import.meta.url), "utf8"); const adminHeader = readFileSync(new URL("../components/admin-header.tsx", import.meta.url), "utf8"); +const appHeader = readFileSync(new URL("../app/app-header.tsx", import.meta.url), "utf8"); +const appSession = readFileSync(new URL("../app/app-session-provider.tsx", import.meta.url), "utf8"); +const rootLayout = readFileSync(new URL("../app/layout.tsx", import.meta.url), "utf8"); const cliPage = readFileSync(new URL("../app/cli/page.tsx", import.meta.url), "utf8"); const cliGuideBlock = readFileSync(new URL("../app/cli/cli-guide-block.tsx", import.meta.url), "utf8"); const homeUi = readFileSync(new URL("../app/home-ui.tsx", import.meta.url), "utf8"); const homePage = readFileSync(new URL("../app/page.tsx", import.meta.url), "utf8"); +const homePageClient = readFileSync(new URL("../app/home-page-client.tsx", import.meta.url), "utf8"); const cyclesState = readFileSync(new URL("../lib/cycles-state.ts", import.meta.url), "utf8"); const apiErrors = readFileSync(new URL("../lib/api-errors.ts", import.meta.url), "utf8"); const wikiLayout = readFileSync(new URL("../app/[databaseId]/layout.tsx", import.meta.url), "utf8"); @@ -37,8 +41,21 @@ assert.match(adminHeader, /export function AdminHeader/); assert.match(adminHeader, /titleAction\?: ReactNode/); assert.match(adminHeader, /titleAction \?
/); assert.match(adminHeader, /Kinic Wiki/); -assert.match(homePage, //); +assert.match(rootLayout, //); +assert.match(appHeader, /usePathname/); +assert.match(appHeader, /pathname !== "\/" && pathname !== "\/cycles"/); +assert.match(appHeader, /title = pathname === "\/cycles" \? "Database cycles purchase" : "Database dashboard"/); +assert.match(appHeader, /Database dashboard/); +assert.match(appHeader, /\}>/); +assert.match(homePage, //); +assert.doesNotMatch(homePage, /useSearchParams/); +assert.doesNotMatch(homePageClient, / database\.member\)/); -assert.match(homePage, /publicDatabases = databases\.filter\(\(database\) => !database\.member && database\.publicReadable\)/); -assert.match(homePage, /Database dashboard/); -assert.match(homePage, //); -assert.match(homePage, /const \[createDialogOpen, setCreateDialogOpen\] = useState\(false\);/); -assert.match(homePage, /const \[newDatabaseName, setNewDatabaseName\] = useState\(""\);/); -assert.match(homePage, /const databaseNameInput = newDatabaseName\.trim\(\);/); -assert.match(homePage, /createDatabaseAuthenticated\(canisterId, authClient\.getIdentity\(\), databaseNameInput\)/); -assert.match(homePage, /connectOisyWallet/); -assert.match(homePage, /connectPlugWallet/); -assert.match(homePage, /getConnectedWalletKinicBalance/); -assert.match(homePage, /const \[walletBalance, setWalletBalance\] = useState\(null\);/); -assert.match(homePage, /const connectedWalletBalanceLabel = walletBalance \? formatTokenAmountFromE8s\(walletBalance\) : null;/); -assert.match(homePage, /await refreshWalletBalance\(wallet\);/); -assert.match(homePage, /purchaseCyclesWithOisy/); -assert.match(homePage, /purchaseCyclesWithPlug/); -assert.match(homePage, /CREATE_DATABASE_PURCHASE_KINIC = "1"/); -assert.match(homePage, /import \{ KINIC_LEDGER_FEE_E8S \} from "@\/lib\/cycles";/); -assert.match(homePage, /const paymentAmountE8s = createDatabasePurchaseAmountE8s\(\);/); -assert.match(homePage, /function createDatabaseRequiredBalanceE8s\(\): bigint/); -assert.match(homePage, /return createDatabasePurchaseAmountE8s\(\) \+ KINIC_LEDGER_FEE_E8S \* 2n;/); -assert.match(homePage, /Database created pending\. Requesting/); -assert.match(homePage, /Database created pending, but initial cycles purchase failed/); -assert.doesNotMatch(homePage, /purchaseQueryString\(\{ databaseId: result\.database_id \}\)/); -assert.doesNotMatch(homePage, /useRouter|router\.push/); -assert.match(homePage, /Connect OISY or Plug with at least \$\{formatTokenAmountFromE8s\(createDatabaseRequiredBalanceE8s\(\)\)\} before creating a database\./); -assert.match(homePage, /Create database requires at least \$\{formatTokenAmountFromE8s\(createDatabaseRequiredBalanceE8s\(\)\)\} in the connected wallet\./); -assert.doesNotMatch(homePage, /Checking KINIC balance|Create database will request the first cycles purchase|walletFundingMessage/); -assert.match(homePage, /await purchaseCyclesWithOisy\(\{ canisterId, databaseId: result\.database_id, paymentAmountE8s \}, wallet\.connection\)/); -assert.match(homePage, /await purchaseCyclesWithPlug\(\{ canisterId, databaseId: result\.database_id, paymentAmountE8s \}, wallet\.connection\)/); -assert.match(homePage, /const walletReadyToFundCreate = walletCanFundCreate\(walletBalance\);/); -assert.match(homePage, /const createUnavailable = loadState === "loading" \|\| walletBusyProvider !== null \|\| walletBalanceLoading \|\| !walletReadyToFundCreate;/); -assert.match(homePage, /function walletCanFundCreate\(balanceE8s: string \| null\): boolean/); -assert.match(homePage, /return BigInt\(balanceE8s\) >= createDatabaseRequiredBalanceE8s\(\);/); -assert.match(homePage, /function databaseCreateButtonLabel/); -assert.match(homePage, /return "Connect wallet first"/); -assert.match(homePage, /return "Checking balance\.\.\."/); -assert.match(homePage, /return "Insufficient KINIC"/); -assert.match(homePage, /return "Create and fund database"/); -assert.match(homePage, /disabled=\{creating \|\| createUnavailable\}/); -assert.match(homePage, / database\.member\)/); +assert.match(homePageClient, /publicDatabases = databases\.filter\(\(database\) => !database\.member && database\.publicReadable\)/); +assert.doesNotMatch(homePageClient, /Database dashboard/); +assert.match(homePageClient, //); +assert.match(homePageClient, /const \[createDialogOpen, setCreateDialogOpen\] = useState\(false\);/); +assert.match(homePageClient, /const \[newDatabaseName, setNewDatabaseName\] = useState\(""\);/); +assert.match(homePageClient, /const databaseNameInput = newDatabaseName\.trim\(\);/); +assert.match(homePageClient, /createDatabaseAuthenticated\(canisterId, authClient\.getIdentity\(\), databaseNameInput\)/); +assert.match(homePageClient, /useAppSession/); +assert.match(homePageClient, /authRefreshSeq/); +assert.match(homePageClient, /setWalletControlsLocked\(creating\)/); +assert.match(appSession, /connectOisyWallet/); +assert.match(appSession, /connectPlugWallet/); +assert.match(appSession, /getConnectedWalletKinicBalance/); +assert.match(appSession, /sessionStorage\.setItem/); +assert.match(appSession, /sessionStorage\.removeItem/); +assert.match(appSession, /provider: nextWallet\.provider/); +assert.match(appSession, /principal: connectedWalletPrincipal\(nextWallet\)/); +assert.match(appSession, /setWallet\(restoredWallet\)/); +assert.match(homePageClient, /await refreshWalletBalance\(wallet\);/); +assert.match(homePageClient, /purchaseCyclesWithOisy/); +assert.match(homePageClient, /purchaseCyclesWithPlug/); +assert.match(homePageClient, /CREATE_DATABASE_PURCHASE_KINIC = "1"/); +assert.match(homePageClient, /import \{ KINIC_LEDGER_FEE_E8S \} from "@\/lib\/cycles";/); +assert.match(homePageClient, /const paymentAmountE8s = createDatabasePurchaseAmountE8s\(\);/); +assert.match(homePageClient, /function createDatabaseRequiredBalanceE8s\(\): bigint/); +assert.match(homePageClient, /return createDatabasePurchaseAmountE8s\(\) \+ KINIC_LEDGER_FEE_E8S \* 2n;/); +assert.match(homePageClient, /Database created pending\. Requesting/); +assert.match(homePageClient, /Database created pending, but initial cycles purchase failed/); +assert.doesNotMatch(homePageClient, /purchaseQueryString\(\{ databaseId: result\.database_id \}\)/); +assert.doesNotMatch(homePageClient, /useRouter|router\.push/); +assert.match(homePageClient, /Connect OISY or Plug with at least \$\{formatTokenAmountFromE8s\(createDatabaseRequiredBalanceE8s\(\)\)\} before creating a database\./); +assert.match(homePageClient, /Create database requires at least \$\{formatTokenAmountFromE8s\(createDatabaseRequiredBalanceE8s\(\)\)\} in the connected wallet\./); +assert.doesNotMatch(homePageClient, /Checking KINIC balance|Create database will request the first cycles purchase|walletFundingMessage/); +assert.match(homePageClient, /await purchaseCyclesWithOisy\(\{ canisterId, databaseId: result\.database_id, paymentAmountE8s \}, wallet\.connection\)/); +assert.match(homePageClient, /await purchaseCyclesWithPlug\(\{ canisterId, databaseId: result\.database_id, paymentAmountE8s \}, wallet\.connection\)/); +assert.match(homePageClient, /const walletReadyToFundCreate = walletCanFundCreate\(walletBalance\);/); +assert.match(homePageClient, /const createUnavailable = loadState === "loading" \|\| walletBusyProvider !== null \|\| walletBalanceLoading \|\| !walletReadyToFundCreate;/); +assert.match(homePageClient, /function walletCanFundCreate\(balanceE8s: string \| null\): boolean/); +assert.match(homePageClient, /return BigInt\(balanceE8s\) >= createDatabaseRequiredBalanceE8s\(\);/); +assert.match(homePageClient, /function databaseCreateButtonLabel/); +assert.match(homePageClient, /return "Connect wallet first"/); +assert.match(homePageClient, /return "Checking balance\.\.\."/); +assert.match(homePageClient, /return "Insufficient KINIC"/); +assert.match(homePageClient, /return "Create and fund database"/); +assert.match(homePageClient, /disabled=\{creating \|\| createUnavailable\}/); +assert.doesNotMatch(homePageClient, / Date: Wed, 3 Jun 2026 18:30:07 +0900 Subject: [PATCH 4/7] Make database cycles browser open non-fatal and fix payment docs --- crates/vfs_cli_app/src/main.rs | 6 ++---- docs/payment.md | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/vfs_cli_app/src/main.rs b/crates/vfs_cli_app/src/main.rs index b3377345..c8a72659 100644 --- a/crates/vfs_cli_app/src/main.rs +++ b/crates/vfs_cli_app/src/main.rs @@ -4,7 +4,7 @@ use anyhow::Result; use clap::Parser; use vfs_cli::commands::{ - database_cycles_url, open_browser_url, print_database_current, run_database_unlink, + database_cycles_url, open_database_cycles_page, print_database_current, run_database_unlink, }; use vfs_cli::connection::{ ResolvedConnection, resolve_connection, resolve_connection_optional_canister, @@ -43,9 +43,7 @@ async fn main() -> Result<()> { database_id, browser_origin, } => { - let url = database_cycles_url(browser_origin.as_deref(), database_id)?; - println!("{url}"); - open_browser_url(&url)?; + open_database_cycles_page(browser_origin.as_deref(), database_id)?; return Ok(()); } _ => {} diff --git a/docs/payment.md b/docs/payment.md index 23c56d25..f2d18df7 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -124,7 +124,7 @@ browser `/cycles` は approve 後の purchase failure でも通常の error 表 dashboard の `Create database` は OISY または Plug 接続済み、かつ接続 wallet の KINIC 残高が `1 KINIC + ledger transfer fee + approve fee` 以上の場合だけ押せる。作成後は同じ wallet で `1 KINIC` の approve と `purchase_database_cycles` を連続実行する。残高未取得、wallet 未接続、または必要額未満では DB 作成自体を開始しない。 -KINIC 入力は正の数だけ許可する。小数は最大 8 桁、URL/UI parser 上の e8s 換算値は `u64::MAX` 以下でなければならない。購入直前の wallet flow は canister 実効上限として `payment_amount_e8s <= i64::MAX` と `amount_cycles <= i64::MAX` も確認する。 +KINIC 入力は正の数だけ許可する。小数は最大 8 桁、URL/UI parser 上の e8s 換算値は `i64::MAX` 以下でなければならない。購入直前の wallet flow は canister 実効上限として `payment_amount_e8s <= i64::MAX` と `amount_cycles <= i64::MAX` も確認する。 OISY と Plug の wallet flow は、購入直前に canister config を取得する。承認 allowance は次である。 From 74f1b2f58c07ac5c289ed520e450a86445f2c9e5 Mon Sep 17 00:00:00 2001 From: hude Date: Thu, 4 Jun 2026 07:34:34 +0900 Subject: [PATCH 5/7] Fix cycles wallet approval reuse and CLI fmt --- crates/vfs_cli_app/src/main.rs | 5 +- docs/payment.md | 2 +- wikibrowser/app/app-session-provider.tsx | 50 ++++++-- wikibrowser/app/home-page-client.tsx | 24 +++- wikibrowser/lib/cycles-wallet.ts | 98 ++++++++++----- wikibrowser/scripts/check-cycles-wallet.mjs | 132 +++++++++++++++++++- wikibrowser/scripts/check-cycles.mjs | 22 +++- wikibrowser/scripts/check-dashboard.mjs | 9 +- 8 files changed, 279 insertions(+), 63 deletions(-) diff --git a/crates/vfs_cli_app/src/main.rs b/crates/vfs_cli_app/src/main.rs index c8a72659..7f1d5817 100644 --- a/crates/vfs_cli_app/src/main.rs +++ b/crates/vfs_cli_app/src/main.rs @@ -3,9 +3,7 @@ // Why: Wiki operations and Skill Registry operations share connection, identity, and DB selection. use anyhow::Result; use clap::Parser; -use vfs_cli::commands::{ - database_cycles_url, open_database_cycles_page, print_database_current, run_database_unlink, -}; +use vfs_cli::commands::{open_database_cycles_page, print_database_current, run_database_unlink}; use vfs_cli::connection::{ ResolvedConnection, resolve_connection, resolve_connection_optional_canister, }; @@ -159,6 +157,7 @@ async fn new_identity_client( #[cfg(test)] mod tests { use super::*; + use vfs_cli::commands::database_cycles_url; #[test] fn database_cycles_url_resolves_without_connection_or_client() { diff --git a/docs/payment.md b/docs/payment.md index f2d18df7..7fabb586 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -132,7 +132,7 @@ OISY と Plug の wallet flow は、購入直前に canister config を取得す approved_allowance_e8s = payment_amount_e8s + ledger_fee_e8s ``` -approve の transaction fee は wallet 残高から別途支払われる。approve は現在 allowance を `expected_allowance` として渡し、30 分後に expire する。approve 後に purchase が失敗した場合、UI は approval が expire まで残る旨を error に含める。 +approve の transaction fee は wallet 残高から別途支払われる。未期限切れ allowance が `approved_allowance_e8s` 以上残っている場合、UI は再 approve せず既存 allowance で `purchase_database_cycles` を再試行する。approve が必要な場合は現在 allowance を `expected_allowance` として渡し、30 分後に expire する。approval 後に purchase が失敗した場合、UI は approval が expire まで残る旨を error に含める。無期限 allowance を再利用した場合は、無期限 approval が残る旨を error に含める。 UI は `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID` と request canister ID が一致しない場合に拒否する。Plug は VFS canister と KINIC ledger canister を whitelist して接続する。OISY は接続確認後に signer popup を閉じ、購入時に再度 signer を開いて同じ owner であることを確認する。OISY は ICRC wallet の call-canister 結果 certificate を検証し、method、canister、arg、reply を照合する。 diff --git a/wikibrowser/app/app-session-provider.tsx b/wikibrowser/app/app-session-provider.tsx index 6818c60f..b0582a8e 100644 --- a/wikibrowser/app/app-session-provider.tsx +++ b/wikibrowser/app/app-session-provider.tsx @@ -43,7 +43,7 @@ export function AppSessionProvider({ children }: { children: ReactNode }) { const [authReady, setAuthReady] = useState(false); const [authRefreshSeq, setAuthRefreshSeq] = useState(0); const [principal, setPrincipal] = useState(null); - const [wallet, setWallet] = useState(null); + const [wallet, setWallet] = useState(() => readStoredWallet()); const [walletBalance, setWalletBalance] = useState(null); const [walletBalanceError, setWalletBalanceError] = useState(null); const [walletBalanceLoading, setWalletBalanceLoading] = useState(false); @@ -55,11 +55,11 @@ export function AppSessionProvider({ children }: { children: ReactNode }) { }, []); const clearStoredWallet = useCallback(() => { - sessionStorage.removeItem(WALLET_SESSION_KEY); + safeSessionStorageRemove(WALLET_SESSION_KEY); }, []); const storeWallet = useCallback((nextWallet: ConnectedKinicWallet) => { - sessionStorage.setItem( + safeSessionStorageSet( WALLET_SESSION_KEY, JSON.stringify({ provider: nextWallet.provider, @@ -113,7 +113,6 @@ export function AppSessionProvider({ children }: { children: ReactNode }) { : { provider, connection: await connectPlugWallet() }; setWallet(nextWallet); storeWallet(nextWallet); - void refreshWalletBalance(nextWallet); } catch (cause) { setWalletBalance(null); setWalletBalanceError(errorMessage(cause)); @@ -121,7 +120,7 @@ export function AppSessionProvider({ children }: { children: ReactNode }) { setWalletBusyProvider(null); } }, - [refreshWalletBalance, storeWallet, walletBusyProvider, walletControlsLocked] + [storeWallet, walletBusyProvider, walletControlsLocked] ); const disconnectWallet = useCallback( @@ -208,11 +207,16 @@ export function AppSessionProvider({ children }: { children: ReactNode }) { }, [syncAuth]); useEffect(() => { - const restoredWallet = readStoredWallet(); - if (!restoredWallet) return; - setWallet(restoredWallet); - void refreshWalletBalance(restoredWallet); - }, [refreshWalletBalance]); + if (!wallet) return; + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + void refreshWalletBalance(wallet); + }); + return () => { + cancelled = true; + }; + }, [refreshWalletBalance, wallet]); return ( { return typeof value === "object" && value !== null; } diff --git a/wikibrowser/app/home-page-client.tsx b/wikibrowser/app/home-page-client.tsx index 5dcf63c5..d342777a 100644 --- a/wikibrowser/app/home-page-client.tsx +++ b/wikibrowser/app/home-page-client.tsx @@ -91,7 +91,14 @@ export function HomePageClient() { useEffect(() => { if (!authReady) return; - void refreshDatabases(authClient); + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + void refreshDatabases(authClient); + }); + return () => { + cancelled = true; + }; }, [authClient, authReady, authRefreshSeq, refreshDatabases]); useEffect(() => { @@ -101,10 +108,17 @@ export function HomePageClient() { useEffect(() => { if (principal) return; - setCyclesBillingConfig(null); - setCreateDialogOpen(false); - setNewDatabaseName(""); - setWalletMessage(null); + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + setCyclesBillingConfig(null); + setCreateDialogOpen(false); + setNewDatabaseName(""); + setWalletMessage(null); + }); + return () => { + cancelled = true; + }; }, [principal]); async function createDatabase() { diff --git a/wikibrowser/lib/cycles-wallet.ts b/wikibrowser/lib/cycles-wallet.ts index 0effbccf..a208e05d 100644 --- a/wikibrowser/lib/cycles-wallet.ts +++ b/wikibrowser/lib/cycles-wallet.ts @@ -19,7 +19,7 @@ type CyclesPurchaseRequest = { type CyclesPurchaseResult = { provider: WalletProvider; - approveBlockIndex: string; + approveBlockIndex: string | null; approvedAllowanceE8s: string; purchasedCycles: string; paymentAmountE8s: string; @@ -29,12 +29,15 @@ type CyclesPurchaseResult = { }; export class CyclesPurchaseAfterApproveError extends Error { - approveBlockIndex: string; + approveBlockIndex: string | null; causeMessage: string; - constructor(input: { approveBlockIndex: string; causeMessage: string; expiresAt: bigint }) { - const expiry = new Date(Number(input.expiresAt / 1_000_000n)).toISOString(); - super(`cycles purchase failed after approve; approval remains until ${expiry}: ${input.causeMessage}`); + constructor(input: { approveBlockIndex: string | null; causeMessage: string; expiresAt: bigint | null }) { + const approvalText = + input.expiresAt === null + ? "approval remains without expiry" + : `approval remains until ${new Date(Number(input.expiresAt / 1_000_000n)).toISOString()}`; + super(`cycles purchase failed after approval; ${approvalText}: ${input.causeMessage}`); this.name = "CyclesPurchaseAfterApproveError"; this.approveBlockIndex = input.approveBlockIndex; this.causeMessage = input.causeMessage; @@ -47,7 +50,9 @@ type PreparedCyclesPurchase = { transferFeeE8s: bigint; paymentAmountE8s: bigint; approvedAllowanceE8s: bigint; - currentAllowanceE8s: bigint; + currentAllowance: LedgerAllowance; + approvalExpiresAt: bigint | null; + approvalRequired: boolean; expiresAt: bigint; }; @@ -220,19 +225,23 @@ export async function purchaseCyclesWithOisy(request: CyclesPurchaseRequest, con const account = accounts[0]; if (!account) throw new Error("OISY account not found"); if (account.owner !== connection.owner) throw new Error("OISY owner changed; connect OISY again"); - const approveBlockIndex = await wallet.approve({ - owner: connection.owner, - ledgerCanisterId: prepared.kinicLedgerCanisterId, - params: approveParams(request.canisterId, prepared.approvedAllowanceE8s, prepared.currentAllowanceE8s, prepared.expiresAt), - options: { timeoutInMilliseconds: CALL_TIMEOUT_MS } - }); + let approveBlockIndex: string | null = null; + if (prepared.approvalRequired) { + const approvedBlockIndex = await wallet.approve({ + owner: connection.owner, + ledgerCanisterId: prepared.kinicLedgerCanisterId, + params: approveParams(request.canisterId, prepared.approvedAllowanceE8s, prepared.currentAllowance.allowance, prepared.expiresAt), + options: { timeoutInMilliseconds: CALL_TIMEOUT_MS } + }); + approveBlockIndex = approvedBlockIndex.toString(); + } const purchase = await purchaseAfterApprove( () => oisyCallCyclesPurchase(wallet, connection.owner, request.canisterId, prepared.purchaseRequest), - { approveBlockIndex: approveBlockIndex.toString(), expiresAt: prepared.expiresAt } + { approveBlockIndex, expiresAt: prepared.approvalExpiresAt } ); return { provider: "oisy", - approveBlockIndex: approveBlockIndex.toString(), + approveBlockIndex, approvedAllowanceE8s: prepared.approvedAllowanceE8s.toString(), purchasedCycles: formatRawCycles(BigInt(purchase.amountCycles)), paymentAmountE8s: prepared.paymentAmountE8s.toString(), @@ -257,14 +266,18 @@ export async function purchaseCyclesWithPlug(request: CyclesPurchaseRequest, con const principal = await plug.agent?.getPrincipal(); if (!principal) throw new Error("Plug principal is not available"); if (principal.toText() !== connection.principal) throw new Error("Plug principal changed; connect Plug again"); - const ledgerActor = await plug.createActor({ - canisterId: prepared.kinicLedgerCanisterId, - interfaceFactory: ledgerIdlFactory - }); - const approve = await ledgerActor.icrc2_approve( - rawApproveArgs(request.canisterId, prepared.approvedAllowanceE8s, prepared.currentAllowanceE8s, prepared.expiresAt) - ); - if ("Err" in approve) throw new Error(`ledger approve failed: ${formatLedgerApproveError(approve.Err)}`); + let approveBlockIndex: string | null = null; + if (prepared.approvalRequired) { + const ledgerActor = await plug.createActor({ + canisterId: prepared.kinicLedgerCanisterId, + interfaceFactory: ledgerIdlFactory + }); + const approve = await ledgerActor.icrc2_approve( + rawApproveArgs(request.canisterId, prepared.approvedAllowanceE8s, prepared.currentAllowance.allowance, prepared.expiresAt) + ); + if ("Err" in approve) throw new Error(`ledger approve failed: ${formatLedgerApproveError(approve.Err)}`); + approveBlockIndex = approve.Ok.toString(); + } const vfsActor = await plug.createActor({ canisterId: request.canisterId, interfaceFactory: idlFactory @@ -273,10 +286,10 @@ export async function purchaseCyclesWithPlug(request: CyclesPurchaseRequest, con const result = await vfsActor.purchase_database_cycles(prepared.purchaseRequest); if ("Err" in result) throw new Error(result.Err); return result.Ok; - }, { approveBlockIndex: approve.Ok.toString(), expiresAt: prepared.expiresAt }); + }, { approveBlockIndex, expiresAt: prepared.approvalExpiresAt }); return { provider: "plug", - approveBlockIndex: approve.Ok.toString(), + approveBlockIndex, approvedAllowanceE8s: prepared.approvedAllowanceE8s.toString(), purchasedCycles: formatRawCycles(purchase.amount_cycles), paymentAmountE8s: prepared.paymentAmountE8s.toString(), @@ -318,7 +331,8 @@ async function prepareCyclesPurchase(request: CyclesPurchaseRequest, payer: stri const minExpectedCycles = cyclesForPaymentAmountE8s(paymentAmountE8s, BigInt(config.cyclesPerKinic)); const approvedAllowanceE8s = allowanceForCyclesPurchase(paymentAmountE8s, transferFeeE8s); const expiresAt = approveExpiresAt(); - const currentAllowanceE8s = await getLedgerAllowance(config.kinicLedgerCanisterId, payer, request.canisterId); + const currentAllowance = await getLedgerAllowance(config.kinicLedgerCanisterId, payer, request.canisterId); + const approvalRequired = !allowanceIsUsable(currentAllowance, approvedAllowanceE8s, nowNs()); return { kinicLedgerCanisterId: config.kinicLedgerCanisterId, purchaseRequest: { @@ -329,7 +343,9 @@ async function prepareCyclesPurchase(request: CyclesPurchaseRequest, payer: stri transferFeeE8s, paymentAmountE8s, approvedAllowanceE8s, - currentAllowanceE8s, + currentAllowance, + approvalExpiresAt: approvalRequired ? expiresAt : allowanceExpiresAt(currentAllowance), + approvalRequired, expiresAt }; } @@ -352,8 +368,22 @@ function assertCanisterPaymentAmountE8s(amountE8s: bigint): void { if (amountE8s > MAX_CANISTER_I64) throw new Error("KINIC amount e8s exceeds canister limit"); } +function allowanceIsUsable(allowance: LedgerAllowance, requiredAllowanceE8s: bigint, currentTimeNs: bigint): boolean { + if (allowance.allowance < requiredAllowanceE8s) return false; + const expiresAt = allowanceExpiresAt(allowance); + return expiresAt === null || expiresAt > currentTimeNs; +} + +function allowanceExpiresAt(allowance: LedgerAllowance): bigint | null { + return allowance.expires_at[0] ?? null; +} + function approveExpiresAt(): bigint { - return BigInt(Date.now() + APPROVE_EXPIRES_IN_MS) * 1_000_000n; + return nowNs() + BigInt(APPROVE_EXPIRES_IN_MS) * 1_000_000n; +} + +function nowNs(): bigint { + return BigInt(Date.now()) * 1_000_000n; } function assertConfiguredCyclesCanister(canisterId: string): void { @@ -364,7 +394,7 @@ function assertConfiguredCyclesCanister(canisterId: string): void { } } -async function getLedgerAllowance(ledgerCanisterId: string, owner: string, spender: string): Promise { +async function getLedgerAllowance(ledgerCanisterId: string, owner: string, spender: string): Promise { const host = process.env.NEXT_PUBLIC_WIKI_IC_HOST ?? "https://icp0.io"; const agent = HttpAgent.createSync({ identity: new AnonymousIdentity(), host }); if (agent.isLocal()) await agent.fetchRootKey(); @@ -372,8 +402,7 @@ async function getLedgerAllowance(ledgerCanisterId: string, owner: string, spend agent, canisterId: Principal.fromText(ledgerCanisterId) }); - const result = await actor.icrc2_allowance(allowanceArgs(owner, spender)); - return result.allowance; + return actor.icrc2_allowance(allowanceArgs(owner, spender)); } async function getLedgerBalance(ledgerCanisterId: string, owner: string): Promise { @@ -424,7 +453,7 @@ async function oisyCallCyclesPurchase( }); } -async function purchaseAfterApprove(run: () => Promise, context: { approveBlockIndex: string; expiresAt: bigint }): Promise { +async function purchaseAfterApprove(run: () => Promise, context: { approveBlockIndex: string | null; expiresAt: bigint | null }): Promise { try { return await run(); } catch (cause) { @@ -437,7 +466,12 @@ async function purchaseAfterApprove(run: () => Promise, context: { approve } function errorMessage(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); + if (cause instanceof Error) return cause.message; + if (isObject(cause)) { + const message = Reflect.get(cause, "message"); + if (typeof message === "string") return message; + } + return String(cause); } function encodeCyclesPurchaseArgs(request: DatabaseCyclesPurchaseRequest): string { diff --git a/wikibrowser/scripts/check-cycles-wallet.mjs b/wikibrowser/scripts/check-cycles-wallet.mjs index a5b4f0b7..45b0140d 100644 --- a/wikibrowser/scripts/check-cycles-wallet.mjs +++ b/wikibrowser/scripts/check-cycles-wallet.mjs @@ -12,13 +12,32 @@ const ts = require("typescript"); const cborMock = { decoded: {} }; let lastBalanceAccount = null; let lastConfigCanister = null; +let ledgerAllowanceMock = { allowance: 0n, expires_at: [] }; +let approveCalls = 0; +let purchaseCalls = 0; +let lastApproveArgs = null; const ledgerActorMock = { icrc1_balance_of: async (account) => { lastBalanceAccount = account; return 123_456_789n; }, - icrc2_allowance: async () => ({ allowance: 0n, expires_at: [] }), - icrc2_approve: async () => ({ Ok: 1n }) + icrc2_allowance: async () => ledgerAllowanceMock, + icrc2_approve: async (args) => { + approveCalls += 1; + lastApproveArgs = args; + return { Ok: 1n }; + } +}; +const vfsActorMock = { + purchase_database_cycles: async () => { + purchaseCalls += 1; + return { Ok: { block_index: 7n, amount_cycles: 1000n, balance_cycles: 2000n } }; + } +}; +const plugMock = { + requestConnect: async () => true, + agent: { getPrincipal: async () => ({ toText: () => "plug-principal" }) }, + createActor: async ({ canisterId }) => (canisterId === "ledger" ? ledgerActorMock : vfsActorMock) }; const walletModule = loadTsModule( @@ -79,7 +98,13 @@ await assert.rejects( () => walletTest.purchaseAfterApprove(async () => { throw new Error("purchase rejected"); }, { approveBlockIndex: "11", expiresAt: 1_700_000_000_000_000_000n }), - /cycles purchase failed after approve; approval remains until .*purchase rejected/ + /cycles purchase failed after approval; approval remains until .*purchase rejected/ +); +await assert.rejects( + () => walletTest.purchaseAfterApprove(async () => { + throw new Error("purchase rejected"); + }, { approveBlockIndex: null, expiresAt: null }), + /cycles purchase failed after approval; approval remains without expiry: purchase rejected/ ); await walletTest.purchaseAfterApprove(async () => "ok", { approveBlockIndex: "11", expiresAt: 1_700_000_000_000_000_000n }); assert.equal( @@ -113,6 +138,59 @@ assert.equal(lastBalanceAccount.owner.toText(), "plug-principal"); assert.equal(Array.isArray(lastBalanceAccount.subaccount), true); assert.equal(lastBalanceAccount.subaccount.length, 0); +ledgerAllowanceMock = { allowance: 0n, expires_at: [] }; +approveCalls = 0; +purchaseCalls = 0; +lastApproveArgs = null; +assert.equal( + ( + await walletModule.purchaseCyclesWithPlug( + { canisterId: "aaaaa-aa", databaseId: "db_alpha", paymentAmountE8s: 100_000_000n }, + { principal: "plug-principal" } + ) + ).approveBlockIndex, + "1" +); +assert.equal(approveCalls, 1); +assert.equal(lastApproveArgs.expected_allowance[0], 0n); +assert.equal(purchaseCalls, 1); + +ledgerAllowanceMock = { allowance: 100_100_000n, expires_at: [] }; +approveCalls = 0; +purchaseCalls = 0; +assert.equal( + ( + await walletModule.purchaseCyclesWithPlug( + { canisterId: "aaaaa-aa", databaseId: "db_alpha", paymentAmountE8s: 100_000_000n }, + { principal: "plug-principal" } + ) + ).approveBlockIndex, + null +); +assert.equal(approveCalls, 0); +assert.equal(purchaseCalls, 1); + +ledgerAllowanceMock = { allowance: 100_100_000n, expires_at: [BigInt(Date.now() + 60_000) * 1_000_000n] }; +approveCalls = 0; +assert.equal( + ( + await walletModule.purchaseCyclesWithPlug( + { canisterId: "aaaaa-aa", databaseId: "db_alpha", paymentAmountE8s: 100_000_000n }, + { principal: "plug-principal" } + ) + ).approveBlockIndex, + null +); +assert.equal(approveCalls, 0); + +ledgerAllowanceMock = { allowance: 100_100_000n, expires_at: [BigInt(Date.now() - 60_000) * 1_000_000n] }; +approveCalls = 0; +await walletModule.purchaseCyclesWithPlug( + { canisterId: "aaaaa-aa", databaseId: "db_alpha", paymentAmountE8s: 100_000_000n }, + { principal: "plug-principal" } +); +assert.equal(approveCalls, 1); + cborMock.decoded = { method_name: "write_node" }; await assert.rejects( () => walletTest.decodeOisyCyclesPurchaseResult({ @@ -173,6 +251,53 @@ await assert.rejects( /wallet response sender mismatch/ ); +const sessionModule = loadTsModule( + "../app/app-session-provider.tsx", + { + "@icp-sdk/auth/client": { AuthClient: { create: async () => ({}) } }, + "react": { + createContext: () => ({}), + useCallback: (run) => run, + useContext: () => null, + useEffect: () => undefined, + useRef: (current) => ({ current }), + useState: (initial) => [typeof initial === "function" ? initial() : initial, () => undefined] + }, + "react/jsx-runtime": { jsx: () => null, jsxs: () => null }, + "@/lib/auth": { AUTH_CLIENT_CREATE_OPTIONS: {}, authLoginOptions: () => ({}) }, + "@/lib/cycles-wallet": { + connectOisyWallet: async () => ({ owner: "oisy-principal" }), + connectPlugWallet: async () => ({ principal: "plug-principal" }), + getConnectedWalletKinicBalance: async () => "123456789" + } + }, + "Object.assign(exports, { __test: { readStoredWallet, safeSessionStorageGet, safeSessionStorageSet, safeSessionStorageRemove } });" +); +const sessionTest = sessionModule.__test; +sessionModule.__context.sessionStorage = { + getItem: () => { + throw new Error("get blocked"); + }, + setItem: () => { + throw new Error("set blocked"); + }, + removeItem: () => { + throw new Error("remove blocked"); + } +}; +assert.equal(sessionTest.safeSessionStorageGet("wallet"), null); +assert.doesNotThrow(() => sessionTest.safeSessionStorageSet("wallet", "value")); +assert.doesNotThrow(() => sessionTest.safeSessionStorageRemove("wallet")); +assert.equal(sessionTest.readStoredWallet(), null); +sessionModule.__context.sessionStorage = { + getItem: () => JSON.stringify({ provider: "plug", principal: "plug-principal" }), + setItem: () => undefined, + removeItem: () => undefined +}; +const restoredSessionWallet = sessionTest.readStoredWallet(); +assert.equal(restoredSessionWallet.provider, "plug"); +assert.equal(restoredSessionWallet.connection.principal, "plug-principal"); + console.log("Cycles wallet checks OK"); function formatTokenAmountFromE8s(value) { @@ -205,6 +330,7 @@ function loadTsModule(relativePath, mocks, append = "") { exports: commonjsModule.exports, module: commonjsModule, process: { env: {} }, + window: { ic: { plug: plugMock } }, require: (id) => { if (Object.prototype.hasOwnProperty.call(mocks, id)) return mocks[id]; throw new Error(`unexpected module import: ${id}`); diff --git a/wikibrowser/scripts/check-cycles.mjs b/wikibrowser/scripts/check-cycles.mjs index 5f0d89de..16e5488b 100644 --- a/wikibrowser/scripts/check-cycles.mjs +++ b/wikibrowser/scripts/check-cycles.mjs @@ -32,8 +32,12 @@ assert.match(appHeader, /pathname !== "\/" && pathname !== "\/cycles"/); assert.match(appHeader, /Database cycles purchase/); assert.match(appHeader, /\(\(\) => readStoredWallet\(\)\)/); +assert.match(appSession, /readStoredWallet\(\)/); assert.doesNotMatch(client, /AuthClient|AUTH_CLIENT_CREATE_OPTIONS|authLoginOptions|notifyPrincipal|notifyIdentity/); assert.doesNotMatch(client, /connectOisyWallet|connectPlugWallet/); assert.match(client, /Purchase cycles with OISY/); @@ -100,10 +104,17 @@ assert.match(wallet, /payment_amount_e8s: paymentAmountE8s/); assert.match(wallet, /min_expected_cycles: minExpectedCycles/); assert.doesNotMatch(wallet, /expected_config_version/); assert.match(wallet, /amount_cycles/); -assert.match(wallet, /approveParams\(request\.canisterId, prepared\.approvedAllowanceE8s, prepared\.currentAllowanceE8s, prepared\.expiresAt\)/); -assert.match(wallet, /rawApproveArgs\(request\.canisterId, prepared\.approvedAllowanceE8s, prepared\.currentAllowanceE8s, prepared\.expiresAt\)/); +assert.match(wallet, /currentAllowance: LedgerAllowance/); +assert.match(wallet, /approvalRequired: boolean/); +assert.match(wallet, /approvalExpiresAt: bigint \| null/); +assert.match(wallet, /function allowanceIsUsable\(allowance: LedgerAllowance, requiredAllowanceE8s: bigint, currentTimeNs: bigint\): boolean/); +assert.match(wallet, /function allowanceExpiresAt\(allowance: LedgerAllowance\): bigint \| null/); +assert.match(wallet, /if \(prepared\.approvalRequired\) \{/); +assert.match(wallet, /approveParams\(request\.canisterId, prepared\.approvedAllowanceE8s, prepared\.currentAllowance\.allowance, prepared\.expiresAt\)/); +assert.match(wallet, /rawApproveArgs\(request\.canisterId, prepared\.approvedAllowanceE8s, prepared\.currentAllowance\.allowance, prepared\.expiresAt\)/); assert.match(wallet, /expected_allowance: \[expectedAllowanceE8s\]/); assert.match(wallet, /expires_at: \[expiresAt\]/); +assert.match(wallet, /approveBlockIndex: string \| null/); assert.match(wallet, /APPROVE_EXPIRES_IN_MS = 30 \* 60 \* 1000/); assert.match(wallet, /assertConfiguredCyclesCanister\(request\.canisterId\)/); assert.match(purchaseOisy, /openOisyWallet\(\)/); @@ -119,7 +130,8 @@ assert.match(wallet, /encodeCyclesPurchaseArgs\(request: DatabaseCyclesPurchaseR assert.match(wallet, /whitelist: \[request\.canisterId, prepared\.kinicLedgerCanisterId\]/); assert.match(wallet, /function defaultAccount\(owner: string\): LedgerAccount/); assert.match(wallet, /DEFAULT_OISY_SIGNER_URL/); -assert.match(wallet, /cycles purchase failed after approve; approval remains until/); +assert.match(wallet, /cycles purchase failed after approval; \$\{approvalText\}/); +assert.match(wallet, /approval remains without expiry/); assert.match(wallet, /class CyclesPurchaseAfterApproveError extends Error/); assert.match(client, /purchased cycles/); assert.match(client, /purchasedCycles/); diff --git a/wikibrowser/scripts/check-dashboard.mjs b/wikibrowser/scripts/check-dashboard.mjs index f0053608..334d19c3 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -175,11 +175,14 @@ assert.match(homePageClient, /setWalletControlsLocked\(creating\)/); assert.match(appSession, /connectOisyWallet/); assert.match(appSession, /connectPlugWallet/); assert.match(appSession, /getConnectedWalletKinicBalance/); -assert.match(appSession, /sessionStorage\.setItem/); -assert.match(appSession, /sessionStorage\.removeItem/); +assert.match(appSession, /function safeSessionStorageGet\(key: string\): string \| null/); +assert.match(appSession, /function safeSessionStorageSet\(key: string, value: string\): void/); +assert.match(appSession, /function safeSessionStorageRemove\(key: string\): void/); +assert.match(appSession, /safeSessionStorageSet\(\s*WALLET_SESSION_KEY,/); +assert.match(appSession, /safeSessionStorageRemove\(WALLET_SESSION_KEY\)/); assert.match(appSession, /provider: nextWallet\.provider/); assert.match(appSession, /principal: connectedWalletPrincipal\(nextWallet\)/); -assert.match(appSession, /setWallet\(restoredWallet\)/); +assert.match(appSession, /useState\(\(\) => readStoredWallet\(\)\)/); assert.match(homePageClient, /await refreshWalletBalance\(wallet\);/); assert.match(homePageClient, /purchaseCyclesWithOisy/); assert.match(homePageClient, /purchaseCyclesWithPlug/); From d3a64fcdb2315d331d0d83326b9b6992225c8ae5 Mon Sep 17 00:00:00 2001 From: hude Date: Thu, 4 Jun 2026 08:24:39 +0900 Subject: [PATCH 6/7] Fix cycles purchase copy and URL params --- wikibrowser/app/cycles/cycles-client.tsx | 2 +- wikibrowser/lib/cycles-state.ts | 2 +- wikibrowser/scripts/check-cycles.mjs | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/wikibrowser/app/cycles/cycles-client.tsx b/wikibrowser/app/cycles/cycles-client.tsx index fb0985b6..5346f52b 100644 --- a/wikibrowser/app/cycles/cycles-client.tsx +++ b/wikibrowser/app/cycles/cycles-client.tsx @@ -58,7 +58,7 @@ export function CyclesClient({ canisterId, databaseId, databaseStatus }: CyclesC : await purchaseCyclesWithPlug(request, wallet.connection); const balance = result.balanceCycles ? `cycles balance ${result.balanceCycles}` : "cycles purchase accepted"; setMessage( - `${result.provider} purchased cycles ${result.purchasedCycles}; paid ${formatTokenAmountFromE8s(result.paymentAmountE8s)} KINIC; approved allowance ${formatTokenAmountFromE8s(result.approvedAllowanceE8s)}; ledger transfer fee in allowance ${formatTokenAmountFromE8s(result.transferFeeE8s)}; ${balance}` + `${result.provider} purchased cycles ${result.purchasedCycles}; paid ${formatTokenAmountFromE8s(result.paymentAmountE8s)}; approved allowance ${formatTokenAmountFromE8s(result.approvedAllowanceE8s)}; ledger transfer fee in allowance ${formatTokenAmountFromE8s(result.transferFeeE8s)}; ${balance}` ); await refreshWalletBalance(wallet); setStatus("success"); diff --git a/wikibrowser/lib/cycles-state.ts b/wikibrowser/lib/cycles-state.ts index 59534e3c..33e6e914 100644 --- a/wikibrowser/lib/cycles-state.ts +++ b/wikibrowser/lib/cycles-state.ts @@ -97,7 +97,7 @@ export function databaseCyclesDisabledReason(database: DatabaseSummary | null, c export function databaseCyclesHref(database: DatabaseSummary): string { const params = new URLSearchParams(); - params.set("databaseId", database.databaseId); + params.set("database_id", database.databaseId); params.set("status", database.status); return `/cycles?${params.toString()}`; } diff --git a/wikibrowser/scripts/check-cycles.mjs b/wikibrowser/scripts/check-cycles.mjs index 16e5488b..35d1f374 100644 --- a/wikibrowser/scripts/check-cycles.mjs +++ b/wikibrowser/scripts/check-cycles.mjs @@ -136,6 +136,7 @@ assert.match(wallet, /class CyclesPurchaseAfterApproveError extends Error/); assert.match(client, /purchased cycles/); assert.match(client, /purchasedCycles/); assert.match(client, /approved allowance/); +assert.doesNotMatch(client, /formatTokenAmountFromE8s\(result\.paymentAmountE8s\)\} KINIC/); assert.doesNotMatch(client, /Wallet approval uses the DB cycle amount plus the ledger transfer fee/); assert.match(client, /transfer fee/); assert.match(client, /A newly created database is pending, not active, until this first cycles purchase completes\./); @@ -186,6 +187,11 @@ assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("1.000000001"), "KINIC mus assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("92233720368.54775807"), 9_223_372_036_854_775_807n); assert.equal(cyclesUrlModule.parseKinicAmountE8sInput("92233720368.54775808"), "KINIC amount e8s must be <= i64::MAX"); +const cyclesStateModule = loadTsModule("../lib/cycles-state.ts", { + "@/lib/cycles": { formatCycles: (value) => value.toString() } +}); +assert.equal(cyclesStateModule.databaseCyclesHref({ databaseId: "db_ok-1", status: "active" }), "/cycles?database_id=db_ok-1&status=active"); + const clientModule = loadTsModule( "../app/cycles/cycles-client.tsx", { From e667abc4a61862ba48018dc8d41fa11d8dcb5e6e Mon Sep 17 00:00:00 2001 From: hude Date: Thu, 4 Jun 2026 08:48:55 +0900 Subject: [PATCH 7/7] Hide empty dashboard summary on the root page --- .../app/dashboard/dashboard-client.tsx | 108 ++++++++++- wikibrowser/app/dashboard/dashboard-ui.tsx | 169 +++++++++++++++++- wikibrowser/lib/types.ts | 31 ++++ wikibrowser/lib/vfs-client.ts | 113 ++++++++++++ wikibrowser/scripts/check-dashboard.mjs | 19 +- 5 files changed, 432 insertions(+), 8 deletions(-) diff --git a/wikibrowser/app/dashboard/dashboard-client.tsx b/wikibrowser/app/dashboard/dashboard-client.tsx index ce882e7a..79264cba 100644 --- a/wikibrowser/app/dashboard/dashboard-client.tsx +++ b/wikibrowser/app/dashboard/dashboard-client.tsx @@ -6,15 +6,17 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pencil } from "lucide-react"; import type { BusyAction } from "./access-control"; -import { AuthControls, OwnerPanel, PendingDatabasePanel, ReadonlyMembersPanel, RenameDatabaseDialog, StatusPanel, SummaryPanel } from "./dashboard-ui"; +import { AuthControls, CyclesHistoryPanel, DashboardTabs, OwnerPanel, PendingDatabasePanel, ReadonlyMembersPanel, RenameDatabaseDialog, StatusPanel, SummaryPanel, type DashboardTab } from "./dashboard-ui"; import { AdminHeader } from "@/components/admin-header"; import { CycleBattery } from "@/components/cycle-battery"; import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; -import type { CyclesBillingConfig, DatabaseMember, DatabaseRole, DatabaseSummary } from "@/lib/types"; +import type { CyclesBillingConfig, DatabaseCycleEntry, DatabaseCyclesPendingPurchase, DatabaseMember, DatabaseRole, DatabaseSummary } from "@/lib/types"; import { deleteDatabaseAuthenticated, getCyclesBillingConfig, grantDatabaseAccessAuthenticated, + listDatabaseCycleEntries, + listDatabaseCyclesPendingPurchasesAuthenticated, listDatabaseMembersAuthenticated, listDatabaseMembersPublic, listDatabasesAuthenticated, @@ -25,11 +27,13 @@ import { type LoadState = "idle" | "loading" | "ready" | "error"; type DatabaseAccessSummary = DatabaseSummary & { publicReadable: boolean }; +const CYCLES_HISTORY_LIMIT = 100; export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) { const canisterId = process.env.NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID ?? ""; const router = useRouter(); const refreshSeqRef = useRef(0); + const cyclesHistorySeqRef = useRef(0); const [authClient, setAuthClient] = useState(null); const [principal, setPrincipal] = useState(null); const [databases, setDatabases] = useState([]); @@ -45,11 +49,30 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) const [busyAction, setBusyAction] = useState(null); const [renameOpen, setRenameOpen] = useState(false); const [renameDraft, setRenameDraft] = useState(""); + const [activeTab, setActiveTab] = useState("access"); + const [cycleEntries, setCycleEntries] = useState([]); + const [cycleEntriesError, setCycleEntriesError] = useState(null); + const [cycleEntriesLoading, setCycleEntriesLoading] = useState(false); + const [cycleNextCursor, setCycleNextCursor] = useState(null); + const [pendingPurchases, setPendingPurchases] = useState([]); + const [pendingPurchasesError, setPendingPurchasesError] = useState(null); + const [pendingPurchasesLoading, setPendingPurchasesLoading] = useState(false); const database = useMemo(() => databases.find((item) => item.databaseId === databaseId) ?? null, [databaseId, databases]); const isActiveDatabase = database?.status === "active"; const canManage = database?.role === "owner" && isActiveDatabase && !memberError; const canDeletePendingDatabase = database?.role === "owner" && database.status === "pending"; + const showDashboardTabs = Boolean(databaseId && (database || principal)); + + const resetCyclesHistoryState = useCallback(() => { + setCycleEntries([]); + setCycleEntriesError(null); + setCycleEntriesLoading(false); + setCycleNextCursor(null); + setPendingPurchases([]); + setPendingPurchasesError(null); + setPendingPurchasesLoading(false); + }, []); const refresh = useCallback( async (client: AuthClient | null, nextDatabaseId: string) => { @@ -68,6 +91,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setError(null); setWarning(null); setMemberError(null); + resetCyclesHistoryState(); setLoadState("ready"); return; } @@ -132,7 +156,55 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setLoadState("error"); } }, - [canisterId] + [canisterId, resetCyclesHistoryState] + ); + + const loadCyclesHistory = useCallback( + async (append: boolean, cursor: string | null) => { + if (!canisterId || !databaseId) return; + if (append && !cursor) return; + const requestSeq = (cyclesHistorySeqRef.current += 1); + const isCurrentRequest = () => requestSeq === cyclesHistorySeqRef.current; + const identity = principal && authClient ? authClient.getIdentity() : null; + await Promise.resolve(); + if (!isCurrentRequest()) return; + setCycleEntriesLoading(true); + setCycleEntriesError(null); + if (!append) { + setCycleEntries([]); + setCycleNextCursor(null); + setPendingPurchases([]); + setPendingPurchasesError(null); + setPendingPurchasesLoading(Boolean(identity)); + } + try { + const entriesPromise = listDatabaseCycleEntries(canisterId, databaseId, cursor, CYCLES_HISTORY_LIMIT, identity ?? undefined); + const pendingPromise = identity ? listDatabaseCyclesPendingPurchasesAuthenticated(canisterId, identity, databaseId) : Promise.resolve([]); + const [entriesResult, pendingResult] = await Promise.allSettled([entriesPromise, pendingPromise]); + if (!isCurrentRequest()) return; + if (entriesResult.status === "fulfilled") { + setCycleEntries((current) => append ? [...current, ...entriesResult.value.entries] : entriesResult.value.entries); + setCycleNextCursor(entriesResult.value.nextCursor); + } else { + setCycleEntriesError(errorMessage(entriesResult.reason)); + } + if (!append && identity) { + if (pendingResult.status === "fulfilled") { + setPendingPurchases(pendingResult.value); + setPendingPurchasesError(null); + } else { + setPendingPurchases([]); + setPendingPurchasesError(errorMessage(pendingResult.reason)); + } + } + } finally { + if (isCurrentRequest()) { + setCycleEntriesLoading(false); + setPendingPurchasesLoading(false); + } + } + }, + [authClient, canisterId, databaseId, principal] ); useEffect(() => { @@ -157,6 +229,14 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) }; }, [databaseId, refresh]); + useEffect(() => { + if (activeTab !== "cycles-history") return; + const timer = window.setTimeout(() => { + void loadCyclesHistory(false, null); + }, 0); + return () => window.clearTimeout(timer); + }, [activeTab, databaseId, loadCyclesHistory, principal]); + async function login() { if (!authClient) return; setError(null); @@ -175,6 +255,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) async function logout() { if (!authClient) return; refreshSeqRef.current += 1; + cyclesHistorySeqRef.current += 1; await authClient.logout(); setPrincipal(null); setDatabases([]); @@ -185,6 +266,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setMemberError(null); setRenameOpen(false); setRenameDraft(""); + resetCyclesHistoryState(); await refresh(null, databaseId); } @@ -335,9 +417,23 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) /> ) : null} - {database ? : null} + {databaseId && (database || principal) ? : null} + {showDashboardTabs ? : null} - {database ? ( + {activeTab === "cycles-history" && showDashboardTabs ? ( + void loadCyclesHistory(true, cycleNextCursor)} + onRefresh={() => void loadCyclesHistory(false, null)} + /> + ) : database ? ( canDeletePendingDatabase ? ( ) : canManage ? ( @@ -368,6 +464,8 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) Open Database dashboard
+ ) : principal ? ( + ) : (

Public anonymous read is not available for this database. Login with Internet Identity to manage database access.

diff --git a/wikibrowser/app/dashboard/dashboard-ui.tsx b/wikibrowser/app/dashboard/dashboard-ui.tsx index 4ec683ce..e70a33ee 100644 --- a/wikibrowser/app/dashboard/dashboard-ui.tsx +++ b/wikibrowser/app/dashboard/dashboard-ui.tsx @@ -8,8 +8,10 @@ import { ANONYMOUS_PRINCIPAL, LLM_WRITER_LABEL, LLM_WRITER_PRINCIPAL, databaseRo import { ActionButton } from "./action-button"; import { DatabaseDangerZone } from "./database-danger-zone"; import { MemberTable } from "./member-table"; +import { formatRawCycles } from "@/lib/cycles"; import { databaseCyclesView, databaseCyclesHref } from "@/lib/cycles-state"; -import type { CyclesBillingConfig, DatabaseMember, DatabaseRole, DatabaseSummary } from "@/lib/types"; +import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; +import type { CyclesBillingConfig, DatabaseCycleEntry, DatabaseCyclesPendingPurchase, DatabaseMember, DatabaseRole, DatabaseSummary } from "@/lib/types"; import { isRoutableDatabaseId, publicDatabasePath, xShareDatabaseHref } from "@/lib/share-links"; type PendingAclAction = { @@ -21,6 +23,8 @@ type PendingAclAction = { kind: "grant" | "revoke"; }; +export type DashboardTab = "access" | "cycles-history"; + export function AuthControls(props: { authReady: boolean; loading: boolean; principal: string | null; onLogin: () => void; onLogout: () => void }) { if (!props.principal) { return ( @@ -320,6 +324,157 @@ export function StatusPanel({ tone, message }: { tone: "error" | "info"; message return
{message}
; } +export function DashboardTabs({ activeTab, onChange }: { activeTab: DashboardTab; onChange: (tab: DashboardTab) => void }) { + return ( + + ); +} + +export function CyclesHistoryPanel(props: { + authenticated: boolean; + entries: DatabaseCycleEntry[]; + entriesError: string | null; + entriesLoading: boolean; + nextCursor: string | null; + pendingError: string | null; + pendingLoading: boolean; + pendingPurchases: DatabaseCyclesPendingPurchase[]; + onLoadMore: () => void; + onRefresh: () => void; +}) { + return ( +
+
+
+
+

Pending purchases

+

Owner, billing authority, and payer can inspect purchase operations.

+
+ + Refresh + +
+ {!props.authenticated ? : null} + {props.pendingError ? : null} + {props.pendingLoading ? : null} + {!props.pendingLoading && props.authenticated && !props.pendingError && props.pendingPurchases.length === 0 ? : null} + {props.pendingPurchases.length > 0 ? : null} +
+ +
+
+

Ledger entries

+

Entries are shown in entry ID order. Reader and writer caller values come from the canister redaction policy.

+
+ {props.entriesError ? : null} + {props.entriesLoading && props.entries.length === 0 ? : null} + {!props.entriesLoading && !props.entriesError && props.entries.length === 0 ? : null} + {props.entries.length > 0 ? : null} + {props.nextCursor ? ( +
+ + Load more + +
+ ) : null} +
+
+ ); +} + +function DashboardTabButton({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) { + const activeClass = active ? "border-accent bg-white text-accent shadow-sm" : "border-transparent bg-transparent text-muted hover:border-line hover:bg-white hover:text-ink"; + return ( + + ); +} + +function PendingPurchasesTable({ purchases }: { purchases: DatabaseCyclesPendingPurchase[] }) { + return ( +
+ + + + + + + + + + + + + + {purchases.map((purchase) => ( + + + + + + + + + + ))} + +
OperationStatusRequired actionCyclesPaymentLedger blockCreated
{purchase.operationId}{purchase.status} + + {formatCycleString(purchase.amountCycles)}{formatTokenAmountFromE8s(purchase.paymentAmountE8s)}{purchase.ledgerBlockIndex ?? "-"}{formatTimestamp(purchase.createdAtMs)}
+
+ ); +} + +function LedgerEntriesTable({ entries }: { entries: DatabaseCycleEntry[] }) { + return ( +
+ + + + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + + + ))} + +
EntryKindAmountBalance afterCallerMethodLedger blockCreated
{entry.entryId}{entry.kind}{formatCycleString(entry.amountCycles)}{formatCycleString(entry.balanceAfterCycles)}{entry.caller}{entry.method ?? "-"}{entry.ledgerBlockIndex ?? "-"}{formatTimestamp(entry.createdAtMs)}
+
+ ); +} + +function RequiredActionBadge({ action }: { action: string }) { + const warning = action === "billing_authority_review"; + const toneClass = warning ? "border-amber-200 bg-amber-50 text-amber-900" : "border-line bg-white text-ink"; + return {action}; +} + +function PanelNotice({ message, tone }: { message: string; tone: "error" | "info" }) { + const toneClass = tone === "error" ? "text-red-900" : "text-muted"; + return
{message}
; +} + function GrantForm({ busy, busyAction, onGrant }: { busy: boolean; busyAction: BusyAction | null; onGrant: (principalText: string, role: DatabaseRole) => void }) { const [principalText, setPrincipalText] = useState(""); const [role, setRole] = useState("reader"); @@ -436,3 +591,15 @@ function formatBytes(value: string): string { } return `${current.toFixed(current >= 10 ? 1 : 2)} ${units[unitIndex]}`; } + +function formatCycleString(value: string): string { + if (!/^-?[0-9]+$/.test(value)) return value; + return `${formatRawCycles(BigInt(value))} cycles`; +} + +function formatTimestamp(value: string): string { + if (!/^-?[0-9]+$/.test(value)) return value; + const time = Number(value); + if (!Number.isFinite(time)) return value; + return new Date(time).toISOString(); +} diff --git a/wikibrowser/lib/types.ts b/wikibrowser/lib/types.ts index b0e668fc..2c5c7e69 100644 --- a/wikibrowser/lib/types.ts +++ b/wikibrowser/lib/types.ts @@ -141,6 +141,37 @@ export type CyclesPurchaseResult = { balanceCycles: string; }; +export type DatabaseCycleEntry = { + entryId: string; + databaseId: string; + kind: string; + amountCycles: string; + balanceAfterCycles: string; + caller: string; + method: string | null; + ledgerBlockIndex: string | null; + paymentAmountE8s: string | null; + cyclesPerKinic: string | null; + cyclesDelta: string | null; + createdAtMs: string; +}; + +export type DatabaseCycleEntryPage = { + entries: DatabaseCycleEntry[]; + nextCursor: string | null; +}; + +export type DatabaseCyclesPendingPurchase = { + operationId: string; + databaseId: string; + status: string; + amountCycles: string; + paymentAmountE8s: string; + ledgerBlockIndex: string | null; + createdAtMs: string; + requiredAction: string; +}; + export type DatabaseMember = { databaseId: string; principal: string; diff --git a/wikibrowser/lib/vfs-client.ts b/wikibrowser/lib/vfs-client.ts index 76cc852c..33338bc3 100644 --- a/wikibrowser/lib/vfs-client.ts +++ b/wikibrowser/lib/vfs-client.ts @@ -8,6 +8,9 @@ import type { CanisterHealth, CyclesBillingConfig, ChildNode, + DatabaseCycleEntry, + DatabaseCycleEntryPage, + DatabaseCyclesPendingPurchase, DeleteDatabaseRequest, DeleteNodeRequest, DeleteNodeResult, @@ -80,6 +83,37 @@ type RawDatabaseSummary = { archived_at_ms: [] | [bigint]; }; +type RawDatabaseCycleEntry = { + method: [] | [string]; + cycles_per_kinic: [] | [bigint]; + payment_amount_e8s: [] | [bigint]; + kind: string; + created_at_ms: bigint; + amount_cycles: bigint; + ledger_block_index: [] | [bigint]; + database_id: string; + balance_after_cycles: bigint; + caller: string; + cycles_delta: [] | [bigint]; + entry_id: bigint; +}; + +type RawDatabaseCycleEntryPage = { + entries: RawDatabaseCycleEntry[]; + next_cursor: [] | [bigint]; +}; + +type RawDatabaseCyclesPendingPurchase = { + operation_id: bigint; + database_id: string; + status: string; + amount_cycles: bigint; + payment_amount_e8s: bigint; + ledger_block_index: [] | [bigint]; + created_at_ms: bigint; + required_action: string; +}; + type RawDeleteDatabaseRequest = { database_id: string; }; @@ -247,6 +281,8 @@ type VfsActor = { delete_node: (request: RawDeleteNodeRequest) => Promise<{ Ok: RawDeleteNodeResult } | { Err: string }>; get_cycles_billing_config: () => Promise<{ Ok: RawCyclesBillingConfig } | { Err: string }>; grant_database_access: (databaseId: string, principal: string, role: Variant) => Promise<{ Ok: null } | { Err: string }>; + list_database_cycle_entries: (databaseId: string, cursor: [] | [bigint], limit: number) => Promise<{ Ok: RawDatabaseCycleEntryPage } | { Err: string }>; + list_database_cycles_pending_purchases: (databaseId: string) => Promise<{ Ok: RawDatabaseCyclesPendingPurchase[] } | { Err: string }>; mkdir_node: (request: RawMkdirNodeRequest) => Promise<{ Ok: RawMkdirNodeResult } | { Err: string }>; move_node: (request: RawMoveNodeRequest) => Promise<{ Ok: RawMoveNodeResult } | { Err: string }>; list_databases: () => Promise<{ Ok: RawDatabaseSummary[] } | { Err: string }>; @@ -436,6 +472,38 @@ export async function listDatabasesPublic(canisterId: string): Promise { + return callVfs(async () => { + const actor = await createReadActor(canisterId, identity); + const result = await actor.list_database_cycle_entries(databaseId, rawDatabaseCycleCursor(cursor), limit); + if ("Err" in result) { + throw new Error(result.Err); + } + return normalizeDatabaseCycleEntryPage(result.Ok); + }); +} + +export async function listDatabaseCyclesPendingPurchasesAuthenticated( + canisterId: string, + identity: Identity, + databaseId: string +): Promise { + return callVfs(async () => { + const actor = await createAuthenticatedActor(canisterId, identity); + const result = await actor.list_database_cycles_pending_purchases(databaseId); + if ("Err" in result) { + throw new Error(result.Err); + } + return result.Ok.map(normalizeDatabaseCyclesPendingPurchase); + }); +} + export async function createDatabaseAuthenticated(canisterId: string, identity: Identity, name: string): Promise { return callVfs(async () => { const actor = await createAuthenticatedActor(canisterId, identity); @@ -866,6 +934,43 @@ function normalizeDatabaseSummary(raw: RawDatabaseSummary): DatabaseSummary { }; } +function normalizeDatabaseCycleEntryPage(raw: RawDatabaseCycleEntryPage): DatabaseCycleEntryPage { + return { + entries: raw.entries.map(normalizeDatabaseCycleEntry), + nextCursor: raw.next_cursor[0]?.toString() ?? null + }; +} + +function normalizeDatabaseCycleEntry(raw: RawDatabaseCycleEntry): DatabaseCycleEntry { + return { + entryId: raw.entry_id.toString(), + databaseId: raw.database_id, + kind: raw.kind, + amountCycles: raw.amount_cycles.toString(), + balanceAfterCycles: raw.balance_after_cycles.toString(), + caller: raw.caller, + method: raw.method[0] ?? null, + ledgerBlockIndex: raw.ledger_block_index[0]?.toString() ?? null, + paymentAmountE8s: raw.payment_amount_e8s[0]?.toString() ?? null, + cyclesPerKinic: raw.cycles_per_kinic[0]?.toString() ?? null, + cyclesDelta: raw.cycles_delta[0]?.toString() ?? null, + createdAtMs: raw.created_at_ms.toString() + }; +} + +function normalizeDatabaseCyclesPendingPurchase(raw: RawDatabaseCyclesPendingPurchase): DatabaseCyclesPendingPurchase { + return { + operationId: raw.operation_id.toString(), + databaseId: raw.database_id, + status: raw.status, + amountCycles: raw.amount_cycles.toString(), + paymentAmountE8s: raw.payment_amount_e8s.toString(), + ledgerBlockIndex: raw.ledger_block_index[0]?.toString() ?? null, + createdAtMs: raw.created_at_ms.toString(), + requiredAction: raw.required_action + }; +} + function normalizeDatabaseMember(raw: RawDatabaseMember): DatabaseMember { return { databaseId: raw.database_id, @@ -968,6 +1073,14 @@ function nodeKindVariant(kind: NodeKind): Variant { return { File: null }; } +function rawDatabaseCycleCursor(cursor: string | null): [] | [bigint] { + if (!cursor) return []; + if (!/^[0-9]+$/.test(cursor)) { + throw new ApiError("Invalid cycles history cursor.", 400); + } + return [BigInt(cursor)]; +} + function rawUrlIngestTriggerSessionRequest(request: UrlIngestTriggerSessionRequest): RawUrlIngestTriggerSessionRequest { return { database_id: request.databaseId, diff --git a/wikibrowser/scripts/check-dashboard.mjs b/wikibrowser/scripts/check-dashboard.mjs index 334d19c3..6a9f785f 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -357,9 +357,14 @@ assert.match(dashboardClient, /await refresh\(null, databaseId\);/); assert.match(dashboardClient, /Select a database to manage/); assert.match(dashboardClient, /Open Database dashboard/); assert.doesNotMatch(dashboardClient, /Database id is missing\./); -assert.match(dashboardClient, //); +assert.match(dashboardClient, /databaseId && \(database \|\| principal\) \? /); assert.match(dashboardClient, /deleteDatabaseAuthenticated/); assert.doesNotMatch(dashboardClient, /listDatabaseCyclePendingOperationsAuthenticated/); +assert.match(dashboardClient, /CyclesHistoryPanel/); +assert.match(dashboardClient, /DashboardTabs/); +assert.match(dashboardClient, /listDatabaseCycleEntries/); +assert.match(dashboardClient, /listDatabaseCyclesPendingPurchasesAuthenticated/); +assert.match(dashboardClient, /CYCLES_HISTORY_LIMIT = 100/); assert.match(dashboardClient, /identity && nextDatabase\?\.role === "owner"/); assert.match(dashboardClient, /nextDatabase\.status === "active"/); assert.doesNotMatch(dashboardClient, /pendingOperationCount/); @@ -418,6 +423,11 @@ assert.doesNotMatch(dashboardUi, /sm:flex-row sm:items-end/); assert.match(dashboardUi, /role or cycles state changes/); assert.match(dashboardUi, /databaseCyclesView/); assert.match(dashboardUi, /databaseCyclesHref/); +assert.match(dashboardUi, /Cycles History/); +assert.match(dashboardUi, /Pending purchases/); +assert.match(dashboardUi, /Ledger entries/); +assert.match(dashboardUi, /RequiredActionBadge/); +assert.match(dashboardUi, /formatRawCycles/); assert.match(dashboardUi, /Your Role/); assert.match(dashboardUi, /databaseStatusLabel/); assert.match(dashboardUi, /active: "Active"/); @@ -428,6 +438,11 @@ assert.doesNotMatch(dashboardUi, /Minimum update/); assert.match(homeUi, /Cycles/); assert.match(homeUi, /Cycles/); assert.match(homePageClient, /getCyclesBillingConfig/); +assert.match(vfsClient, /export async function listDatabaseCycleEntries/); +assert.match(vfsClient, /export async function listDatabaseCyclesPendingPurchasesAuthenticated/); +assert.match(vfsClient, /normalizeDatabaseCycleEntryPage/); +assert.match(vfsClient, /normalizeDatabaseCyclesPendingPurchase/); +assert.doesNotMatch(vfsClient, /redacted/); assert.doesNotMatch(cyclesState, /Cycles unknown/); assert.doesNotMatch(homeUi, /Cycles unknown \/ 0 e8s/); assert.doesNotMatch(dashboardUi, /Cycles unknown \/ 0 e8s/); @@ -457,6 +472,6 @@ assert.match(homePageClient, /createDatabaseAction=\{/); assert.match(homeUi, /