diff --git a/crates/vfs_cli_app/src/cli.rs b/crates/vfs_cli_app/src/cli.rs index ed2589b..e8bc034 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 c20b221..63d4fcd 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 d85d9ca..7f1d581 100644 --- a/crates/vfs_cli_app/src/main.rs +++ b/crates/vfs_cli_app/src/main.rs @@ -3,7 +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::{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, }; @@ -37,6 +37,13 @@ async fn main() -> Result<()> { run_database_unlink()?; return Ok(()); } + DatabaseCommand::Cycles { + database_id, + browser_origin, + } => { + open_database_cycles_page(browser_origin.as_deref(), database_id)?; + return Ok(()); + } _ => {} } } @@ -146,3 +153,29 @@ async fn new_identity_client( ) .await } + +#[cfg(test)] +mod tests { + use super::*; + use vfs_cli::commands::database_cycles_url; + + #[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 3550c20..7d2d7bc 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 2271e6c..0f607b8 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,16 @@ 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}"); + if let Err(error) = open_browser_url(&url) { + eprintln!("{}", browser_open_warning(&error)); + } + Ok(()) +} + +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()) @@ -843,13 +807,22 @@ fn database_cycles_url( 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?databaseId={}&kinic={}", - query_encode(database_id), - query_encode(kinic) + "{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() { @@ -916,7 +889,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") { @@ -934,6 +907,10 @@ fn open_browser_url(url: &str) -> Result<()> { Ok(()) } +fn browser_open_warning(error: &anyhow::Error) -> String { + format!("warning: could not open browser automatically; open the URL manually: {error}") +} + async fn run_cycles_command(client: &impl VfsApi, command: CyclesCommand) -> Result<()> { match command { CyclesCommand::Config { json } => { @@ -1427,6 +1404,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 +1471,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 +1555,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 +1816,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 +1835,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 +1860,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,25 +2335,42 @@ 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_alpha"); } #[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_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] + 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")); + } + + #[test] + fn database_cycles_open_warning_keeps_url_actionable() { + let error = anyhow!("xdg-open missing"); + let warning = super::browser_open_warning(&error); + + assert!(warning.contains("warning: could not open browser automatically")); + assert!(warning.contains("open the URL manually")); + assert!(warning.contains("xdg-open missing")); + } + #[tokio::test] async fn cycles_config_json_calls_client() { let client = MockClient::default(); diff --git a/docs/CLI.md b/docs/CLI.md index 9929994..20ac44b 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. 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. -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 5a8810a..7fabb58 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -119,11 +119,12 @@ 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`。 -KINIC 入力は正の数だけ許可する。小数は最大 8 桁、URL/UI parser 上の e8s 換算値は `u64::MAX` 以下でなければならない。購入直前の wallet flow は canister 実効上限として `payment_amount_e8s <= i64::MAX` と `amount_cycles <= i64::MAX` も確認する。 +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 換算値は `i64::MAX` 以下でなければならない。購入直前の wallet flow は canister 実効上限として `payment_amount_e8s <= i64::MAX` と `amount_cycles <= i64::MAX` も確認する。 OISY と Plug の wallet flow は、購入直前に canister config を取得する。承認 allowance は次である。 @@ -131,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-header.tsx b/wikibrowser/app/app-header.tsx new file mode 100644 index 0000000..5344f61 --- /dev/null +++ b/wikibrowser/app/app-header.tsx @@ -0,0 +1,93 @@ +"use client"; + +// Where: root wikibrowser layout. +// What: renders the shared dashboard/cycles header with wallet and II controls. +// Why: funding pages should keep the same wallet session and management shell. +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { AdminHeader } from "@/components/admin-header"; +import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; +import { AuthControls, WalletControls } from "./home-ui"; +import { connectedWalletPrincipal, useAppSession } from "./app-session-provider"; + +export function AppHeader() { + const pathname = usePathname(); + const { + authClient, + authLoading, + authReady, + connectWallet, + disconnectWallet, + login, + logout, + principal, + refreshAuth, + wallet, + walletBalance, + walletBalanceLoading, + walletBusyProvider, + walletControlsLocked + } = useAppSession(); + + if (pathname !== "/" && pathname !== "/cycles") return null; + + const title = pathname === "/cycles" ? "Database cycles purchase" : "Database dashboard"; + const connectedWalletLabel = wallet ? `${walletLabel(wallet.provider)} ${shortPrincipal(connectedWalletPrincipal(wallet))}` : null; + const connectedWalletBalanceLabel = walletBalance ? formatTokenAmountFromE8s(walletBalance) : null; + + return ( +
+
+ + Database dashboard + + ) : null + } + actions={ + <> + { + void connectWallet(provider); + }} + onDisconnect={disconnectWallet} + /> + { + void login(); + }} + onLogout={() => { + void logout(); + }} + onRefresh={() => { + void refreshAuth(); + }} + /> + + } + /> +
+
+ ); +} + +function walletLabel(provider: "oisy" | "plug"): string { + return provider === "oisy" ? "OISY" : "Plug"; +} + +function shortPrincipal(value: string): string { + if (value.length <= 16) return value; + return `${value.slice(0, 8)}...${value.slice(-5)}`; +} diff --git a/wikibrowser/app/app-session-provider.tsx b/wikibrowser/app/app-session-provider.tsx new file mode 100644 index 0000000..b0582a8 --- /dev/null +++ b/wikibrowser/app/app-session-provider.tsx @@ -0,0 +1,309 @@ +"use client"; + +// Where: root wikibrowser app shell. +// What: shares Internet Identity and wallet session state across dashboard and cycles pages. +// Why: funding can move between pages without losing local wallet context. +import { AuthClient } from "@icp-sdk/auth/client"; +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; +import { connectOisyWallet, connectPlugWallet, getConnectedWalletKinicBalance, type ConnectedKinicWallet } from "@/lib/cycles-wallet"; +import type { HeaderWalletProvider } from "./home-ui"; + +type AppSessionContext = { + authClient: AuthClient | null; + authError: string | null; + authLoading: boolean; + authReady: boolean; + authRefreshSeq: number; + principal: string | null; + wallet: ConnectedKinicWallet | null; + walletBalance: string | null; + walletBalanceError: string | null; + walletBalanceLoading: boolean; + walletBusyProvider: HeaderWalletProvider | null; + walletControlsLocked: boolean; + connectWallet: (provider: HeaderWalletProvider) => Promise; + disconnectWallet: (provider: HeaderWalletProvider) => void; + logout: () => Promise; + login: () => Promise; + refreshAuth: () => Promise; + refreshWalletBalance: (wallet: ConnectedKinicWallet) => Promise; + setWalletControlsLocked: (locked: boolean) => void; +}; + +const WALLET_SESSION_KEY = "kinic-wiki.wallet-session"; +const AppSession = createContext(null); + +export function AppSessionProvider({ children }: { children: ReactNode }) { + const canisterId = process.env.NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID ?? ""; + const walletBalanceSeqRef = useRef(0); + const [authClient, setAuthClient] = useState(null); + const [authError, setAuthError] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + const [authReady, setAuthReady] = useState(false); + const [authRefreshSeq, setAuthRefreshSeq] = useState(0); + const [principal, setPrincipal] = useState(null); + const [wallet, setWallet] = useState(() => readStoredWallet()); + const [walletBalance, setWalletBalance] = useState(null); + const [walletBalanceError, setWalletBalanceError] = useState(null); + const [walletBalanceLoading, setWalletBalanceLoading] = useState(false); + const [walletBusyProvider, setWalletBusyProvider] = useState(null); + const [walletControlsLocked, setWalletControlsLockedState] = useState(false); + + const setWalletControlsLocked = useCallback((locked: boolean) => { + setWalletControlsLockedState(locked); + }, []); + + const clearStoredWallet = useCallback(() => { + safeSessionStorageRemove(WALLET_SESSION_KEY); + }, []); + + const storeWallet = useCallback((nextWallet: ConnectedKinicWallet) => { + safeSessionStorageSet( + WALLET_SESSION_KEY, + JSON.stringify({ + provider: nextWallet.provider, + principal: connectedWalletPrincipal(nextWallet) + }) + ); + }, []); + + const clearWallet = useCallback(() => { + walletBalanceSeqRef.current += 1; + setWallet(null); + setWalletBalance(null); + setWalletBalanceLoading(false); + setWalletBalanceError(null); + setWalletBusyProvider(null); + clearStoredWallet(); + }, [clearStoredWallet]); + + const refreshWalletBalance = useCallback( + async (nextWallet: ConnectedKinicWallet) => { + 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); + } + }, + [canisterId] + ); + + const connectWallet = useCallback( + async (provider: HeaderWalletProvider) => { + if (walletControlsLocked || walletBusyProvider) return; + setWalletBusyProvider(provider); + setWalletBalanceError(null); + try { + const nextWallet: ConnectedKinicWallet = + provider === "oisy" + ? { provider, connection: await connectOisyWallet() } + : { provider, connection: await connectPlugWallet() }; + setWallet(nextWallet); + storeWallet(nextWallet); + } catch (cause) { + setWalletBalance(null); + setWalletBalanceError(errorMessage(cause)); + } finally { + setWalletBusyProvider(null); + } + }, + [storeWallet, walletBusyProvider, walletControlsLocked] + ); + + const disconnectWallet = useCallback( + (provider: HeaderWalletProvider) => { + if (walletControlsLocked || walletBusyProvider || wallet?.provider !== provider) return; + clearWallet(); + }, + [clearWallet, wallet, walletBusyProvider, walletControlsLocked] + ); + + const syncAuth = useCallback(async (client: AuthClient) => { + const authenticated = await client.isAuthenticated(); + setPrincipal(authenticated ? client.getIdentity().getPrincipal().toText() : null); + setAuthRefreshSeq((current) => current + 1); + }, []); + + const refreshAuth = useCallback(async () => { + if (!authClient) return; + setAuthLoading(true); + setAuthError(null); + try { + await syncAuth(authClient); + } catch (cause) { + setAuthError(errorMessage(cause)); + } finally { + setAuthLoading(false); + } + }, [authClient, syncAuth]); + + const login = useCallback(async () => { + if (!authClient) return; + setAuthLoading(true); + setAuthError(null); + await authClient.login({ + ...authLoginOptions(), + onSuccess: () => { + void syncAuth(authClient).finally(() => setAuthLoading(false)); + }, + onError: (cause) => { + setAuthError(errorMessage(cause)); + setAuthLoading(false); + } + }); + }, [authClient, syncAuth]); + + const logout = useCallback(async () => { + if (!authClient) return; + setAuthLoading(true); + setAuthError(null); + try { + await authClient.logout(); + setPrincipal(null); + setAuthRefreshSeq((current) => current + 1); + clearWallet(); + } catch (cause) { + setAuthError(errorMessage(cause)); + } finally { + setAuthLoading(false); + } + }, [authClient, clearWallet]); + + useEffect(() => { + let cancelled = false; + + AuthClient.create(AUTH_CLIENT_CREATE_OPTIONS) + .then(async (client) => { + if (cancelled) return; + setAuthClient(client); + await syncAuth(client); + if (cancelled) return; + setAuthReady(true); + setAuthLoading(false); + }) + .catch((cause) => { + if (cancelled) return; + setAuthError(errorMessage(cause)); + setAuthReady(false); + setAuthLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [syncAuth]); + + useEffect(() => { + if (!wallet) return; + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + void refreshWalletBalance(wallet); + }); + return () => { + cancelled = true; + }; + }, [refreshWalletBalance, wallet]); + + return ( + + {children} + + ); +} + +export function useAppSession(): AppSessionContext { + const session = useContext(AppSession); + if (!session) throw new Error("AppSessionProvider is required"); + return session; +} + +export function connectedWalletPrincipal(wallet: ConnectedKinicWallet): string { + return wallet.provider === "oisy" ? wallet.connection.owner : wallet.connection.principal; +} + +function readStoredWallet(): ConnectedKinicWallet | null { + const stored = safeSessionStorageGet(WALLET_SESSION_KEY); + if (!stored) return null; + try { + const parsed: unknown = JSON.parse(stored); + if (!isRecord(parsed)) return null; + const provider = Reflect.get(parsed, "provider"); + const principal = Reflect.get(parsed, "principal"); + if (!isWalletProvider(provider) || typeof principal !== "string" || !principal.trim()) return null; + return provider === "oisy" ? { provider, connection: { owner: principal } } : { provider, connection: { principal } }; + } catch { + return null; + } +} + +function safeSessionStorageGet(key: string): string | null { + try { + return sessionStorage.getItem(key); + } catch { + return null; + } +} + +function safeSessionStorageSet(key: string, value: string): void { + try { + sessionStorage.setItem(key, value); + } catch { + // Wallet persistence is best effort; connection state remains valid in memory. + } +} + +function safeSessionStorageRemove(key: string): void { + try { + sessionStorage.removeItem(key); + } catch { + // Wallet persistence is best effort; disconnect state remains valid in memory. + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isWalletProvider(value: unknown): value is HeaderWalletProvider { + return value === "oisy" || value === "plug"; +} + +function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : "Unexpected error"; +} diff --git a/wikibrowser/app/create-database-dialog.tsx b/wikibrowser/app/create-database-dialog.tsx index e8d01c1..f04d734 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; @@ -38,7 +40,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 {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, initialKinic }: CyclesCli ); } -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 ef703b1..7895425 100644 --- a/wikibrowser/app/cycles/page.tsx +++ b/wikibrowser/app/cycles/page.tsx @@ -1,7 +1,8 @@ // Where: /cycles route. // What: passes the configured canister and target database into the client. -// Why: CLI/query can seed cycles, but canister selection must not come from URL input. +// 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,7 +18,7 @@ export default async function CyclesPage({ searchParams }: { searchParams: PageS ); } @@ -26,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/dashboard/dashboard-client.tsx b/wikibrowser/app/dashboard/dashboard-client.tsx index d693b07..79264cb 100644 --- a/wikibrowser/app/dashboard/dashboard-client.tsx +++ b/wikibrowser/app/dashboard/dashboard-client.tsx @@ -4,15 +4,19 @@ import { AuthClient } from "@icp-sdk/auth/client"; import Link from "next/link"; 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, 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, @@ -23,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([]); @@ -41,11 +47,32 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) const [actionTone, setActionTone] = useState<"error" | "info">("info"); const [busy, setBusy] = useState(false); 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) => { @@ -64,6 +91,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setError(null); setWarning(null); setMemberError(null); + resetCyclesHistoryState(); setLoadState("ready"); return; } @@ -128,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(() => { @@ -153,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); @@ -171,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([]); @@ -179,6 +264,9 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setError(null); setWarning(null); setMemberError(null); + setRenameOpen(false); + setRenameDraft(""); + resetCyclesHistoryState(); await refresh(null, databaseId); } @@ -220,8 +308,8 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) } } - async function renameDatabase(name: string) { - if (!authClient || !databaseId) return; + async function renameDatabase(name: string): Promise { + if (!authClient || !databaseId) return false; setBusy(true); setBusyAction({ kind: "rename" }); setActionMessage(null); @@ -230,15 +318,32 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) setActionTone("info"); setActionMessage("Database name updated."); await refresh(authClient, databaseId); + return true; } catch (cause) { setActionTone("error"); setActionMessage(errorMessage(cause)); + return false; } finally { setBusy(false); setBusyAction(null); } } + function openRenameDialog() { + if (!database || !canManage) return; + setRenameDraft(database.name); + setRenameOpen(true); + } + + async function submitRename(name: string) { + if (!database || busy) return; + const nextName = name.trim(); + if (!nextName || nextName === database.name) return; + if (await renameDatabase(nextName)) { + setRenameOpen(false); + } + } + async function deleteDatabase(): Promise { if (!authClient || !databaseId) return "Login with Internet Identity to delete database."; if (!database) return "Database summary unavailable."; @@ -262,32 +367,73 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) return (
-
-
- - Dashboard - - {databaseId && isActiveDatabase ? ( - - Skill Registry + + + + ) : 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} + {databaseId && (database || principal) ? : null} + {showDashboardTabs ? : null} - {database ? ( + {activeTab === "cycles-history" && showDashboardTabs ? ( + void loadCyclesHistory(true, cycleNextCursor)} + onRefresh={() => void loadCyclesHistory(false, null)} + /> + ) : database ? ( canDeletePendingDatabase ? ( ) : canManage ? ( @@ -301,7 +447,6 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) principal={principal ?? "anonymous"} onDelete={deleteDatabase} onGrant={grantAccess} - onRename={renameDatabase} onRevoke={revokeAccess} /> ) : database.publicReadable ? ( @@ -319,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 297d985..e70a33e 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,11 +23,13 @@ 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 ( - Login with Internet Identity + Internet Identity ); } @@ -108,11 +112,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 +227,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 +254,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 (
@@ -297,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"); @@ -351,16 +529,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} + ); } @@ -418,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/app/home-page-client.tsx b/wikibrowser/app/home-page-client.tsx new file mode 100644 index 0000000..d342777 --- /dev/null +++ b/wikibrowser/app/home-page-client.tsx @@ -0,0 +1,325 @@ +"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; + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + void refreshDatabases(authClient); + }); + return () => { + cancelled = true; + }; + }, [authClient, authReady, authRefreshSeq, refreshDatabases]); + + useEffect(() => { + setWalletControlsLocked(creating); + return () => setWalletControlsLocked(false); + }, [creating, setWalletControlsLocked]); + + useEffect(() => { + if (principal) return; + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + setCyclesBillingConfig(null); + setCreateDialogOpen(false); + setNewDatabaseName(""); + setWalletMessage(null); + }); + return () => { + cancelled = true; + }; + }, [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/home-ui.tsx b/wikibrowser/app/home-ui.tsx index b9a82d1..913ace4 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/layout.tsx b/wikibrowser/app/layout.tsx index fdd1303..b668b42 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 c68255d..054a03e 100644 --- a/wikibrowser/app/page.tsx +++ b/wikibrowser/app/page.tsx @@ -1,269 +1,20 @@ -"use client"; - -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 { CreateDatabaseDialog } from "./create-database-dialog"; -import { AuthControls, CreatedDatabasePanel, DatabaseBody, OfficialKinicWikiPanel, StatusPanel } from "./home-ui"; -import { AUTH_CLIENT_CREATE_OPTIONS, authLoginOptions } from "@/lib/auth"; -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"; +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 [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 [createdDatabase, setCreatedDatabase] = useState<{ databaseId: string; name: string } | null>(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); - setCreatedDatabase(null); - setCreateDialogOpen(false); - setNewDatabaseName(""); - setError(null); - setPublicError(null); - await refreshDatabases(null); - } - - async function createDatabase() { - if (!authClient || !canisterId) return; - const databaseNameInput = newDatabaseName.trim(); - const validationError = databaseNameError(databaseNameInput); - if (validationError) { - setError(validationError); - setLoadState("error"); - return; - } - setCreating(true); - setError(null); - try { - const result = await createDatabaseAuthenticated(canisterId, authClient.getIdentity(), databaseNameInput); - setCreatedDatabase({ databaseId: result.database_id, name: result.name }); - setCreateDialogOpen(false); - setNewDatabaseName(""); - await refreshDatabases(authClient); - } catch (cause) { - setError(errorMessage(cause)); - 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 createDisabled = creating || loadState === "loading" || databaseNameValidationError !== null; - +function HomePageFallback() { return ( -
+
-
-
- -
-

Kinic Wiki

-

Database dashboard

-
-
-
- - - CLI - - { - if (authClient) void refreshDatabases(authClient); - }} - /> -
-
- - {error ? : null} - {warning ? : null} - {createdDatabase ? : null} - { - if (creating) return; - setCreateDialogOpen(false); - setNewDatabaseName(""); - }} - onChange={setNewDatabaseName} - onSubmit={() => void createDatabase()} - /> - - - - {principal ? ( - <> -
-
-
-

Database dashboard

-

{principal}

-
- -
-
- - - ) : ( -
-
-
-

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 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/skills/skill-registry-client.tsx b/wikibrowser/app/skills/skill-registry-client.tsx index e46bceb..6554c14 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 0000000..981fd20 --- /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 afe7b61..3dc5e83 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 01138b7..40a905d 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-state.ts b/wikibrowser/lib/cycles-state.ts index 352e8bf..33e6e91 100644 --- a/wikibrowser/lib/cycles-state.ts +++ b/wikibrowser/lib/cycles-state.ts @@ -97,7 +97,8 @@ 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/lib/cycles-url.ts b/wikibrowser/lib/cycles-url.ts index 38b4056..aa5cffa 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 db8fee5..a208e05 100644 --- a/wikibrowser/lib/cycles-wallet.ts +++ b/wikibrowser/lib/cycles-wallet.ts @@ -6,7 +6,8 @@ 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"; @@ -18,7 +19,7 @@ type CyclesPurchaseRequest = { type CyclesPurchaseResult = { provider: WalletProvider; - approveBlockIndex: string; + approveBlockIndex: string | null; approvedAllowanceE8s: string; purchasedCycles: string; paymentAmountE8s: string; @@ -28,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; @@ -46,7 +50,9 @@ type PreparedCyclesPurchase = { transferFeeE8s: bigint; paymentAmountE8s: bigint; approvedAllowanceE8s: bigint; - currentAllowanceE8s: bigint; + currentAllowance: LedgerAllowance; + approvalExpiresAt: bigint | null; + approvalRequired: boolean; expiresAt: bigint; }; @@ -82,6 +88,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 +129,8 @@ export type ConnectedPlugWallet = { principal: string; }; +export type ConnectedKinicWallet = { provider: "oisy"; connection: ConnectedOisyWallet } | { provider: "plug"; connection: ConnectedPlugWallet }; + declare global { interface Window { ic?: { @@ -133,8 +142,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 = { @@ -203,6 +210,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(); @@ -211,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(), @@ -248,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: ${JSON.stringify(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 @@ -264,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(), @@ -309,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: { @@ -320,31 +343,47 @@ async function prepareCyclesPurchase(request: CyclesPurchaseRequest, payer: stri transferFeeE8s, paymentAmountE8s, approvedAllowanceE8s, - currentAllowanceE8s, + currentAllowance, + approvalExpiresAt: approvalRequired ? expiresAt : allowanceExpiresAt(currentAllowance), + approvalRequired, expiresAt }; } 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 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 { @@ -355,7 +394,18 @@ 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(); + const actor = Actor.createActor(ledgerIdlFactory, { + agent, + canisterId: Principal.fromText(ledgerCanisterId) + }); + return actor.icrc2_allowance(allowanceArgs(owner, spender)); +} + +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(); @@ -363,17 +413,24 @@ 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.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, @@ -396,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) { @@ -409,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 { @@ -491,6 +553,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 +661,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/lib/cycles.ts b/wikibrowser/lib/cycles.ts index a95c37b..4b2ef5a 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/lib/types.ts b/wikibrowser/lib/types.ts index b0e668f..2c5c7e6 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 76cc852..33338bc 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/package.json b/wikibrowser/package.json index 0abb76e..686b011 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 0000000..45b0140 --- /dev/null +++ b/wikibrowser/scripts/check-cycles-wallet.mjs @@ -0,0 +1,341 @@ +// 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; +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 () => 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( + "../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, + 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 } });" +); +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 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( + 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); + +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({ + 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/ +); + +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) { + 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: {} }, + window: { ic: { plug: plugMock } }, + 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 24ab903..35d1f37 100644 --- a/wikibrowser/scripts/check-cycles.mjs +++ b/wikibrowser/scripts/check-cycles.mjs @@ -8,37 +8,58 @@ 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"); 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(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, /\(\(\) => readStoredWallet\(\)\)/); +assert.match(appSession, /readStoredWallet\(\)/); assert.doesNotMatch(client, /AuthClient|AUTH_CLIENT_CREATE_OPTIONS|authLoginOptions|notifyPrincipal|notifyIdentity/); -assert.match(client, /Connect OISY/); -assert.match(client, /Connect Plug/); +assert.doesNotMatch(client, /connectOisyWallet|connectPlugWallet/); assert.match(client, /Purchase cycles with OISY/); assert.match(client, /Purchase cycles with Plug/); -assert.match(client, /router\.replace\("\/"\)/); +assert.match(client, /router\.replace\(cyclesPurchaseSuccessHref\(\{/); +assert.match(client, /databaseId: parsedTarget\.databaseId/); +assert.match(client, /kinic: formatTokenAmountFromE8s\(result\.paymentAmountE8s\)/); +assert.match(client, /provider: result\.provider/); +assert.match(client, /function cyclesPurchaseSuccessHref/); +assert.match(client, /params\.set\("funding", "success"\)/); +assert.match(client, /params\.set\("database_id", databaseId\)/); +assert.match(client, /params\.set\("provider", provider\)/); +assert.match(client, /params\.set\("kinic", kinic\)/); +assert.match(client, /params\.set\("cycles", cycles\)/); assert.match(client, /const purchaseDisabled = !selectedProvider \|\| Boolean\(error\) \|\| Boolean\(amountError\) \|\| busy/); -assert.match(client, /function WalletConnect/); +assert.doesNotMatch(client, /function WalletConnect/); 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 +76,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/); @@ -67,8 +93,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/); @@ -78,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\(\)/); @@ -95,15 +128,19 @@ 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, /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/); 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\./); +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")); @@ -115,9 +152,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/); @@ -133,6 +171,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 } }); @@ -145,57 +184,13 @@ 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 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 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", @@ -215,78 +210,25 @@ 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 () => ({}), 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 6ddae8b..6a9f785 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -13,10 +13,15 @@ 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 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"); @@ -32,7 +37,26 @@ 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(rootLayout, //); +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, //); assert.match(dashboardRoute, /params: Promise<\{ databaseId: string \}>/); assert.match(dashboardRoute, //); assert.match(dashboardClient, /export function DashboardDatabaseClient\(\{ databaseId \}/); +assert.match(dashboardClient, / 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, / 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, /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, /useState\(\(\) => readStoredWallet\(\)\)/); +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, / 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(homePageClient, /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\./); @@ -245,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/); @@ -276,7 +393,7 @@ assert.match(dashboardDangerZone, /const deleteDisabled = props\.busy/); assert.match(dashboardDangerZone, /disabled=\{props\.busy \|\| !deleteConfirmed\}/); assert.match(vfsIdl, /const DeleteDatabaseRequest = idl\.Record/); assert.match(vfsIdl, /delete_database: idl\.Func\(\[DeleteDatabaseRequest\], \[ResultUnit\], \[\]\)/); -assert.doesNotMatch(homePage, /process\.env\.KINIC_WIKI_CANISTER_ID/); +assert.doesNotMatch(homePageClient, /process\.env\.KINIC_WIKI_CANISTER_ID/); assert.doesNotMatch(dashboardClient, /process\.env\.KINIC_WIKI_CANISTER_ID/); assert.match(dashboardUi, /type PendingAclAction/); @@ -294,9 +411,23 @@ 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/); +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"/); @@ -306,7 +437,12 @@ assert.match(dashboardUi, /BookOpen/); assert.doesNotMatch(dashboardUi, /Minimum update/); assert.match(homeUi, /Cycles/); assert.match(homeUi, /Cycles/); -assert.match(homePage, /getCyclesBillingConfig/); +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/); @@ -325,12 +461,17 @@ assert.match(dashboardActionButton, /hover:-translate-y-\[3px\]/); assert.match(dashboardClient, /busyAction/); assert.match(dashboardClient, /Access updated\./); -assert.match(homePage, /refreshSeqRef/); -assert.match(homePage, /isCurrentRefresh/); +assert.match(homePageClient, /refreshSeqRef/); +assert.match(homePageClient, /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.doesNotMatch(homePage, /setDatabases\(\[\]\);\n setCreatedDatabaseId\(null\);\n setError\(null\);\n setWarning\(null\);\n setLoadState\("idle"\);/); -assert.match(dashboardClient, /refreshSeqRef\.current \+= 1;\n await authClient\.logout/); +assert.match(appSession, /await authClient\.logout\(\);/); +assert.match(appSession, /setPrincipal\(null\);/); +assert.match(appSession, /clearWallet\(\);/); +assert.match(homePageClient, /createDatabaseAction=\{/); +assert.match(homeUi, //); assert.match(route, //); assert.match(client, /SkillRegistryClient/); +assert.match(adminHeader, /export function AdminHeader/); +assert.match(client, /