diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..8aeb29f4 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +docs-notarization = "doc -p notarization" +docs-audit-trail = "doc -p audit_trails" diff --git a/.claude/skills/init-api-mapping/SKILL.md b/.claude/skills/init-api-mapping/SKILL.md new file mode 100644 index 00000000..8d680fb2 --- /dev/null +++ b/.claude/skills/init-api-mapping/SKILL.md @@ -0,0 +1,222 @@ +--- +name: init-api-mapping +description: Create an initial `api_mapping.toml` for a new IOTA Trust Framework product, then delegate to the `update-api-mapping` skill to populate it with all currently public Move/Rust/WASM entities. +--- + +# Bootstrap a product's `api_mapping.toml` + +## Purpose + +A new IOTA Trust Framework product (or a product whose Move/Rust/WASM code +already exists but has never had an API mapping committed) needs a starting +`api_mapping.toml` placed in the Move package root. This skill scaffolds that +file with the standard header and conventions, then hands off to the +`update-api-mapping` skill to fill in every section by diffing against the +empty tree — i.e. treating _every_ current public Move/Rust/WASM entity as +"newly added". + +The actual extraction, naming, and reconciliation logic lives in +`update-api-mapping`. This skill exists only to: + +1. Establish the file at the right path with the right header. +2. Invoke `update-api-mapping` with the right inputs so the bootstrap pass + reconciles "nothing" against "everything currently in `HEAD`". +3. Hand a populated, verified TOML back to the user. + +## When to invoke this skill + +- The user says "create an api_mapping for ``", "bootstrap the api + mapping", "we need an `api_mapping.toml` for the new `` crate", + or similar. +- A product directory has Move/Rust/WASM code but no `api_mapping.toml` + alongside the Move package. +- The user explicitly invokes `/init-api-mapping`. + +Do **not** invoke this skill when an `api_mapping.toml` already exists at +the target location — use `update-api-mapping` instead. If one exists and +the user really wants to recreate it, confirm first that they want the +existing file overwritten before proceeding. + +## Required inputs + +The caller (user or invoking agent) **must** provide all of the following. +If any are missing, try to guess them from the user's current working +directory or from the product name they mentioned, and present the guesses +back for validation. Do not pick defaults silently. + +1. **`rust-crate-path`** — path to the `src` folder of the product's Rust + implementation (e.g. `audit-trail-rs/src`, `notarization-rs/src`). +2. **`wasm-bindings-path`** — path to the `src` folder of the product's + WASM bindings (typically `bindings/wasm/_wasm/src`). +3. **`move-sc-path`** — path to the `sources` folder of the product's Move + smart contracts (e.g. `audit-trail-move/sources`). + +Try to guess the correct values for the `rust-crate-path`, `wasm-bindings-path` +and `move-sc-path` depending on the folder the user currently works in or if the +user mentions the product name. Present the input argument values to the user for +validation. + +### Derived values + +- **`api-mapping-path`** — `/../api_mapping.toml`. This is + where the new file will be created. +- **`product`** — the product identifier, derived from the basename of + the Move package directory (parent of `move-sc-path`) with any trailing + `-move` stripped and `-` replaced by `_`. Examples: + - `audit-trail-move/sources` → `audit_trail` + - `notarization-move/sources` → `notarization` +- **`product-display`** — a human-readable form for the file's title + comment (e.g. `Audit Trail`, `Notarization`). Derive by title-casing + the package basename with `-move` stripped and `-` → space; confirm + with the user if uncertain. + +## Workflow + +1. **Validate inputs.** Confirm: + - All three paths exist. + - `` actually contains `*.move` files. + - `` does **not** already exist. If it does, stop + and tell the user to use `update-api-mapping` instead (or ask + whether they want to overwrite — never overwrite silently). + +2. **Identify the Move package's "main" file.** List `*.move` files in + `` and identify the one whose basename matches the + product identifier (with any underscore/dash normalisation needed). + That file's entities will go under the `.main.*` keys; all + others use their bare filename. If no file matches the product name, + ask the user which file should be considered "main" (or whether the + convention should be relaxed for this product). + +3. **Create the scaffold TOML.** Write `` with this + exact header and one banner per Move source file (in source order), + leaving each module body empty for the next step to populate: + + ```toml + # API Mapping + # + # Maps each public Move function or struct in the `/` + # modules to the related Rust entities in `/` and + # WASM/TS entities in `/`. + # + # TOML section keys are formed as `..`: + # - `` — the product identifier, derived from the basename + # of the Move package directory with any trailing `-move` stripped + # and `-` replaced by `_`. For this file: ``. + # - `` — `main` for the Move source file whose basename + # matches the product name (`.move` → `main`); for any + # other Move source file, the bare filename without extension. + # - `` — the function name or struct/enum/const name in + # that module. + # + # `rust` and `wasm` arrays list the Rust- resp. WASM-level functions, + # methods, and types that wrap, build, or otherwise correspond to the + # Move entity. Entry conventions: + # - `Type::method` — an inherent method on `Type` + # - `Type::Variant` — an enum variant + # - `Type` — a plain type/struct/enum + # - `Type.field` — a struct field + # - `module::function` — a free function + # + # An entry of `[]` means there is intentionally no counterpart on + # that side. + # + # This mapping is intended for automatic comparison of function and + # struct documentation across the three implementation layers, and is + # maintained via the `update-api-mapping` and `sync-product-docs` + # skills under `.claude/skills/`. + + # ============================================================================= + # Module: ::main (/.move) + # ============================================================================= + + # ============================================================================= + # Module: :: (/.move) + # ============================================================================= + ``` + + Substitute the placeholders (``, ``, + ``, ``, ``) with + the actual values. Emit one banner per `.move` file, in the order + they appear in `` (alphabetical, with the `main` file + first). + +4. **Bootstrap the contents via `update-api-mapping`.** Invoke the + `update-api-mapping` skill with: + + - `rust-crate-path`, `wasm-bindings-path`, `move-sc-path` — the same + values supplied to this skill. + - **base revision** = the git empty-tree SHA + `4b825dc642cb6eb9a060e54bf8d69288fbee4904`. Diffing against this + SHA causes every public Move/Rust/WASM entity in the working tree + to be reported as "added", which is exactly what bootstrapping + needs. + + `update-api-mapping` will then: + + - Detect every `public fun`, `public struct`, `public enum` in the + Move sources and propose a section per entity. + - Match Rust/WASM symbols using its standard naming heuristics. + - Write all sections to the freshly scaffolded TOML. + + Follow `update-api-mapping`'s normal confirmation flow. Because this + is a bootstrap, expect a large change set — group the proposal by + Move module so the user can review one file at a time. + +5. **Verify the result.** After `update-api-mapping` finishes: + + - Re-read `` and confirm it parses. + - Confirm every Move public entity in `` has a + corresponding TOML section. If any are missing, list them and ask + the user whether to add them with `[]` arrays or pick up after + manual investigation. + - Confirm every banner has at least one section under it (or note + that the module is intentionally empty). + - Report a summary: number of sections created, number with + non-empty `rust`/`wasm` arrays, number left as `[]` for the user + to fill in, and any items that needed user input during the + bootstrap. + +## Operating rules + +- **Don't duplicate `update-api-mapping`'s logic.** This skill scaffolds + and delegates; it must not extract symbols, propose Rust/WASM matches, + or edit individual sections itself. If `update-api-mapping`'s behavior + needs to change, change _that_ skill, not this one. +- **One product per invocation.** Bootstrap one mapping at a time so + the bootstrap diff is reviewable. +- **Never overwrite an existing TOML silently.** Stop and ask if + `` already exists. +- **Don't invent Move source files.** Only create banners for `*.move` + files actually present in ``. +- **Preserve the empty-tree contract.** The hand-off to + `update-api-mapping` always uses the empty-tree SHA as the base. + Don't substitute `origin/main` or similar — those would compare + against an unrelated history and miss entities. + +## Example invocation + +User: + +> `/init-api-mapping` +> `rust-crate-path=identity-rs/src` +> `wasm-bindings-path=bindings/wasm/identity_wasm/src` +> `move-sc-path=identity-move/sources` + +Expected behavior: + +1. Confirm none of the three paths is missing and that + `identity-move/api_mapping.toml` does not yet exist. +2. Inspect `identity-move/sources/` — find e.g. `identity.move`, + `credentials.move`, `revocation.move`. Identify `identity.move` as + the "main" file (matches product name `identity`). +3. Write the scaffold TOML at `identity-move/api_mapping.toml` with + header and three module banners (`identity::main`, + `identity::credentials`, `identity::revocation`). +4. Invoke `update-api-mapping` with base + `4b825dc642cb6eb9a060e54bf8d69288fbee4904`. It detects every + `public fun`/`public struct`/`public enum` in those three Move + files, proposes Rust and WASM counterparts via its naming + heuristics, and (after user confirmation) fills the file in. +5. Re-read the resulting TOML, summarise: e.g. "47 sections created; + 42 have non-empty `rust` arrays; 5 left as `[]` for review: + `identity.main.rotate_keys`, …". diff --git a/.claude/skills/naming-conventions/SKILL.md b/.claude/skills/naming-conventions/SKILL.md new file mode 100644 index 00000000..e5db5c37 --- /dev/null +++ b/.claude/skills/naming-conventions/SKILL.md @@ -0,0 +1,260 @@ +--- +name: naming-conventions +description: Audit README files and public-entity doc comments across Move, Rust and WASM/TS sources for compliance with the `Naming Conventions` section of the repository's root `CLAUDE.md`, and optionally apply fixes. +--- + +# Naming Conventions audit & fix + +## Purpose + +The repository's root `CLAUDE.md` defines a `Naming Conventions` section that +governs how the **Notarization Toolkit**, its TF products (**Single +Notarization**, **Audit Trail**, …), Notarization Methods, and per-language +**Packages** must be referred to in prose. The conventions distinguish product +names (title case, singular) from instance plurals (lowercase) and forbid +synonyms like "SDK" or "Suite" as package labels. + +This skill reads the current `Naming Conventions` section and uses it as the +**single source of truth** to: + +1. Audit README files and the doc comments of public entities in the Move, + Rust, and WASM/TS source trees. +2. Report violations with file:line and a proposed replacement. +3. Optionally apply the fixes. + +The root `CLAUDE.md` `Naming Conventions` section is authoritative — if the +section changes, this skill picks up the new rules automatically. Do not +hard-code rule snapshots inside this skill. + +## When to invoke this skill + +- The user says "audit the naming", "check naming conventions", "fix naming", + "make sure we use Audit Trail correctly", or similar. +- The user just edited the `Naming Conventions` section of `CLAUDE.md` and + wants the codebase brought into alignment. +- The user is preparing a release or doc PR and wants a sweep. + +## Inputs + +The skill is **self-scoping**: it walks the repo from the root and applies +the conventions to README files and public-entity prose. Two optional inputs: + +1. **`scope`** — one of: + - `all` (default) — audit every README and every public-entity doc + comment across Move, Rust, and WASM/TS source trees in the repo. + - `readmes` — audit only README files. + - `sources` — audit only public-entity doc comments in source files. + - A path or glob (e.g. `audit-trail-rs/src`, `bindings/wasm/**`) — + audit only that subtree. +2. **`mode`** — `audit` (default; report only) or `fix` (apply edits, with + user confirmation for anything beyond mechanical case changes). + +If the user names a single TF product ("audit the audit-trail naming", +"naming sweep for notarization"), narrow scope accordingly: + +- "audit trail" → `audit-trail-move/`, `audit-trail-rs/`, + `bindings/wasm/audit_trail_wasm/`, plus any README at the repo root that + references it. +- "notarization" / "single notarization" → `notarization-move/`, + `notarization-rs/`, `bindings/wasm/notarization_wasm/`, plus the repo-root + README. + +## Workflow + +1. **Read the source of truth.** Open the root `CLAUDE.md` and extract the + `## Naming Conventions` section verbatim. Use those rules — and only + those — as the basis for findings. Do not import rules from prior runs + or from memory. + +2. **Build the in-scope file list.** Per `scope`: + - READMEs: every `README.md` not under `node_modules/`, `target/`, + `build/`, or `.git/`. + - Source prose: doc comments on public entities only. Public means: + - **Move** — `///` and `/** … */` on `public fun`, `public struct`, + `public enum`, `const`, and module-level `///` at the top of a + `.move` file. Skip `public(package)` and private functions. + - **Rust** — `///` and `//!` on `pub fn`, `pub struct`, `pub enum`, + `pub` fields, `pub` consts, `pub mod` declarations, and crate-level + `//!` in `lib.rs`/`mod.rs`. Skip `pub(crate)`, `pub(super)`, + `pub(in …)`, and private items. + - **WASM** — `///` on items annotated `#[wasm_bindgen]` (or inside a + `#[wasm_bindgen]`-annotated impl block), plus crate-level `//!`. + +3. **Audit each in-scope file.** Apply the `Naming Conventions` rules + you extracted in step 1 — they are the only rules. This step adds + only the auditor-specific judgement that the rules themselves don't + spell out: + + **Product-sense vs instance-sense — the central judgement call.** + Most product names in the rules (`Audit Trail`, `Notarization`, …) + are required to be title case _when they refer to the TF product + itself_, but the same words may appear lowercase in plural or + instance form (per the rules' own examples). Deciding which sense + an occurrence is in requires reading the surrounding sentence, not + a regex. Lean on these heuristics: + + - **Product-sense indicators** (title case expected): the word is + acting as a proper noun; replace it with the literal product name + ("Audit Trail") and the sentence still reads correctly; it labels + a package, client family, event family, smart-contract bundle, or + enum surface for the product; it sits in a module-level or + crate-level doc describing the crate as a whole. + - **Instance-sense indicators** (lowercase permitted): the word is + preceded by an article or quantifier ("a", "an", "the", "each", + "one", "this"); it refers to one on-chain object or to many; it + appears in an error message or runtime string about a specific + object; replacing it with "this object" reads sensibly. + + When the sentence is genuinely ambiguous, prefer `INCONSISTENT` and + flag for confirmation rather than rewriting. + + **Identifier exemption.** Code identifiers — struct/enum/module/ + function names, `iota_notarization::notarization`, dependency names + (`iota-sdk`, `@iota/sdk`), error-variant names, Cargo.toml fields, + JSON package names — are not prose and are out of scope. Only + comments and Markdown narrative are eligible for findings. + + **Generated-artifact exemption.** Files under `bindings/wasm/*/docs/**` + are generated from Rust `#[wasm_bindgen]` doc attributes. Never edit + them directly — fix the Rust source attribute, and re-generation + will propagate. + +4. **Classify each finding.** Per occurrence, emit one of: + - `VIOLATION` — clear breach (e.g. `Audit Trails` as product name, + `Notarization SDK`, lowercase "audit trail" used in a product-sense + sentence). + - `INCONSISTENT` — permitted by the rules but stylistically out of step + with siblings in the same file (e.g. a list of `Move Package`, + `Rust Package`, `wasm bindings` where the third entry should match + the labelling style of the first two). + - `OK` — compliant; do not report. + +5. **Report (audit mode) or fix (fix mode).** + + - In **audit mode**, group findings by file in source order, quote the + offending sentence with `file:line`, and show the proposed + replacement. End with a one-paragraph summary: total files scanned, + total `VIOLATION`s, total `INCONSISTENT`s, and which TF + product(s)/Package(s) each cluster of findings affects. + + - In **fix mode**, apply mechanical edits without asking (pure + case-change replacements within the same word, e.g. + `audit trail clients` → `Audit Trail clients` in a comment that + unambiguously refers to the product). For anything that requires + rephrasing (e.g. swapping `the bindings` for `the Wasm Package`, + rewording a section heading), present the proposed change and wait + for confirmation. Group edits by file. + +6. **Verify after editing.** When fix mode touched Rust or WASM + source files, run: + - `cargo check -p ` for any modified Rust crate. + - `cargo check --target wasm32-unknown-unknown` from the WASM + bindings crate root for any modified WASM crate. + - For Markdown-only changes no compile check is needed. + +7. **Summarize.** Print total findings, fixes applied, fixes deferred for + user confirmation, and any items the skill chose to skip (with the + reason). + +## Operating rules + +- **Source of truth is `CLAUDE.md`.** Re-read it at the start of each run. + Do not rely on rule snapshots stored in this file, in memory, or in + prior turns. +- **Product-sense vs instance-sense requires reading the sentence.** + This is not a regex job. A blind `s/audit trail/Audit Trail/g` will + damage correct lowercase instance-plurals like "audit trails on the + IOTA ledger". When uncertain, treat the occurrence as `INCONSISTENT` + and flag for confirmation rather than rewriting. +- **Public-entity scope only for source prose.** Private items, doc + comments on `pub(crate)`/`pub(super)` items, and inline `//` comments + are not in scope. Test files and example crates are not in scope + unless the user names them explicitly. +- **Identifiers are exempt.** Never rewrite `iota_notarization::notarization`, + struct names, module names, dependency names, error variant names, + Cargo.toml fields, package names in JSON, etc. Only prose comments and + Markdown narrative text are eligible. +- **Don't touch generated artifacts.** Skip `bindings/wasm/*/docs/**`, + `target/**`, `build/**`, `node_modules/**`. Generated `.d.ts` files + reflect Rust doc attributes; fix the Rust attribute instead. +- **One repo per invocation.** This skill operates on the current repo. +- **Pair with sync-product-docs.** After a naming fix that touches doc + comments, the result is still a single-layer edit; if the Move/Rust/WASM + triplet's other layers need the same wording change, follow up with + `sync-product-docs` rather than expanding this skill's scope. + +## What "compliant" looks like — examples + +**OK — instance-sense or carve-out applies:** + +- `/// Creates a new audit trail with an optional initial record.` + — "a new audit trail" is an instance. +- `/// Top-level locking configuration for the audit trail.` + — "the audit trail" reads as the current instance. +- `A client for creating and managing audit trails on the IOTA blockchain.` + — instance plural, lowercase per rule example. +- `The toolkit includes:` (backref to "IOTA Notarization Toolkit") + — standard capitalization permitted for backrefs. +- `Build the wasm bindings yourself if you have Rust installed.` + — carve-out applies: refers to the binding code. + +**VIOLATION — clear breach of the rules:** + +- `//! Package management for audit trail smart contracts.` + — "audit trail" here labels the product → `Audit Trail`. +- `/// Permission variants enumerated by the audit trail.` + — refers to the product enum surface → `Audit Trail`. +- `## I want a toolkit to build an application` + — uses `toolkit` as a label for what is a Package. +- `Notarization SDK` + — forbidden synonym. +- `Audit Trails` (as product name) + — forbidden plural-as-product. + +**INCONSISTENT — permitted but stylistically off:** + +- `- **wasm bindings** for JavaScript and TypeScript integrations`, + sitting in a list of `Move Package`, `Rust Package`, … + — allowed under the carve-out but out of step with the sibling labels. + +## Example invocations + +### Example 1 + +> `/naming-conventions` + +Expected behavior: + +1. Read `## Naming Conventions` from the root `CLAUDE.md`. +2. Walk all READMEs and all public-entity doc comments in + `audit-trail-move/`, `audit-trail-rs/`, `notarization-move/`, + `notarization-rs/`, `bindings/wasm/*/`. +3. Print a per-file findings list with `VIOLATION` / `INCONSISTENT` and + proposed replacements. +4. Print a summary. + +### Example 2 + +> `/naming-conventions audit-trail fix` + +Expected behavior: + +1. Narrow scope to `audit-trail-move/`, `audit-trail-rs/`, + `bindings/wasm/audit_trail_wasm/`, and any repo-root README that + references Audit Trail. +2. Apply mechanical case-change fixes without asking. For anything that + needs rephrasing (e.g. section heading rewrites, swapping + `the bindings` for `the Wasm Package`), present the proposed change + and wait for confirmation. +3. After editing Rust/WASM sources, run the relevant `cargo check` + commands. +4. Summarize. + +### Example 3 + +> `/naming-conventions readmes` + +Expected behavior: + +1. Audit only README files (root and per-package). +2. Report; do not edit unless `mode=fix` is also specified. diff --git a/.claude/skills/sync-product-docs/SKILL.md b/.claude/skills/sync-product-docs/SKILL.md new file mode 100644 index 00000000..25e04fce --- /dev/null +++ b/.claude/skills/sync-product-docs/SKILL.md @@ -0,0 +1,319 @@ +--- +name: sync-product-docs +description: Sync the doc comments of public Move functions/structs in a product's Move sources with the corresponding Rust and WASM/TypeScript entities, using the product's api_mapping.toml as the source of truth for the mapping. +--- + +# Sync product documentation across Move, Rust and WASM layers + +## Purpose + +An IOTA Trust Framework product (Audit Trail, Notarization, …) has three +implementation layers that need to convey the same behavior to their users: + +- **Move** — `/*.move` +- **Rust** — `/**/*.rs` +- **WASM/TypeScript** — `/**/*.rs` + +The product's `api_mapping.toml` (located at `/../api_mapping.toml` + +- see **`api-mapping-path`** below for more details) + is the canonical mapping from each public Move function/struct to the Rust and + WASM entities that wrap, build, or otherwise correspond to it. This skill uses + that mapping to keep the doc comments of each "triplet" (Move ↔ Rust ↔ WASM) + semantically aligned. + +The Move layer is the **authoritative source of behavior**. Its doc comments +describe what the on-chain function does, what arguments it takes, what events +it emits, and when it aborts. Rust and WASM doc comments must convey the same +contract, framed for the language they live in. + +## Required inputs + +The caller (user or invoking agent) **must** provide all of the following. If any +are missing, ask once before proceeding. + +1. **`rust-crate-path`** — path to the `src` folder of the product's Rust + implementation (e.g. `audit-trail-rs/src`, `notarization-rs/src`). +2. **`wasm-bindings-path`** — path to the `src` folder of the product's WASM + bindings (typically `bindings/wasm/_wasm/src`). +3. **`move-sc-path`** — path to the `sources` folder of the product's Move + smart contracts (e.g. `audit-trail-move/sources`). + +Try to guess the correct values for the `rust-crate-path`, `wasm-bindings-path` +and `move-sc-path` depending on the folder the user currently works in or if the +user mentions the product name. Present the input argument values to the user for +validation. + +### Derived paths + +- **`api-mapping-path`** — `/../api_mapping.toml`. The TOML lives + in the parent of the Move sources directory. If it isn't there, ask the user + where it lives before continuing. +- **`wasm-docs-path`** — `/../docs/`. Generated; never edit. + +## When to invoke this skill + +- The user says "sync the audit trail docs", "sync the notarization docs", "check the api_mapping doc + alignment for notarization", "update the Rust/WASM docs to match Move", or similar. +- The user has just edited a `.move` file in a product implementation folder + i.e. `audit-trail-move/sources/` and asks to propagate the doc change. +- The user wants a report of where the three layers disagree. + +## Prepare the scope + +Always read the `api_mapping.toml` file first. It is the source of +truth for which entities are paired up. Do not invent additional pairings or +skip entries the file lists. + +## TOML key convention + +Each TOML key has the form `..`: + +- `` — the product identifier (derived from the Move package + directory name with any trailing `-move` stripped and `-` replaced by `_`). +- `` — `main` for the Move source file whose basename matches the + product name; the bare filename without extension otherwise. +- `` — the function name or struct/enum/const name in that module. + +The Move source file for a given module key is therefore: + +- `.main` → `/.move` (with `_` → original + package separator if needed) +- `.` → `/.move` + +Each entry has a `rust` and a `wasm` array. Entry conventions: + +- `Type::method` — an inherent method on `Type` +- `Type::Variant` — an enum variant +- `Type` — a plain type/struct/enum +- `Type.field` — a struct field +- `module::function` — a free function + +An entry of `[]` means there is intentionally no counterpart on that side. +Treat that as a legitimate state, not a missing doc. + +## Workflow + +Pick one of the two modes the user asks for: + +**Audit mode (default when unspecified):** report mismatches without editing. +**Fix mode:** apply edits to bring Rust and/or WASM docs into alignment with +the Move doc, asking the user before non-trivial rewrites. + +Follow these steps: + +1. **Parse the product's api_mapping.toml** at ``. Build the + list of triplets (Move entity → Rust entries → WASM entries). If the user + named a specific module/entity, filter to that subset. + +2. **For each Move entity, locate its doc comment in `/`.** In + Move, doc comments are `///` lines (or `/** ... */`) immediately preceding + the `public fun`, `public struct`, `public enum`, or `const` declaration. + If the entity is a struct/enum, also collect the per-field/per-variant + `///` comments where applicable. + +3. **For each `rust` entry, locate the doc comment in `/`.** + Use grep to find the declaration (`fn `, `struct `, + `enum `, `impl ... { fn ... }`, etc.). Doc comments are `///` + lines preceding the item. For `Type.field`, find the field inside the + struct definition. + + **Audit every entry in the `rust` array independently** — type-level, + method-level, field-level, variant-level, free-function-level. A triplet + whose entrypoint method is well-aligned may still have a stale struct doc + that omits an invariant, or a field doc that hasn't picked up a new + constraint. Do not collapse the per-entry audit into a single "Rust looks + fine" conclusion. + +4. **For each `wasm` entry, do the same in `/`.** WASM + types are typically prefixed `Wasm` and bound via `#[wasm_bindgen]`. Some + WASM entries are exposed as TypeScript via tsify/jsdoc — check that the + rendered TS doc (visible in `` or in the + `#[wasm_bindgen(...)]` attribute) matches. + + The same per-entry rule applies: audit every entry in the `wasm` array + independently. + + **Audit Rust and WASM symmetrically.** Both layers are first-class + targets — the skill is not "sync WASM to match Rust" or vice versa. + For every triplet, run the audit against the Move source on the Rust + array AND on the WASM array, even when one of them was recently + touched. Recent edits often update some entries in a triplet but miss + others (e.g. update `Foo::some_method` but not the `Foo` struct-level + invariants doc, or update the validator method but not the struct's + field doc that constrains the input). Never assume "the commit touched + Rust, so Rust is done" — verify it. + +5. **Compare semantically, not character-by-character.** A Rust doc may + reasonably: + - Rephrase to fit Rust idioms (e.g. "Returns `Option`" instead of + Move's "returns the value if set"). + - Add Rust-specific details (lifetimes, async, error type, builder + positioning). + - Drop on-chain-only details that don't apply at the client layer. + + A mismatch is anything that changes the **observable contract**: argument + meaning, return semantics, abort/error conditions, emitted events, + authorization requirements, locking/timing constraints, units (ms vs s, + epoch vs timestamp). + + Pay particular attention to: + - **Abort conditions in Move** → these must be reflected as documented + errors in the Rust/WASM wrappers. + - **Permission/authorization requirements** → wrappers on either side + must describe the same gated operation and required capability. + - **Locking/timing semantics** → time-based values, count-based values and + any reserved variants (e.g. constraints valid only for specific locks) + must be consistent across all three layers. + - **Field-level docs** for structs that document fields with `///` — + the Rust struct fields and WASM getter accessors must match. + - **Derived-set constructors** (e.g. the `*_permissions()` permission-set + presets, and any future constructor whose doc enumerates the members of + a set it builds) → verify each layer's enumerated doc list against that + layer's **implementation**, not just against the Move doc. Doc-vs-doc + comparison can be consistent and still wrong when the Move + implementation changed without its doc list. The Move implementation is + the behavioral truth: if the Move doc list disagrees with the Move code, + flag it to the user (offer to fix it) before propagating; if the Rust + implementation disagrees with the Move implementation, report that as a + code (not doc) divergence — WASM delegates to Rust and only needs its + doc list checked. + +6. **Report (audit mode) or edit (fix mode).** Report **per entry**, not + per layer or per triplet — collapsing multiple entries into one verdict + hides drift. For each entry under a triplet, report one of: + - `OK` — semantically aligned, no action. + - `MISSING ` — the entity exists per the mapping but has no doc + comment in that layer. + - `DRIFT ` — the doc exists but contradicts or omits a contract + point from the Move source. Quote the diverging sentence and name + the specific entry (e.g. `DRIFT Rust: LockingConfig` struct-level + doc — not just `DRIFT Rust`). + - `MAPPING STALE` — an entry in the TOML refers to a Rust/WASM symbol + that does not exist in the source tree. Suggest fixing the TOML + (via the `update-api-mapping` skill) rather than the docs. + + In fix mode, propose the new Rust/WASM doc comment and apply the edit + with the `Edit` tool. Never edit the Move doc to match Rust/WASM — Move + is the source of truth for behavior. If you believe the Move doc is + wrong, flag it for the user instead of changing it. + + **After applying fixes to one layer, re-check the other.** If you only + edited WASM, sweep the Rust entries once more before declaring the + triplet done; if you only edited Rust, do the symmetric sweep on WASM. + +7. **Summarize at the end:** total triplets checked, OK count, mismatches by + category, and the list of entries skipped (if any). + +## Scoping by commit or diff + +When the user scopes the run to a specific commit or branch (e.g. +"sync the docs regarding changes of commit `abc1234`"), the diff tells +you **which triplets to check** — not which entries inside those triplets +to check. + +For every triplet that contains a Move entity touched by the diff: + +- Audit **every** Rust entry in the triplet's `rust` array against the + current Move source, regardless of whether the entry was modified in + the commit. A commit may have updated `Foo::method` while leaving the + `Foo` struct-level doc — which constrains the same contract — stale. +- Audit **every** WASM entry the same way. +- Audit related triplets too: if the Move change affects a contract + point that appears in another module's doc (e.g. a constraint on + `LockingConfig` that also surfaces in `update_delete_record_window`'s + doc), follow the contract point across triplets. + +Do **not** let the diff bias the audit toward "what was touched" — that +is exactly how drift survives a commit that "already updated the docs". + +## Operating rules + +- **Use the TOML — don't reinvent the mapping.** Even when an obvious naming + pattern exists, only sync pairs the file declares. +- **Don't add documentation to entities the TOML lists with `[]`.** That + empty list is intentional (no counterpart exists yet, by design). +- **Audit per-entry, not per-layer.** Each item in a `rust`/`wasm` array + has its own doc and its own potential for drift. A triplet may be + "mostly OK" but still have one stale field doc or one outdated + struct-level invariant; the report must surface that entry by name. +- **Audit Rust and WASM with equal rigor.** They are peer targets of this + skill. Never conclude "Rust looks fine" without grep-verifying each + `rust` entry's doc against the Move source, and likewise for WASM. +- **Process by Move module.** Working through one `.move` file at a time + keeps the Move source open in context and reduces churn. +- **Group edits by file.** When fixing, batch all edits to the same Rust or + WASM file into a single pass to minimize re-reads. +- **Follow existing doc style guides.** Lookup possibly referenced documentation + guidelines in `CLAUDE.md` files in the Rust crate or Move package folder. + For Rust, look for a `CLAUDE.md` or `DOC-STYLEGUIDE.md` at the crate + root; for WASM, the per-bindings `CLAUDE.md` typically points at a + shared `bindings/wasm/DOC-STYLEGUIDE.md`. +- **Preserve existing doc style.** If no documentation + guideline can be found, match the surrounding crate's tone. +- **List Move events.** If Move events are documented with the related Move + function, also list these events at the back of Rust and TS function + documentation. +- **Don't touch generated artifacts.** Files under `/docs/**` are + generated; fix the source Rust attributes instead. +- **Verify the build after edits.** Run `cargo check -p ` after + Rust doc edits and `cargo check --target wasm32-unknown-unknown` (from + the WASM bindings crate) after WASM doc edits, so a typo or broken + intra-doc link is caught before the run ends. +- **One product per invocation.** This skill operates on a single product. + To sync several products' docs, invoke the skill once per product. + +## What "aligned" looks like — example + +For an entry `[audit_trails.locking.new]` (the `LockingConfig::new` +constructor): + +- **Move** (`/locking.move`): "Create a new locking + configuration. Aborts with `EUntilDestroyedNotSupportedForDeleteTrail` + when `delete_trail_lock` is `TimeLock::UntilDestroyed`; that variant is + reserved for the write lock." +- **Rust** (`LockingConfig`, `LockingConfig::to_ptb` in ``): + the type-level doc should mention that `delete_trail_lock` cannot be + `TimeLock::UntilDestroyed` and explain the resulting on-chain abort the + wrapper surfaces as an error. +- **WASM** (`WasmLockingConfig::new` in ``): the + constructor doc must surface the same constraint, framed as a JS exception + that callers will see. + +If the Rust doc says only "Construct a `LockingConfig`" without mentioning +the constraint, that is a `DRIFT Rust` finding. + +## Example invocation + +### Example 1 + +> `/sync-product-docs notarization` + +Expected behavior: + +1. Explore the child folder names in the repository root folder and in the `bindings/wasm` folder + and figure out if the three input arguments can be guessed from the product name `notarization`. +2. Present the guessed input arguments to the user for validation or - in case of doubts - ask the + user for the correct product name or input arguments. +3. Read `notarization-move/api_mapping.toml`. +4. For each triplet, locate the Move, Rust, and WASM doc comments. +5. Report `OK` / `MISSING` / `DRIFT` / `MAPPING STALE` per triplet. +6. Edit the docs accordingly +7. Print a summary grouped by Move module. + +### Example 2 + +User: + +> `/sync-product-docs` +> `rust-crate-path=notarization-rs/src` +> `wasm-bindings-path=bindings/wasm/notarization_wasm/src` +> `move-sc-path=notarization-move/sources` +> mode=audit + +Expected behavior: + +1. Read `notarization-move/api_mapping.toml`. +2. For each triplet, locate the Move, Rust, and WASM doc comments. +3. Report `OK` / `MISSING` / `DRIFT` / `MAPPING STALE` per triplet. +4. Print a summary grouped by Move module. diff --git a/.claude/skills/update-api-mapping/SKILL.md b/.claude/skills/update-api-mapping/SKILL.md new file mode 100644 index 00000000..4a110e1c --- /dev/null +++ b/.claude/skills/update-api-mapping/SKILL.md @@ -0,0 +1,263 @@ +--- +name: update-api-mapping +description: Update a product's `api_mapping.toml` by diffing the working tree against a user-provided git revision + and reconciling added/removed public Move functions/structs and their Rust and WASM/TS counterparts. +--- + +# Update a product's `api_mapping.toml` from a git diff + +## Purpose + +For an IOTA Trust Framework product (e.g. Audit Trail, Notarization, …) the +`api_mapping.toml` lists every public Move function and struct in the product's +Move sources together with the Rust entities and WASM/TS entities that wrap or +correspond to them. + +When the product API changes — new Move functions, renamed Rust types, removed +WASM bindings — this file must be kept in sync. This skill takes a user-supplied +**base revision** plus three product paths and updates the TOML to reflect what +changed between that revision and the current working tree. + +## Required inputs + +The caller (user or invoking agent) **must** provide all of the following. If any +are missing, ask once with a concrete suggestion before proceeding. Do not pick +defaults silently. + +1. **`rust-crate-path`** — path to the `src` folder of the product's Rust + implementation (e.g. `audit-trail-rs/src`, `notarization-rs/src`). +2. **`wasm-bindings-path`** — path to the `src` folder of the product's WASM + bindings (typically `bindings/wasm/_wasm/src`). +3. **`move-sc-path`** — path to the `sources` folder of the product's Move + smart contracts (e.g. `audit-trail-move/sources`, `notarization-move/sources`). +4. **base revision** — a git ref (commit SHA, branch, tag, or `HEAD~N`) to diff + against. Suggest `origin/main` if the user offers no hint. + +Try to guess the correct values for the `rust-crate-path`, `wasm-bindings-path` +and `move-sc-path` depending on the folder the user currently works in or if the +user mentions the product name. Present the input argument values to the user for +validation. + +### Derived paths + +- **`api-mapping-path`** — `/../api_mapping.toml`. The TOML lives + in the parent of the Move sources directory (i.e. the Move package root). If + it doesn't exist there, ask the user where it lives before continuing. +- **`wasm-docs-path`** — `/../docs/` for generated WASM/TS + documentation. Always treated as read-only / ignore for diffing. + +## TOML key convention + +Each TOML section key has the form `..`: + +- `` — the product identifier, derived from the basename of the Move + package directory (the parent of `move-sc-path`) with any trailing `-move` + stripped and `-` replaced by `_`. Examples: + - `audit-trail-move/sources` → product `audit_trail` + - `notarization-move/sources` → product `notarization` +- `` — `main` for the Move source file whose basename matches the + product name (e.g. `audit_trail.move` → `main`, `notarization.move` → + `main`); for any other Move source file, the bare filename without + extension (e.g. `locking.move` → `locking`). +- `` — the function name or struct/enum/const name within that module. + +If the existing TOML uses a different convention, treat the existing file as +authoritative and follow its conventions; do not rename keys. + +## Scope of changes considered + +Only diffs inside these paths are relevant: + +- `/` — drives which TOML keys exist +- `/` — drives the `rust` arrays +- `/` — drives the `wasm` arrays + +Ignore changes in tests, examples, docs, generated WASM output (`wasm-docs-path`), +Move build artifacts (`build/`, `Move.lock`), and anything else outside the +three paths above. + +## What counts as a "new" or "obsolete" item + +**New** — present at `HEAD` (working tree) but not in the base revision: + +- Move: `public fun `, `public struct `, `public enum `, + `const ` (only if convention in the existing TOML lists consts). + `public(package) fun ` is **excluded** — only fully `public` items + enter the mapping. +- Rust: any `pub fn`, `pub struct`, `pub enum`, `impl` method exposed via + `pub`, or `pub` field that is plausibly a wrapper for a Move entity. +- WASM: any `#[wasm_bindgen]`-exposed type, method, or getter, or any + `Wasm*` struct/enum and its public methods. + +**Obsolete** — present in the base revision but no longer in `HEAD`: + +- A whole TOML section becomes obsolete if its Move function/struct was + removed (or renamed without the TOML being updated). Remove the section + entirely. +- An individual entry inside a `rust` or `wasm` array becomes obsolete if + the symbol it names no longer exists in the corresponding source tree. + Remove just that entry. + +Renames are handled as remove-then-add. If you can pair an obsolete name +with a new name (same surrounding context, same signature), surface the +pairing to the user before applying — it's usually a rename and the TOML +update is mechanical. + +## Workflow + +1. **Validate inputs.** Confirm all three paths exist and resolve the base + revision: + + ```bash + git rev-parse --verify + ``` + + Stop and ask if anything doesn't resolve. + +2. **Get the scoped diff.** Run: + + ```bash + git diff ..HEAD -- \ + / \ + / \ + / + ``` + + Also check the working tree for uncommitted changes to those paths + (`git status --short`) so unstaged additions are not missed. If any are + present, include them in the analysis and tell the user. + +3. **Extract added/removed Move entities.** From the diff in `/`, + collect: + - Added lines matching `public fun `, `public struct `, + `public enum `. + - Removed lines matching the same patterns. + - Ignore `public(package)` — those are not part of the mapping. + - Ignore signature-only changes (argument types, return types) when the + name is unchanged; those don't move TOML keys. + +4. **Extract added/removed Rust entities.** From the diff in `/`, + collect added/removed `pub fn`, `pub struct`, `pub enum`, methods inside + `impl` blocks declared `pub`, and `pub` struct fields. Convert to the TOML + form: `Type::method` for inherent methods, `Type` for plain types, + `Type::Variant` for enum variants, `Type.field` for fields, + `module::function` for free functions. + +5. **Extract added/removed WASM entities.** Same analysis applied to + `/`. Convention: WASM entities are typically prefixed + `Wasm*`. Include only items annotated with `#[wasm_bindgen]` (directly or + via an enclosing impl block). + +6. **Reconcile against the existing TOML.** Read `` and + produce three lists: + - **TOML keys to add** — Move entities newly present in `HEAD` that have + no key. + - **TOML keys to remove** — keys whose Move entity no longer exists. + - **Array entries to add/remove** — Rust/WASM symbols added or removed + under existing keys. + + For each new TOML key, propose an initial `rust` and `wasm` array by + matching the new Move name against the new Rust/WASM symbols. Use these + heuristics: + - Same name (snake_case → snake_case for fns; PascalCase → PascalCase + for types; PascalCase ↔ `Wasm` + PascalCase for WASM types). + - For a new Move `public fun foo`, look for a Rust `Foo` transaction + struct with `Foo::new`, a builder method, and a WASM `WasmFoo` with + `build_programmable_transaction` / `apply_with_events` — that's the + established pattern (see existing TOML entries for examples). + - Leave arrays empty (`[]`) when no plausible match exists; flag for + user input rather than guessing. + +7. **Show the proposed changes before editing.** Present a concise diff: + - Sections to add (with proposed `rust`/`wasm` arrays). + - Sections to remove. + - Per-section additions/removals to `rust`/`wasm` arrays. + + Wait for user confirmation if any item required heuristic matching or + if obsolete items might be renames. Skip the confirmation gate for + purely mechanical updates (e.g. an obvious added entry whose Rust/WASM + counterparts have identical names to other entries already in the file). + +8. **Apply the edits.** Use `Edit` to modify `` in place. + Preserve: + - The file header comment. + - The `# =====` module banner comments. + - The order: sections grouped by module, in source file order; entries + within `rust`/`wasm` arrays roughly follow the order in the source + file (declaration order). + +9. **Verify.** After editing: + - Re-read the TOML and confirm it parses (every section has a key in + the form `..`, every value is an array of + strings). + - For each Rust/WASM entry in modified sections, confirm the symbol + actually exists in the corresponding source tree (`grep -n "fn "` + or `grep -n "struct "` in the relevant directory). + - Report what was changed and any items left as `[]` for the user to + fill in. + +## Operating rules + +- **Never invent symbols.** If a Rust or WASM name is not found in the + source tree, do not add it to the TOML. Either find the real name or + flag the gap. +- **Preserve human curation.** Existing `rust`/`wasm` arrays may include + related types beyond direct wrappers (e.g. an event struct alongside a + transaction struct). Do not prune entries just because their name + doesn't match the Move name — only remove entries whose underlying + symbol no longer exists. +- **Don't reorder unrelated sections.** Limit edits to the sections being + added, removed, or modified. The TOML is human-edited and arbitrary + diffs are noisy in code review. +- **One product per invocation.** This skill operates on a single product + (one `(rust-crate-path, wasm-bindings-path, move-sc-path)` triple). To + update mappings for several products, invoke the skill once per product. +- **Stop on ambiguity.** If a Move function clearly maps to multiple + candidate Rust types, ask the user rather than picking one. +- **Diff hygiene.** When showing the proposed change set, group by Move + module so a reviewer can scan it the same way the file is laid out. + +## Example invocation + +### Example 1 + +> `/update-api-mapping notarization base=653a27c` + +1. Explore the child folder names in the repository root folder and in the `bindings/wasm` folder + and figure out if the three input arguments can be guessed from the product name `notarization`. +2. Present the guessed input arguments to the user for validation or - in case of doubts - ask the + user for the correct product name or input arguments. +3. Diff that 653a27c..HEAD restricted to the three source paths. +4. Find e.g. an added `public fun foo_bar` in `notarization.move`, an + added `FooBar` struct + `FooBar::new` in `notarization-rs/src`, + and `WasmFooBar` with the usual two methods in the WASM crate. +5. Propose a new `[notarization.main.foo_bar]` section with both arrays + prefilled. +6. Find e.g. that `delete_foo`'s removed Rust helper + `FooBar::delete_legacy` should be dropped from the existing + section. +7. Show the change set, apply on confirmation, verify, summarize. + +### Example 2 + +User: + +> `/update-api-mapping` +> `rust-crate-path=audit-trail-rs/src` +> `wasm-bindings-path=bindings/wasm/audit_trail_wasm/src` +> `move-sc-path=audit-trail-move/sources` +> base=`origin/feat/audit-trails-dev` + +Expected behavior: + +1. Resolve `origin/feat/audit-trails-dev` to a SHA. +2. Diff that SHA..HEAD restricted to the three source paths. +3. Find e.g. an added `public fun pause_trail` in `audit_trail.move`, an + added `PauseTrail` struct + `PauseTrail::new` in `audit-trail-rs/src`, + and `WasmPauseTrail` with the usual two methods in the WASM crate. +4. Propose a new `[audit_trails.main.pause_trail]` section with both arrays + prefilled. +5. Find e.g. that `delete_records_batch`'s removed Rust helper + `TrailRecords::delete_batch_legacy` should be dropped from the existing + section. +6. Show the change set, apply on confirmation, verify, summarize. diff --git a/.github/actions/publish/wasm/action.yml b/.github/actions/publish/wasm/action.yml index 4fc39627..ba44b905 100644 --- a/.github/actions/publish/wasm/action.yml +++ b/.github/actions/publish/wasm/action.yml @@ -10,6 +10,9 @@ inputs: working-directory: description: "Directory to publish from" required: true + artifact-download-path: + description: "Directory to download artifacts to (defaults to working-directory)" + required: false dry-run: description: "'true' = only log potential result; 'false' = publish'" required: true @@ -27,7 +30,7 @@ runs: uses: actions/download-artifact@v4 with: name: ${{ inputs.input-artifact-name }} - path: bindings/wasm/notarization_wasm + path: ${{ inputs.artifact-download-path || inputs.working-directory }} - name: Publish WASM bindings to NPM shell: sh diff --git a/.github/banner_audit_trails.png b/.github/banner_audit_trails.png new file mode 100644 index 00000000..fac89062 Binary files /dev/null and b/.github/banner_audit_trails.png differ diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 830ff1b5..657bdcdf 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -17,11 +17,13 @@ on: - ".github/workflows/shared-build-wasm.yml" - ".github/actions/**" - "**.rs" + - "**.move" - "**.toml" - "**.lock" - "bindings/**" - "!bindings/**.md" - "bindings/wasm/notarization_wasm/README.md" # the Readme contain txm tests + - "bindings/wasm/audit_trail_wasm/README.md" schedule: # * is a special character in YAML so you have to quote this string @@ -165,17 +167,27 @@ jobs: - name: test Notarization Move package if: matrix.os != 'windows-latest' - # publish the package and set the IOTA_NOTARIZATION_PKG_ID env variable - run: | - iota move test + run: iota move test working-directory: notarization-move + - name: test Audit Trail Move package + if: matrix.os != 'windows-latest' + run: iota move test + working-directory: audit-trail-move + - name: publish Notarization Move package if: matrix.os != 'windows-latest' - # publish the package and set the IOTA_NOTARIZATION_PKG_ID env variable run: echo "IOTA_NOTARIZATION_PKG_ID=$(./publish_package.sh)" >> "$GITHUB_ENV" working-directory: notarization-move/scripts/ + - name: publish Audit Trail Move package + if: matrix.os != 'windows-latest' + run: | + eval "$(./publish_package.sh)" + echo "IOTA_AUDIT_TRAIL_PKG_ID=$IOTA_AUDIT_TRAIL_PKG_ID" >> "$GITHUB_ENV" + echo "IOTA_TF_COMPONENTS_PKG_ID=$IOTA_TF_COMPONENTS_PKG_ID" >> "$GITHUB_ENV" + working-directory: audit-trail-move/scripts/ + - name: Run tests if: matrix.os != 'windows-latest' run: cargo test --workspace --release -- --test-threads=1 @@ -210,7 +222,7 @@ jobs: uses: "./.github/actions/rust/sccache/stop" with: os: ${{matrix.os}} - build-wasm: + build-wasm-notarization: needs: check-for-run-condition if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} uses: "./.github/workflows/shared-build-wasm.yml" @@ -218,8 +230,18 @@ jobs: run-unit-tests: false output-artifact-name: notarization-wasm-bindings-build - test-wasm: - needs: build-wasm + build-wasm-audit-trail: + needs: check-for-run-condition + if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} + uses: "./.github/workflows/shared-build-wasm.yml" + with: + run-unit-tests: false + output-artifact-name: audit-trail-wasm-bindings-build + wasm-package-dir: bindings/wasm/audit_trail_wasm + wasm-crate-name: audit_trail_wasm + + test-wasm-notarization: + needs: [build-wasm-notarization, check-for-run-condition] if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} runs-on: ubuntu-24.04 strategy: @@ -253,21 +275,53 @@ jobs: iota-version: ${{ env.IOTA_VERSION }} - name: publish Notarization Move package - if: matrix.os != 'windows-latest' - # publish the package and set the IOTA_NOTARIZATION_PKG_ID env variable run: echo "IOTA_NOTARIZATION_PKG_ID=$(./publish_package.sh)" >> "$GITHUB_ENV" working-directory: notarization-move/scripts/ + - name: Run Wasm examples + run: npm run test:node + working-directory: bindings/wasm/notarization_wasm + + test-wasm-audit-trail: + needs: [build-wasm-audit-trail, check-for-run-condition] + if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: 20.x + - name: Install JS dependencies run: npm ci - working-directory: bindings/wasm/notarization_wasm + working-directory: bindings/wasm/audit_trail_wasm + + - name: Download bindings/wasm/audit_trail_wasm artifacts + uses: actions/download-artifact@v4 + with: + name: audit-trail-wasm-bindings-build + path: bindings/wasm/audit_trail_wasm + + - name: Start iota sandbox + uses: "./.github/actions/iota/setup" + with: + iota-version: ${{ env.IOTA_VERSION }} + + - name: publish Audit Trail Move package + run: | + eval "$(./publish_package.sh)" + echo "IOTA_AUDIT_TRAIL_PKG_ID=$IOTA_AUDIT_TRAIL_PKG_ID" >> "$GITHUB_ENV" + echo "IOTA_TF_COMPONENTS_PKG_ID=$IOTA_TF_COMPONENTS_PKG_ID" >> "$GITHUB_ENV" + working-directory: audit-trail-move/scripts/ - name: Run Wasm examples - #run: npm run test:readme && npm run test:node run: npm run test:node - working-directory: bindings/wasm/notarization_wasm - test-wasm-browser: - needs: build-wasm + working-directory: bindings/wasm/audit_trail_wasm + test-wasm-browser-notarization: + needs: [build-wasm-notarization, check-for-run-condition] if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} runs-on: ubuntu-24.04 strategy: @@ -316,3 +370,57 @@ jobs: - name: Run cypress run: docker run --network host cypress-test test:browser:${{ matrix.browser }} + + test-wasm-browser-audit-trail: + needs: [build-wasm-audit-trail, check-for-run-condition] + if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + browser: [chrome, firefox] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: 20.x + + - name: Install JS dependencies + run: npm ci + working-directory: bindings/wasm/audit_trail_wasm + + - name: Download bindings/wasm/audit_trail_wasm artifacts + uses: actions/download-artifact@v4 + with: + name: audit-trail-wasm-bindings-build + path: bindings/wasm/audit_trail_wasm + + - name: Start iota sandbox + uses: "./.github/actions/iota/setup" + with: + iota-version: ${{ env.IOTA_VERSION }} + + - name: publish Audit Trail Move package + run: | + eval "$(./publish_package.sh)" + echo "IOTA_AUDIT_TRAIL_PKG_ID=$IOTA_AUDIT_TRAIL_PKG_ID" >> "$GITHUB_ENV" + echo "IOTA_TF_COMPONENTS_PKG_ID=$IOTA_TF_COMPONENTS_PKG_ID" >> "$GITHUB_ENV" + working-directory: audit-trail-move/scripts/ + + - name: Build Docker image + uses: docker/build-push-action@v6.2.0 + with: + context: bindings/wasm/ + file: bindings/wasm/audit_trail_wasm/cypress/Dockerfile + push: false + tags: cypress-audit-trail:latest + load: true + build-args: | + IOTA_AUDIT_TRAIL_PKG_ID=${{ env.IOTA_AUDIT_TRAIL_PKG_ID }} + IOTA_TF_COMPONENTS_PKG_ID=${{ env.IOTA_TF_COMPONENTS_PKG_ID }} + + - name: Run cypress + run: docker run --network host cypress-audit-trail test:browser:${{ matrix.browser }} diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index b542cd19..16239453 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -49,3 +49,9 @@ jobs: if: ${{ false }} with: args: --manifest-path ./bindings/wasm/notarization_wasm/Cargo.toml --target wasm32-unknown-unknown --all-targets --all-features -- -D warnings + + - name: Wasm clippy check audit_trail_wasm + uses: actions-rs-plus/clippy-check@b09a9c37c9df7db8b1a5d52e8fe8e0b6e3d574c4 + if: ${{ false }} + with: + args: --manifest-path ./bindings/wasm/audit_trail_wasm/Cargo.toml --target wasm32-unknown-unknown --all-targets --all-features -- -D warnings diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index eeb124f3..13e1a5c9 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -49,6 +49,9 @@ jobs: - name: wasm fmt check notarization_wasm run: cargo +nightly fmt --manifest-path ./bindings/wasm/notarization_wasm/Cargo.toml --all -- --check + - name: wasm fmt check audit_trail_wasm + run: cargo +nightly fmt --manifest-path ./bindings/wasm/audit_trail_wasm/Cargo.toml --all -- --check + - name: fmt check with dprint run: dprint check @@ -61,6 +64,10 @@ jobs: - name: Install prettier-plugin-move run: npm i @mysten/prettier-plugin-move - - name: prettier-move check + - name: prettier-move check notarization-move working-directory: notarization-move run: npx prettier-move -c **/*.move + + - name: prettier-move check audit-trail-move + working-directory: audit-trail-move + run: npx prettier-move -c **/*.move diff --git a/.github/workflows/shared-build-wasm.yml b/.github/workflows/shared-build-wasm.yml index 9e4a432a..4f8c954b 100644 --- a/.github/workflows/shared-build-wasm.yml +++ b/.github/workflows/shared-build-wasm.yml @@ -19,6 +19,16 @@ on: description: "Name used for the output build artifact" required: true type: string + wasm-package-dir: + description: "Relative path to the wasm package directory (e.g. bindings/wasm/notarization_wasm)" + required: false + type: string + default: "bindings/wasm/notarization_wasm" + wasm-crate-name: + description: "Name of the wasm crate (e.g. notarization_wasm)" + required: false + type: string + default: "notarization_wasm" jobs: build-wasm: defaults: @@ -52,6 +62,7 @@ jobs: sccache-enabled: true sccache-path: ${{ matrix.sccache-path }} target-cache-path: bindings/wasm/target + target-cache-key-suffix: ${{ inputs.wasm-crate-name }} # Download a pre-compiled wasm-bindgen binary. - name: Install wasm-bindgen-cli @@ -71,16 +82,16 @@ jobs: - name: Install JS dependencies run: npm ci - working-directory: bindings/wasm/notarization_wasm + working-directory: ${{ inputs.wasm-package-dir }} - name: Build WASM bindings run: npm run build - working-directory: bindings/wasm/notarization_wasm + working-directory: ${{ inputs.wasm-package-dir }} - name: Run Node unit tests if: ${{ inputs.run-unit-tests }} run: npm run test:unit:node - working-directory: bindings/wasm/notarization_wasm + working-directory: ${{ inputs.wasm-package-dir }} - name: Stop sccache uses: "./.github/actions/rust/sccache/stop" @@ -92,9 +103,9 @@ jobs: with: name: ${{ inputs.output-artifact-name }} path: | - bindings/wasm/notarization_wasm/node - bindings/wasm/notarization_wasm/web - bindings/wasm/notarization_wasm/examples/dist - bindings/wasm/notarization_wasm/docs + ${{ inputs.wasm-package-dir }}/node + ${{ inputs.wasm-package-dir }}/web + ${{ inputs.wasm-package-dir }}/examples/dist + ${{ inputs.wasm-package-dir }}/docs if-no-files-found: error retention-days: 1 diff --git a/.github/workflows/upload-docs.yml b/.github/workflows/upload-docs.yml index a1579e29..db321840 100644 --- a/.github/workflows/upload-docs.yml +++ b/.github/workflows/upload-docs.yml @@ -8,6 +8,16 @@ on: version: description: "Version to publish docs under (e.g. `v1.2.3-dev.1`)" required: true + ref: + description: "Optional git ref to checkout before building docs" + required: false + product: + description: "Which product docs to publish" + required: true + type: choice + options: + - notarization + - audit-trail env: GH_TOKEN: ${{ github.token }} @@ -16,20 +26,32 @@ permissions: actions: "write" jobs: - build-wasm: + build-wasm-notarization: + if: ${{ github.event_name == 'release' || github.event.inputs.product == 'notarization' }} uses: "./.github/workflows/shared-build-wasm.yml" with: run-unit-tests: false ref: ${{ inputs.ref }} output-artifact-name: notarization-docs - upload-docs: + build-wasm-audit-trail: + if: ${{ github.event_name == 'release' || github.event.inputs.product == 'audit-trail' }} + uses: "./.github/workflows/shared-build-wasm.yml" + with: + run-unit-tests: false + ref: ${{ inputs.ref }} + output-artifact-name: audit-trail-docs + wasm-package-dir: bindings/wasm/audit_trail_wasm + wasm-crate-name: audit_trail_wasm + + upload-notarization-docs: runs-on: ubuntu-latest - needs: build-wasm + needs: [build-wasm-notarization] steps: - uses: actions/download-artifact@v4 with: name: notarization-docs + path: notarization-docs - name: Get release version id: get_release_version run: | @@ -42,12 +64,42 @@ jobs: echo VERSION=$VERSION >> $GITHUB_OUTPUT - name: Compress generated docs run: | - tar czvf wasm.tar.gz notarization/docs/* + tar czvf wasm.tar.gz notarization-docs/docs/* - - name: Upload docs to AWS S3 + - name: Upload notarization docs to AWS S3 env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_IOTA_WIKI }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_IOTA_WIKI }} AWS_DEFAULT_REGION: "eu-central-1" run: | aws s3 cp wasm.tar.gz s3://files.iota.org/iota-wiki/iota-notarization/${{ steps.get_release_version.outputs.VERSION }}/ --acl public-read + + upload-audit-trail-docs: + runs-on: ubuntu-latest + needs: [build-wasm-audit-trail] + steps: + - uses: actions/download-artifact@v4 + with: + name: audit-trail-docs + path: audit-trail-docs + - name: Get release version + id: get_release_version + run: | + if [ "${{ github.event_name }}" = "release" ]; then + INPUT_VERSION="${{ github.ref }}" + else + INPUT_VERSION="${{ github.event.inputs.version }}" + fi + VERSION=$(echo $INPUT_VERSION | sed -e 's/.*v\([0-9]*\.[0-9]*\).*/\1/') + echo VERSION=$VERSION >> $GITHUB_OUTPUT + - name: Compress generated docs + run: | + tar czvf audit-trail-wasm.tar.gz audit-trail-docs/docs/* + + - name: Upload audit trail docs to AWS S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_IOTA_WIKI }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_IOTA_WIKI }} + AWS_DEFAULT_REGION: "eu-central-1" + run: | + aws s3 cp audit-trail-wasm.tar.gz s3://files.iota.org/iota-wiki/iota-notarization/${{ steps.get_release_version.outputs.VERSION }}/ --acl public-read diff --git a/.github/workflows/wasm-publish.yml b/.github/workflows/wasm-publish.yml index 5cbee4e1..28be0b54 100644 --- a/.github/workflows/wasm-publish.yml +++ b/.github/workflows/wasm-publish.yml @@ -27,6 +27,13 @@ on: retag-tag: description: "RETAG - Tag to set" required: false + package: + description: "Which package to publish/retag" + required: true + type: choice + options: + - notarization + - audit-trail permissions: id-token: write # Required for OIDC @@ -63,8 +70,8 @@ jobs: check_vars retag_version retag_tag fi - build-wasm: - if: ${{ github.event.inputs.operation == 'publish' }} + build-wasm-notarization: + if: ${{ github.event.inputs.operation == 'publish' && github.event.inputs.package == 'notarization' }} needs: [check-inputs] uses: "./.github/workflows/shared-build-wasm.yml" with: @@ -72,10 +79,21 @@ jobs: ref: ${{ github.event.inputs.publish-branch }} output-artifact-name: notarization-wasm-bindings-build - release-wasm: - if: ${{ github.event.inputs.operation == 'publish' }} + build-wasm-audit-trail: + if: ${{ github.event.inputs.operation == 'publish' && github.event.inputs.package == 'audit-trail' }} + needs: [check-inputs] + uses: "./.github/workflows/shared-build-wasm.yml" + with: + run-unit-tests: false + ref: ${{ github.event.inputs.publish-branch }} + output-artifact-name: audit-trail-wasm-bindings-build + wasm-package-dir: bindings/wasm/audit_trail_wasm + wasm-crate-name: audit_trail_wasm + + release-wasm-notarization: + if: ${{ github.event.inputs.operation == 'publish' && github.event.inputs.package == 'notarization' }} runs-on: ubuntu-latest - needs: [build-wasm] + needs: [build-wasm-notarization] steps: - name: Checkout uses: actions/checkout@v4 @@ -89,6 +107,23 @@ jobs: working-directory: ./bindings/wasm/notarization_wasm tag: ${{ github.event.inputs.publish-tag }} + release-wasm-audit-trail: + if: ${{ github.event.inputs.operation == 'publish' && github.event.inputs.package == 'audit-trail' }} + runs-on: ubuntu-latest + needs: [build-wasm-audit-trail] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.publish-branch }} + - name: Release to npm + uses: "./.github/actions/publish/wasm" + with: + dry-run: ${{ github.event.inputs.publish-dry-run }} + input-artifact-name: audit-trail-wasm-bindings-build + working-directory: ./bindings/wasm/audit_trail_wasm + tag: ${{ github.event.inputs.publish-tag }} + retag-wasm: if: ${{ github.event.inputs.operation == 'retag' }} needs: [check-inputs] @@ -100,7 +135,14 @@ jobs: node-version: "lts/*" registry-url: "https://registry.npmjs.org" - - name: Run dist-tag + - name: Run dist-tag notarization + if: ${{ github.event.inputs.package == 'notarization' }} shell: bash run: | npm dist-tag add @iota/notarization@${{ github.event.inputs.retag-version }} ${{ github.event.inputs.retag-tag }} + + - name: Run dist-tag audit-trail + if: ${{ github.event.inputs.package == 'audit-trail' }} + shell: bash + run: | + npm dist-tag add @iota/audit-trails@${{ github.event.inputs.retag-version }} ${{ github.event.inputs.retag-tag }} diff --git a/.github/workflows/wasm-retag-npm.yml b/.github/workflows/wasm-retag-npm.yml index 3830bec7..4d4fabdb 100644 --- a/.github/workflows/wasm-retag-npm.yml +++ b/.github/workflows/wasm-retag-npm.yml @@ -9,6 +9,13 @@ on: version: description: "version to set" required: true + package: + description: "Which package to retag" + required: true + type: choice + options: + - notarization + - audit-trail jobs: release-wasm: @@ -21,9 +28,18 @@ jobs: node-version: "20.x" registry-url: "https://registry.npmjs.org" - - name: Run dist-tag + - name: Run dist-tag notarization + if: ${{ github.event.inputs.package == 'notarization' }} shell: sh env: NODE_AUTH_TOKEN: ${{ secrets.NPM_NOTARIZATION_TOKEN }} run: | npm dist-tag add @iota/notarization@${{ github.event.inputs.version }} ${{ github.event.inputs.tag }} + + - name: Run dist-tag audit-trail + if: ${{ github.event.inputs.package == 'audit-trail' }} + shell: sh + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_NOTARIZATION_TOKEN }} + run: | + npm dist-tag add @iota/audit-trails@${{ github.event.inputs.version }} ${{ github.event.inputs.tag }} diff --git a/.gitignore b/.gitignore index d29419b0..480833a5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,14 @@ *.code-workspace .idea +.history .DS_Store /notarization-move/build/* /bindings/wasm/notarization_wasm/docs/* +/bindings/wasm/audit_trail_wasm/docs/* # ignore folder created in CI for downloaded iota binaries /iota/ -/toml-cli/ \ No newline at end of file +/toml-cli/ +/audit-trail-move/build +/.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3786bb01 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,308 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +IOTA Notarization enables creation of immutable, on-chain records for arbitrary data by storing it (or a hash) in dedicated Move objects on the IOTA ledger. The workspace has two components: **Notarization** (creating tamper-proof records) and **Audit Trail** (structured, role-based audit logging). + +## Naming Conventions + +- Everything contained in this repository is part of the **Notarization Toolkit** - do not use synonyms like "Notarization Suite", + "Notarization SDK" or similar labels for the Notarization Toolkit. +- The Notarization Toolkit is part of the IOTA Trust Framework + - Use standard capitalization for the word `Toolkit` (includes "title case" where needed) - use "Toolkit" or "toolkit" + whatever suites into the context the best. In this stylguide `Toolkit` is used for referencing the term. Use "title case" + allways for `Notarization Toolkit` (never use `Notarization toolkit` or `notarization toolkit`). +- The IOTA Trust Framework consist of Trust Framework Products (TF products) +- The Notarization Toolkit contains two TF products: **Single Notarization** and **Audit Trails** + - In the context of Notarization Toolkit documentation, Single Notarization and Audit Trails are called components + - In the context of IOTA Trust Framework documentation, Single Notarization and Audit Trails are called TF products + - These rules also apply to future TF products in the Notarization Toolkit (i.e. "Proof of Inclusion") +- Regarding usage of singular and plural in TF product resp. Notarization Toolkit component names: + - If the product is meant itself: + - Use the product name (i.e. `Audit Trails`, `Notarization`) with singular form - example: "Audit Trails is the best ..." + - Use capitalization (a.k.a. title case) - examples `Audit Trails`, `Notarization` + - If multiple instances of a product (typically equivalent to multiple on-chain objects) are meant: + - Use plural (i.e. `Using multiple audit trails facilitates ...` or `Avoid creating too much notarizations for ...`) + - Use lower case for the plural form - except at the beginning of sentences and in markdown titles + - If the TF product or multiple product instances could be meant: Prefer the TF product variant if possible. Only + use the plural variant where clearly more suitable. + - This rule - including the capitalization aspects - only applies to TF products (resp. Toolkit components). Using the + plural form with title case for other entities like i.e. Notarization Methods (`Locked Notarization`, `Dynamic Notarization` - see below) is OK. + - If onchain objects of the TF product are addressed: + - In source code documentation related to the TF product specific Move object type (i.e. `AuditTrail`, `Notarization`), + the type name followed by "object" resp. "objects" shall be used (examples: "To create a `Notarization` object use ...", + "`AuditTrail` objects can be batch deleted using ..."). + - In less technical documentation, typically in the context of general descriptions of TF products or Notarization Toolkit components: + - the product name in singular of plural form shall be used (see above) without any extensions + - if the onchain object, equivalent to the product itself, is addressed, use either the Move object type based form (see above) + or the product name followed by "object" resp. "objects" (examples: "`Notarization` onchain objects facilitate ...", + "Audit Trails on-chain objects must be managed ... ") whatever is most suitable. +- Regarding Single Notarization (Component/TF product): + - Single Notarization provides two **Notarization Methods**: **Locked Notarization** and **Dynamic Notarization** + - There might be additional Notarization Methods in future versions of Single Notarization (i.e. "Custom Notarization") + - For Notarization Methods, the following can be used to describe or identify the method (whatever suites into the context the best): + - Short Name: `Locked`, `Dynamic`, ... + - Full Name: `Locked Notarization`, `Dynamic Notarization`, ... +- Each TF product/component provides packages for Move, Rust and WASM/TypeScript: + - Do not use terms like `toolkit`, `SDK`, ... for the packages - only use the term `Package` + - Use standard capitalization for the word `Package` (includes "title case" where needed) - use "Package" or "package" + whatever suites into the context the best. In this stylguide `Package` is used for referencing the term. + - Aspects regarding the use of `Package` for software development in general: + - For Move the term `Package` is allways used + - In Rust contexts, the term `Package` denotes a bundle of one or more crates containing a Cargo. toml file. Use the term + `Crate` and `Package` whatever suites into the context the best + - For WASM: + - The term `Package` can have two meanings: + - The WASM-Rust package containing the WASM binding code + - If the documentation refers to this aspect (i.e. explaining the existence of wasm bindings in the Rust package) + the term "wasm bindings" instead of `Package` is OK. + - The JS/TS package created out of the WASM-Rust binding code using wasm-bindgen + - In most contexts this doesn't need to be distinguished, so just use the term `Package` + +## Common Commands + +### Build & Check + +```bash +cargo build --workspace --tests --examples +cargo check -p notarization-rs +cargo check -p audit-trail-rs +``` + +### Test + +```bash +# Tests must run single-threaded (IOTA sandbox requirement) +cargo test --workspace --release -- --test-threads=1 + +# Single test +cargo test --release -p notarization-rs test_name -- --test-threads=1 + +# Move contract tests (from notarization-move/ or audit-trail-move/) +iota move test +``` + +### Lint & Format + +```bash +cargo clippy --all-targets --all-features +cargo fmt --all +cargo fmt --all -- --check # check only +``` + +### WASM Bindings (in bindings/wasm/notarization_wasm/ or audit_trail_wasm/) + +```bash +npm install +npm run build +npm test # Node.js tests +npm run test:browser # Cypress browser tests +``` + +### Move Scripts + +```bash +# From notarization-move/ or audit-trail-move/ +./scripts/publish_package.sh +./scripts/notarize.sh +``` + +### Running Examples + +Examples require the relevant Move package to be published first. + +**Notarization examples** — from the repo root: + +```bash +# Publish the package and capture the package ID +export IOTA_NOTARIZATION_PKG_ID=$(./notarization-move/scripts/publish_package.sh) + +# Run a specific example +cargo run --release --example +``` + +To run all notarization examples: + +```bash +# Make sure IOTA_NOTARIZATION_PKG_ID is set as shown above +./examples/run.sh +``` + +**Audit Trail examples** — from the repo root: + +```bash +# Publish the package; on localnet both vars are set to the same package ID +eval $(./audit-trail-move/scripts/publish_package.sh) + +# Run a specific example +cargo run --release --example +``` + +The `eval` form is required because the publish script prints shell `export` statements for two variables: + +- `IOTA_AUDIT_TRAIL_PKG_ID` — the Audit Trail package ID +- `IOTA_TF_COMPONENTS_PKG_ID` — the TfComponents package ID (equals `IOTA_AUDIT_TRAIL_PKG_ID` on localnet) + +## Developing Examples + +### Adding a new example + +1. Create the source file under `examples/notarization/` or `examples/audit-trail/`. +2. Add an `[[example]]` entry to `examples/Cargo.toml` pointing to the new file. +3. Use `examples::get_funded_notarization_client()` (notarization) or `examples::get_funded_audit_trail_client()` (Audit Trail) from `examples/utils/utils.rs` to obtain a funded, signed client. Do not inline client construction in example files. + +### Audit Trail example patterns + +Reference implementation: `examples/audit-trail/01_create_audit_trail.rs` + +**Client setup** — `get_funded_audit_trail_client()` reads `IOTA_AUDIT_TRAIL_PKG_ID` and `IOTA_TF_COMPONENTS_PKG_ID` from the environment and returns `AuditTrailClient`. + +**Creating a trail** — use the builder returned by `client.create_trail()`: + +```rust +let created = client + .create_trail() + .with_trail_metadata(ImmutableMetadata::new("name".into(), Some("description".into()))) + .with_updatable_metadata("mutable status string") + .with_initial_record(InitialRecord::new(Data::text("content"), Some("metadata".into()), None)) + .finish() + .build_and_execute(&client) + .await? + .output; // TrailCreated { trail_id, creator, timestamp } +``` + +The creator automatically receives an Admin capability object in their wallet. + +**Defining a role** — use the trail handle's access API with the implicit Admin capability: + +```rust +client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&client) + .await?; +``` + +`PermissionSet` convenience constructors: `admin_permissions()`, `record_admin_permissions()`, `role_admin_permissions()`, `locking_admin_permissions()`, `tag_admin_permissions()`, `cap_admin_permissions()`, `metadata_admin_permissions()`. + +**Issuing a capability** — mint a capability object for a role: + +```rust +let cap = client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await? + .output; // CapabilityIssued { capability_id, target_key, role, issued_to, valid_from, valid_until } +``` + +Use `CapabilityIssueOptions { issued_to, valid_from_ms, valid_until_ms }` to restrict who may use the capability or set a validity window. + +**Key types** (from `audit_trails::core::types`): `Data`, `InitialRecord`, `ImmutableMetadata`, `LockingConfig`, `LockingWindow`, `TimeLock`, `Permission`, `PermissionSet`, `CapabilityIssueOptions`, `RoleTags`. + +### Notarization example patterns + +Reference implementations: `examples/notarization/01_create_locked_notarization.rs` and `examples/notarization/02_create_dynamic_notarization.rs`. + +Use `examples::get_funded_notarization_client()` to get a `NotarizationClient`. Read `audit-trail-rs/tests/e2e/` for detailed usage of every API surface. + +## Workspace Structure + +The root `Cargo.toml` defines a workspace with members: `notarization-rs`, `audit-trail-rs`, `examples`. The WASM crates (`bindings/wasm/*`) are excluded from the workspace and built separately. + +- **`notarization-rs/`** — Rust client library for notarization +- **`notarization-move/`** — Move smart contracts for notarization +- **`audit-trail-rs/`** — Rust client library for Audit Trail +- **`audit-trail-move/`** — Move smart contracts for Audit Trail +- **`bindings/wasm/notarization_wasm/`** — JS/TS WASM bindings for notarization +- **`bindings/wasm/audit_trail_wasm/`** — JS/TS WASM bindings for Audit Trail +- **`examples/`** — Rust examples (basic CRUD + real-world scenarios like IoT, legal contracts) + +When performing checks and edits always ignore the folders and files defined in the `.gitignore` file. + +## Architecture + +### Client Layer Pattern + +Both `notarization-rs` and `audit-trail-rs` follow the same pattern: + +- **Full client** (`NotarizationClient` / `AuditTrailClient`): Signs and submits transactions +- **Read-only client** (`NotarizationClientReadOnly` / `AuditTrailClientReadOnly`): Read-only state inspection +- Clients wrap a `product_common` transaction builder that supports `.build()`, `.build_and_execute()`, and `.execute_with_gas_station()` + +### Builder Pattern (Type-State) + +Notarization creation uses a `NotarizationBuilder` with phantom type states to enforce valid configurations at compile time. Separate builder paths exist for **Dynamic** (mutable, transferable) vs **Locked** (immutable, non-transferable) notarizations. + +### Method Types + +- **Dynamic**: State and metadata are updatable after creation; supports transfer locks +- **Locked**: State and metadata are immutable; supports time-based destruction + +### Lock System + +- **Transfer locks**: `None`, `UnlockAt(epoch)`, `UntilDestroyed` +- **Delete locks**: Restrict when a notarization can be destroyed + +### Cross-Platform Compilation + +Code uses `#[cfg(target_arch = "wasm32")]` guards to conditionally compile for WASM. Features `send-sync`, `gas-station`, `default-http-client`, and `irl` control optional capabilities. + +### Derived Permission Sets (Audit Trails) + +The permission-set convenience constructors (`admin_permissions()`, `record_admin_permissions()`, +`role_admin_permissions()`, …) exist in all three layers and each layer's doc comment enumerates +the permissions the set contains. The Move and Rust implementations are **independent** and must +return the same set; the WASM implementation delegates to Rust, but its doc list does not follow +automatically. When adding a `Permission` variant or changing any `*_permissions()` set: + +1. Update the Move constructor implementation **and** its doc bullet list (`permission.move`). +2. Mirror the change in the Rust `PermissionSet` constructor implementation **and** its doc list. +3. Update the enumerated doc list of the matching `WasmPermissionSet` constructor. + +Doc lists must always be verified against the implementation of their own layer, not copied +doc-to-doc — doc lists can agree across layers and still all be stale. + +### Event Types Across Layers + +Every public Move event struct (`public struct has copy, drop`) must have counterparts in +the other two layers of the same product: + +1. A Rust deserialization struct in `-rs/src/core/types/event.rs` with matching field + names, following the existing patterns there (e.g. `deserialize_number_from_string` for + string-encoded `u64` timestamps in Audit Trails). +2. A WASM payload type (`Wasm` with `#[wasm_bindgen]`) plus a `From<>` impl in the + bindings' `types.rs`. +3. A section in the product's `api_mapping.toml` mapping the Move event to both counterparts + (use the `update-api-mapping` skill). + +Every function that emits the event must document the emission in all three layers: the +``Emits a `` event on success.`` paragraph in Move (see `MOVE-DOC-STYLEGUIDE.md`), prose in +the Rust transaction-type doc ("On success a `` event is emitted."), and the +`Emits a {@link } event on success.` line in WASM (see `bindings/wasm/DOC-STYLEGUIDE.md`) +on both the transaction wrapper type and the handle method. + +### Key External Dependencies + +- `iota-sdk` (v1.19.1, from IOTA git) — on-chain interaction +- `iota_interaction` / `iota_interaction_rust` / `iota_interaction_ts` — from `product-core` repo, `feat/tf-compoenents-dev` branch +- `product_common` — transaction builder abstraction from `product-core` +- `secret-storage` (v0.3.0) — key management + +## Testing Requirements + +- Tests require an IOTA sandbox running locally +- Always use `--test-threads=1` (tests share sandbox state) +- Notarization examples require `IOTA_NOTARIZATION_PKG_ID` environment variable set to the deployed package ID +- Audit trail examples require `IOTA_AUDIT_TRAIL_PKG_ID` (and `IOTA_TF_COMPONENTS_PKG_ID` on localnet) — use `eval $(./audit-trail-move/scripts/publish_package.sh)` to set both +- WASM browser tests use Cypress + +## Rust Version + +Minimum: **1.85**, Edition: **2024** diff --git a/Cargo.toml b/Cargo.toml index b965e475..b59a6040 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,27 +8,27 @@ rust-version = "1.85" [workspace] resolver = "2" -members = ["examples", "notarization-rs"] -exclude = ["bindings/wasm/notarization_wasm"] +members = ["audit-trail-rs", "examples", "notarization-rs"] +exclude = ["bindings/wasm/notarization_wasm", "bindings/wasm/audit_trail_wasm"] [workspace.dependencies] anyhow = "1.0" async-trait = "0.1" bcs = "0.1" +chrono = { version = "0.4", default-features = false } hyper = "1" iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.23.2" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.18", default-features = false, package = "iota_interaction" } -iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.18", default-features = false, package = "iota_interaction_rust" } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.18", default-features = false, package = "iota_interaction_ts" } -product_common = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.18", default-features = false, package = "product_common" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.19", default-features = false, package = "iota_interaction" } +iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.19", default-features = false, package = "iota_interaction_rust" } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.19", default-features = false, package = "iota_interaction_ts" } +product_common = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.19", default-features = false, package = "product_common" } +secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } +serde-aux = { version = "4.7.0", default-features = false } serde_json = { version = "1.0", default-features = false } +sha2 = { version = "0.10", default-features = false } strum = { version = "0.27", default-features = false, features = ["std", "derive"] } thiserror = { version = "2.0", default-features = false } - -chrono = { version = "0.4", default-features = false } -secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } -sha2 = { version = "0.10", default-features = false } tokio = { version = "1.52.2", default-features = false, features = ["macros", "sync", "rt", "process"] } [profile.release.package.iota_interaction_ts] diff --git a/DOC-SKILL-EXAMPLES.md b/DOC-SKILL-EXAMPLES.md new file mode 100644 index 00000000..c45b960b --- /dev/null +++ b/DOC-SKILL-EXAMPLES.md @@ -0,0 +1,156 @@ +# Skill examples + +Practical examples for the three project-local skills under `.claude/skills/`. +Each skill is invoked by typing its slash-command in a Claude Code session +(e.g. `/init-api-mapping`) or by asking in natural language — the descriptions +below show both. + +The three skills work together: + +``` +init-api-mapping ─► update-api-mapping ─► sync-product-docs + (bootstrap) (reconcile API) (propagate docs) +``` + +Inputs every skill needs (it will try to guess from your cwd, but always +validate the guesses): + +- `move-sc-path` — e.g. `notarization-move/sources` +- `rust-crate-path` — e.g. `notarization-rs/src` +- `wasm-bindings-path` — e.g. `bindings/wasm/notarization_wasm/src` + +--- + +## 1. `init-api-mapping` — bootstrap a new product's mapping + +Creates `/../api_mapping.toml` with the standard header, then +delegates to `update-api-mapping` to populate every section from `HEAD`. + +**When to use:** the product has Move/Rust/WASM code but no `api_mapping.toml` yet. + +### Example prompts + +```text +/init-api-mapping +``` + +```text +Bootstrap the api_mapping for a new product called "credentials". +Its Move sources are in credentials-move/sources, the Rust crate is +credentials-rs/src, and the WASM bindings live in +bindings/wasm/credentials_wasm/src. +``` + +```text +We have a new TF product under audit-log-move/. Please create its +api_mapping.toml. +``` + +### Expected outcome + +- `/api_mapping.toml` exists with the canonical header + comment and one section per public Move entity, each with its Rust and + WASM/TS counterparts pre-populated. +- The skill refuses (rather than overwriting) if the file already exists. + +--- + +## 2. `update-api-mapping` — reconcile after API changes + +Diffs the working tree against a base revision and updates the `rust` / +`wasm` arrays of each entry. Adds new sections, removes entries for deleted +Move entities, and reports anything ambiguous. + +**When to use:** public Move/Rust/WASM API changed since the last mapping update. + +### Example prompts + +```text +/update-api-mapping +``` + +```text +Update the notarization api_mapping against origin/main. +``` + +```text +I just renamed two Move functions in audit-trail-move/sources/record.move. +Please reconcile audit-trail-move/api_mapping.toml against HEAD~5. +``` + +```text +A few public WASM bindings were removed. Update the notarization +api_mapping.toml using origin/feat/audit-trails-dev as the base. +``` + +### Expected outcome + +- New public Move entities appear as new `[..]` + sections, with `rust = [...]` / `wasm = [...]` populated where matches + exist and `[]` where they intentionally don't. +- Entries for removed Move entities are dropped. +- A short report lists ambiguous matches that need human judgment. + +--- + +## 3. `sync-product-docs` — propagate Move docs to Rust and WASM/TS + +Walks `api_mapping.toml` and aligns the doc comments of each (Move, Rust, +WASM) triplet. The Move layer is authoritative; Rust and WASM/TS docs are +edited to convey the same contract in their respective styles (rustdoc / +TSDoc). + +**When to use:** Move doc comments changed and the Rust/WASM/TS layers +need to be brought back in line — or you want a drift report. + +### Example prompts + +```text +/sync-product-docs +``` + +```text +I just rewrote the doc comments on notarization-move/sources/notarization.move. +Please sync notarization-rs and notarization_wasm to match, using +notarization-move/api_mapping.toml. +``` + +```text +Check the audit-trail docs for drift against the Move sources — report +where the three layers disagree but don't edit anything yet. +``` + +```text +Sync the notarization doc comments end-to-end (Move → Rust → WASM/TS), +following the styleguides referenced from notarization-move/CLAUDE.md and +bindings/wasm/notarization_wasm/CLAUDE.md. +``` + +### Expected outcome + +- Rust doc comments (rustdoc) and WASM doc comments (TSDoc) are updated + to mirror the Move semantics for every entity listed in the mapping. +- Per-method bullet lists, abort/error sections, and `Emits …` lines are + rendered in the form prescribed by `MOVE-DOC-STYLEGUIDE.md` and + `bindings/wasm/DOC-STYLEGUIDE.md`. +- `cargo check`, `cargo doc`, and `cargo check --target wasm32-unknown-unknown` + all still pass. + +--- + +## Typical end-to-end workflow + +```text +# 1. Move API changed on a branch +/update-api-mapping # base revision: origin/main + +# 2. After Move doc comments are rewritten +/sync-product-docs # propagates Move docs into Rust + WASM/TS +``` + +For a brand-new product: + +```text +/init-api-mapping # bootstrap the mapping (delegates to update-api-mapping) +/sync-product-docs # initial pass across the three layers +``` diff --git a/MOVE-DOC-STYLEGUIDE.md b/MOVE-DOC-STYLEGUIDE.md new file mode 100644 index 00000000..0305b83f --- /dev/null +++ b/MOVE-DOC-STYLEGUIDE.md @@ -0,0 +1,262 @@ +# MOVE-DOC-STYLEGUIDE.md — Move documentation style guide + +This file is the canonical style guide for `///` doc comments on every Move +item in Move package implementations of IOTA Trust Framework products (TF-product). +Apply it whenever you add or edit a doc comment in one of these packages. + +The style guides needs to be referenced in the `CLAUDE.md` file of the respective +Move project folder like this: + +```markdown +## Documentation Style Guide + +Follow the guidelines in `relative/path/to/MOVE-DOC-STYLEGUIDE.md` and make sure to +follow all rules stated there. +``` + +Product specific explanations e.g. regarding used access control (i.e. `RoleMap` based) +should follow the above paragraph. + +The goal is that a reader of the on-chain Move source — and of the Rust and +WASM/TS bindings generated from it (see `api_mapping.toml`) — can understand +each function's contract without having to read the function body or chase +across modules. + +## Doc-comment syntax + +- Use `///` line comments only. Do not use `/** ... */`. +- Place the doc comment on the lines immediately preceding the item it + documents, with no blank line in between. +- Use Markdown inside doc comments; doc-tools render it. +- Wrap lines at roughly 100 columns. Continuation lines of a Markdown bullet + are indented two spaces under the `*`. +- Ignore existing line comments using `//` as these comments are dedicated for developers of the + product and don't need to apply to this style guide. + +## Function doc structure + +Every public function (`public fun` and `public(package) fun`) and every +`entry fun` follows this structure. Each section is a separate paragraph +separated from neighbours by an empty `///` line. + +1. **Summary sentence** (mandatory) — a single short sentence describing what + the function does. Use present tense, third-person ("Creates …", + "Returns …", "Checks whether …"). Do not begin with the function name. +2. **Behaviour paragraphs** (optional) — one or more paragraphs describing + internal behaviour, preconditions, postconditions, invariants, and + cross-references to related functions. Include only what a caller needs + beyond the summary. +3. **`Requires …` paragraph** — required when the function gates on a + capability/permission. Phrase as "Requires a capability granting the + `` permission." When multiple roles or conditions apply, + list them in one sentence or a short bullet list. +4. **`Aborts with:` paragraph** — required when the function can abort. + Format as a Markdown bullet list of every abort cause (see next + section). +5. **`Emits …` paragraph** — required when the function emits one or more + events. Phrase as "Emits a `` event on success." For multiple + events, "Emits `` and `` …" or one bullet per event. +6. **`Returns …` paragraph** — required when the function has a non-trivial + return. For trivial getters whose summary already says "Returns the X." + the explicit `Returns …` paragraph may be omitted. + +The order is fixed: summary → behaviour → Requires → Aborts → Emits → +Returns. Only include the sections that apply. + +### Trivial getters and constructors + +A function whose summary already conveys everything (e.g. a one-line getter +returning a stored field, or a one-line constructor wrapping an enum +variant) does not need any further sections. Keep it as a single short +sentence. + +```move +/// Returns the address that created this foo-bar object. +public fun creator(self: &FooBar): address { ... } +``` + +## `Aborts with:` formatting + +List every abort cause as a Markdown bullet. The intro line is +`/// Aborts with:` on its own. Each bullet is `/// * .` and ends with +a full stop. Continuation lines of a bullet are indented two spaces under +the `*`. + +```move +/// Aborts with: +/// * `EPackageVersionMismatch` when the foo-bar object is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `EFooBarAlreadyDefined` when `foo-bar` is already in the registry. +``` + +Conventions inside bullets: + +- Identify the abort by the **error constant name** in backticks + (`` `EFoo` ``). For external errors use the fully qualified path + (`` `tf_components::capability::EValidityPeriodInconsistent` ``). +- Use lower-case prose for the condition: ``* `EFoo` when ...``. +- For aborts that bubble up from a delegated call, reference the + authoritative abort list rather than re-listing every variant. Example: + ``* any error documented by `RoleMap::assert_capability_valid` when `cap` + fails authorization checks.`` +- Order bullets from most general to most specific: package-version checks + first, then capability/permission validation, then function-specific + causes. + +## `Requires …` formatting + +A single sentence in the active voice referring to the capability: + +```move +/// Requires a capability granting the `AddRecord` permission. +``` + +When the function requires additionally authorization append it to the +same sentence: + +The following example applies to IOTA Audit Trail and demonstrates an additional +optional required tag, granted by the capability to add records to the trail: + +```move +/// Requires a capability granting the `AddRecord` permission and, when +/// `record_tag` is set, a role whose `RoleTags` allow that tag. +``` + +## `Emits …` formatting + +```move +/// Emits a `FooAdded` event on success. +``` + +For batch operations: + +```move +/// Emits one `FooDeleted` event per deletion. +``` + +For multiple distinct events use a bullet list with the same convention as +`Aborts with:`. + +## `Returns …` formatting + +For functions whose summary does not already describe the result: + +```move +/// Returns the constructed `FooBarConfig`. +``` + +For tuples, name the components: + +Example regarding [RoleMap](https://github.com/iotaledger/product-core/blob/main/components_move/sources/role_map.move) initialization: + +```move +/// Returns the tuple `(role_map, admin_cap)`: the role_map object +/// and the initial admin `Capability`. +``` + +For `Option` returns, document both branches: + +```move +/// Returns `option::some(bytes)` when `data` is `Data::Bytes`, otherwise +/// `option::none()`. +``` + +## Cross-references and identifiers + +- Refer to types, functions, fields, and constants in backticks. Use + `Type::method` for methods, `Type::Variant` for enum variants, + `Type.field` for fields, and `module::function` for free functions. +- When a wrapper delegates to a function in another module, link by name + (`` `RoleMap::assert_capability_valid` ``) instead of duplicating its + abort list. The reader can follow the reference; we don't drift out of + sync. +- Inside the same module, omit the module prefix + (`` `add_record` `` rather than `` `audit_trails::main::add_record` ``). +- Refer to permission variants by their bare enum name in backticks + (`` `AddFoo` `` rather than `` `Permission::AddFoo` ``) — the + context makes the type unambiguous and matches the permission constants + emitted by helper constructors. +- Units must be explicit when stating timestamps: "milliseconds since the + Unix epoch" or "seconds". Do not write "ms" or "s" as a bare suffix. + +## Tone and wording + +- Present tense, third-person, active voice. +- Begin with a verb: "Creates …", "Returns …", "Removes …", "Checks + whether …". +- Avoid hedging ("usually", "tries to", "may"). State invariants directly. +- Do not write the same fact twice. If the summary already carries the + information, do not repeat it under `Returns`. +- Do not document what is obvious from a short, well-named function — a + trivial getter does not need an `Aborts` section saying it cannot abort. + +## Struct, enum, constant, and event docs + +- Document each public struct, enum, and event with a short summary + sentence above the definition. +- Document each public field of a struct with a `///` line above the field. + Field docs follow the same brevity rules as function summaries. +- Error constants (`#[error] const E…`) carry the user-facing abort message; + no separate doc comment is required when the message is self-explanatory. +- Module-level docs (the `///` block above `module audit_trails::…;`) must + describe the module's purpose in one or two sentences. + +## Don'ts + +- Don't add `Parameters:` / `Arguments:` sections — Move parameter names + serve as their own documentation; describe their meaning in the relevant + paragraph instead. +- Don't add `Notes:` or `Warning:` headings — write a behaviour paragraph + instead. Genuine warnings about destructive or irreversible operations + may use an inline `WARNING:` prefix on a paragraph. +- Don't quote the abort message string. Reference the error constant name. +- Don't number bullet points unless ordering is meaningful. +- Don't add a doc comment that simply restates the function name in + English ("Get the foo-bar creator address"). Either add value or omit. +- Don't edit or remove already existing line comments starting with `//` (only two slashes instead of three). + +## Worked example + +Example taken from IOTA Audit Trail: + +```move +/// Adds a record to the trail at the next available sequence number. +/// +/// Records are appended sequentially with auto-assigned sequence numbers. +/// When `record_tag` is set, the trail's tag-registry usage count for that +/// tag is incremented. +/// +/// Requires a capability granting the `AddRecord` permission and, when +/// `record_tag` is set, a role whose `RoleTags` allow that tag. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `ETrailWriteLocked` while `write_lock` is active. +/// * `ERecordTagNotDefined` when `record_tag` is not in the trail's tag registry. +/// * `ERecordTagNotAllowed` when `cap`'s role does not allow `record_tag`. +/// +/// Emits a `RecordAdded` event on success. +public fun add_record( + self: &mut AuditTrail, + cap: &Capability, + stored_data: D, + record_metadata: Option, + record_tag: Option, + clock: &Clock, + ctx: &mut TxContext, +) { ... } +``` + +## When in doubt + +The Move sources are the source of truth for the audit-trail product's +behaviour. Doc comments must reflect the on-chain contract precisely: +arguments, return semantics, abort/error conditions, emitted events, +authorization requirements, and locking/timing constraints. + +If the code and the doc disagree, fix the doc — and verify the discrepancy +is not also present in the Rust and WASM/TS layers (see the +`sync-audit-trail-docs` skill). diff --git a/README.md b/README.md index 0efb11a3..bbb3ab70 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@

Introduction ◈ + Where To Start ◈ + Toolkit ComponentsDocumentation & ResourcesBindingsContributing @@ -16,41 +18,107 @@ --- -# IOTA Notarization +# IOTA Notarization Toolkit ## Introduction -IOTA Notarization enables the creation of immutable, on-chain records for any arbitrary data. This is achieved by storing the data, or a hash of it, inside a dedicated Move object on the IOTA ledger. This process provides a verifiable, timestamped proof of the data's existence and integrity at a specific point in time. +This repository contains the IOTA Notarization Toolkit, a set of IOTA ledger tools for verifiable on-chain data workflows. -IOTA Notarization is composed of two primary components: +The toolkit includes: -- **Notarization Move Package**: The on-chain smart contracts that define the behavior and structure of notarization objects. -- **Notarization Library (Rust/Wasm)**: A client-side library that provides developers with convenient functions to create, manage, and verify `Notarization` objects on the network. +- **Single Notarization** + Use this for individual locked or dynamic notarizations of arbitrary data, documents, hashes, or latest-state records. +- **Audit Trails** + Use this for structured record histories with sequential entries, role-based access control, locking, and tagging. -## Documentation and Resources +Each toolkit component is available as: -- [Notarization Documentation Pages](https://docs.iota.org/developer/iota-notarization): Supplementing documentation with context around notarization and simple examples on library usage. -- API References: - - [Rust API Reference](https://iotaledger.github.io/notarization/notarization/index.html): Package documentation (cargo docs). +- **Move Package** for the on-chain contracts +- **Rust Package** for typed client access and transaction builders +- **TypeScript/JS Package** using wasm bindings for the above-mentioned Rust package - +## Where To Start -- Examples: - - [Rust Examples](https://github.com/iotaledger/notarization/tree/main/examples/README.md): Practical code snippets to get you started with the library in Rust. - - [Wasm Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm/examples/README.md): Practical code snippets to get you started with the library in TypeScript/JavaScript. +### I want a single notarized record + +Use **Single Notarization** when your main need is proving the existence, integrity, or latest state of one notarized object on-chain. + +- [Single Notarization Rust Package](./notarization-rs) +- [Single Notarization Move Package](./notarization-move) +- [Single Notarization Wasm Package](./bindings/wasm/notarization_wasm) +- [Single Notarization examples](./bindings/wasm/notarization_wasm/examples/README.md) + +### I want an audit trail + +Use **Audit Trails** when you need a structured record history with permissions, capabilities, tagging, and write or delete controls. + +- [Audit Trails Rust Package](./audit-trail-rs) +- [Audit Trails Move Package](./audit-trail-move) +- [Audit Trails Wasm Package](./bindings/wasm/audit_trail_wasm) +- [Audit Trails examples](./bindings/wasm/audit_trail_wasm/examples/README.md) + +### I want the on-chain contracts + +- [Single Notarization Move](./notarization-move) +- [Audit Trails Move](./audit-trail-move) + +### I want to build an application + +- [Single Notarization Rust](./notarization-rs) +- [Audit Trails Rust](./audit-trail-rs) +- [Single Notarization Wasm](./bindings/wasm/notarization_wasm) +- [Audit Trails Wasm](./bindings/wasm/audit_trail_wasm) + +## Toolkit Components + +| Component | Best for | Move Package | Rust Package | Wasm Package | +| ------------------- | --------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------- | -------------------------------------------------------- | +| Single Notarization | Individual locked or dynamic notarizations for documents, hashes, and state | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`notarization_wasm`](./bindings/wasm/notarization_wasm) | +| Audit Trails | Shared sequential records with roles, capabilities, tagging, and locking | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | + +### Which one should I use? + +| Need | Best fit | +| ------------------------------------------------------------------------- | ------------------- | +| Locked proof object for arbitrary data | Single Notarization | +| Dynamic latest-state notarization flow | Single Notarization | +| Shared sequential records with roles, capabilities, and record tag policy | Audit Trails | +| Team or system audit log with governance and operational controls | Audit Trails | + +## Documentation And Resources + +### Single Notarization + +- [Single Notarization Rust Package README](./notarization-rs/README.md) +- [Single Notarization Move Package README](./notarization-move/README.md) +- [Single Notarization Wasm Package README](./bindings/wasm/notarization_wasm/README.md) +- [Single Notarization examples](./bindings/wasm/notarization_wasm/examples/README.md) +- [IOTA Notarization Docs Portal](https://docs.iota.org/developer/iota-notarization) + +### Audit Trails + +- [Audit Trails Rust Package README](./audit-trail-rs/README.md) +- [Audit Trails Move Package README](./audit-trail-move/README.md) +- [Audit Trails Wasm Package README](./bindings/wasm/audit_trail_wasm/README.md) +- [Audit Trails examples](./bindings/wasm/audit_trail_wasm/examples/README.md) + +### Shared + +- [Repository examples](./examples/README.md) ## Bindings -[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) Bindings of this [Rust](https://www.rust-lang.org/) library to other programming languages: +[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) bindings available in this repository: -- [Web Assembly](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm) (JavaScript/TypeScript) +- [Web Assembly for Single Notarization](./bindings/wasm/notarization_wasm) +- [Web Assembly for Audit Trails](./bindings/wasm/audit_trail_wasm) ## Contributing -We would love to have you help us with the development of IOTA Notarization. Each and every contribution is greatly valued! +We would love to have you help us with the development of the IOTA Notarization Toolkit. Each and every contribution is greatly valued. Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). -To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included! +To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included. -The best place to get involved in discussions about this library or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). +The best place to get involved in discussions about these libraries or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). diff --git a/audit-trail-move/.prettierignore b/audit-trail-move/.prettierignore new file mode 100644 index 00000000..a007feab --- /dev/null +++ b/audit-trail-move/.prettierignore @@ -0,0 +1 @@ +build/* diff --git a/audit-trail-move/.prettierrc b/audit-trail-move/.prettierrc new file mode 100644 index 00000000..0ceb3060 --- /dev/null +++ b/audit-trail-move/.prettierrc @@ -0,0 +1,8 @@ +{ + "tabWidth": 4, + "printWidth": 100, + "useModuleLabel": true, + "autoGroupImports": "package", + "enableErrorDebug": false, + "wrapComments": false +} \ No newline at end of file diff --git a/audit-trail-move/CLAUDE.md b/audit-trail-move/CLAUDE.md new file mode 100644 index 00000000..1768aed7 --- /dev/null +++ b/audit-trail-move/CLAUDE.md @@ -0,0 +1,10 @@ +# CLAUDE.md — Guidelines for `audit-trail-move` + +## Documentation Style Guide + +Follow the guidelines in `../MOVE-DOC-STYLEGUIDE.md` and make sure to +follow all rules stated there. + +Note: `audit-trail-move` uses [RoleMap based access control](https://github.com/iotaledger/product-core/blob/main/components_move/sources/role_map.move)) +so make sure - despite all other rules described there - to add propper +`Requires …` paragraphs. diff --git a/audit-trail-move/Move.history.json b/audit-trail-move/Move.history.json new file mode 100644 index 00000000..04e94921 --- /dev/null +++ b/audit-trail-move/Move.history.json @@ -0,0 +1,10 @@ +{ + "aliases": { + "testnet": "2304aa97" + }, + "envs": { + "2304aa97": [ + "0x7655d346145e2ba7fcb6a5c63b4b9ec18a92c435364206e5c3f3dfd8cb95d98d" + ] + } +} \ No newline at end of file diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock new file mode 100644 index 00000000..0d29a698 --- /dev/null +++ b/audit-trail-move/Move.lock @@ -0,0 +1,73 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 3 +manifest_digest = "EBCB35B368C39FD9E190F502DC6A07A51CF87960E25EC4439DCBF9FBA8307B3C" +deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "IotaSystem", name = "IotaSystem" }, + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Stardust", name = "Stardust" }, + { id = "TfComponents", name = "TfComponents" }, +] + +[[move.package]] +id = "Iota" +source = { git = "https://github.com/iotaledger/iota.git", rev = "b1b37ed9d5ff64cbbfb3aa1ebd9b9431a0337311", subdir = "crates/iota-framework/packages/iota-framework" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, +] + +[[move.package]] +id = "IotaSystem" +source = { git = "https://github.com/iotaledger/iota.git", rev = "b1b37ed9d5ff64cbbfb3aa1ebd9b9431a0337311", subdir = "crates/iota-framework/packages/iota-system" } + +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "MoveStdlib", name = "MoveStdlib" }, +] + +[[move.package]] +id = "MoveStdlib" +source = { git = "https://github.com/iotaledger/iota.git", rev = "b1b37ed9d5ff64cbbfb3aa1ebd9b9431a0337311", subdir = "crates/iota-framework/packages/move-stdlib" } + +[[move.package]] +id = "Stardust" +source = { git = "https://github.com/iotaledger/iota.git", rev = "b1b37ed9d5ff64cbbfb3aa1ebd9b9431a0337311", subdir = "crates/iota-framework/packages/stardust" } + +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "MoveStdlib", name = "MoveStdlib" }, +] + +[[move.package]] +id = "TfComponents" +source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } + +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "IotaSystem", name = "IotaSystem" }, + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Stardust", name = "Stardust" }, +] + +[move.toolchain-version] +compiler-version = "1.20.1" +edition = "2024.beta" +flavor = "iota" + +[env] + +[env.localnet] +chain-id = "4c9c65c9" +original-published-id = "0x567c1e6c76db3b47826019f15818c747e0a2588d428e00c13863bcf683ec5bbe" +latest-published-id = "0x567c1e6c76db3b47826019f15818c747e0a2588d428e00c13863bcf683ec5bbe" +published-version = "1" + +[env.testnet] +chain-id = "2304aa97" +original-published-id = "0x7655d346145e2ba7fcb6a5c63b4b9ec18a92c435364206e5c3f3dfd8cb95d98d" +latest-published-id = "0x7655d346145e2ba7fcb6a5c63b4b9ec18a92c435364206e5c3f3dfd8cb95d98d" +published-version = "1" diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml new file mode 100644 index 00000000..0596d836 --- /dev/null +++ b/audit-trail-move/Move.toml @@ -0,0 +1,9 @@ +[package] +name = "IotaAuditTrails" +edition = "2024.beta" + +[dependencies] +TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "v0.8.19" } + +[addresses] +audit_trails = "0x0" diff --git a/audit-trail-move/README.md b/audit-trail-move/README.md new file mode 100644 index 00000000..844aad3e --- /dev/null +++ b/audit-trail-move/README.md @@ -0,0 +1,90 @@ +![banner](https://github.com/iotaledger/notarization/raw/HEAD/.github/banner_notarization.png) + +

+ StackExchange + Discord + Apache 2.0 license +

+ +

+ Introduction ◈ + Modules ◈ + Development & Testing ◈ + Related Libraries ◈ + Contributing +

+ +--- + +# IOTA Audit Trails Move Package + +## Introduction + +`IotaAuditTrails` is the on-chain Move package behind IOTA Audit Trails. + +It defines the shared `AuditTrail` object and the supporting types needed for: + +- sequential record storage +- role-based access control through capabilities +- trail-wide locking for writes and deletions +- record tags and role tag restrictions +- immutable and updatable trail metadata +- emitted events for trail and record lifecycle changes + +The package depends on `TfComponents` for reusable capability, role-map, and timelock primitives. + +## Modules + +- `audit_trails::main` + Core shared object, events, trail lifecycle, record mutation, metadata updates, roles, and capabilities. +- `audit_trails::record` + Record payloads, initial records, and correction metadata. +- `audit_trails::locking` + Locking configuration and lock evaluation helpers. +- `audit_trails::permission` + Permission constructors and admin permission presets. +- `audit_trails::record_tags` + Tag registry and role tag helpers. + +## Development And Testing + +Build the Move package: + +```bash +cd audit-trail-move +iota move build +``` + +Run the Move test suite: + +```bash +cd audit-trail-move +iota move test +``` + +Publish locally: + +```bash +cd audit-trail-move +./scripts/publish_package.sh +``` + +The publish script prints `IOTA_AUDIT_TRAIL_PKG_ID` and, on `localnet`, also exports `IOTA_TF_COMPONENTS_PKG_ID`. + +The package history files [`Move.lock`](./Move.lock) and [`Move.history.json`](./Move.history.json) are used by the Rust crate to resolve and track deployed package versions. + +## Related Libraries + +- [Rust Package](https://github.com/iotaledger/notarization/tree/main/audit-trail-rs/README.md) +- [Wasm Package](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/README.md) +- [Repository Root](https://github.com/iotaledger/notarization/tree/main/README.md) + +## Contributing + +We would love to have you help us with the development of IOTA Audit Trails. Each and every contribution is greatly valued. + +Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). + +To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included. + +The best place to get involved in discussions about this package or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). diff --git a/audit-trail-move/api_mapping.toml b/audit-trail-move/api_mapping.toml new file mode 100644 index 00000000..1cb31926 --- /dev/null +++ b/audit-trail-move/api_mapping.toml @@ -0,0 +1,1280 @@ +# Audit Trail API Mapping +# +# Maps each public Move function or struct in the `audit-trail-move/sources/` modules +# to the related Rust entities in `audit-trail-rs/src/` and WASM/TS entities in +# `bindings/wasm/audit_trail_wasm/src/`. +# +# TOML section keys are formed as `..`: +# - `` — the product identifier, derived from the basename of the +# Move package directory with any trailing `-move` stripped and `-` +# replaced by `_`. For this file: `audit_trail`. +# - `` — `main` for the Move source file whose basename matches +# the product name (`audit_trail.move` → `main`); for any other Move +# source file, the bare filename without extension (e.g. `locking.move` +# → `locking`). +# - `` — the function name or struct/enum/const name in that module. +# +# `rust` and `wasm` arrays list the Rust- resp. WASM-level functions, methods, +# and types that wrap, build, or otherwise correspond to the Move entity. +# Entry conventions: +# - `Type::method` — an inherent method on `Type` +# - `Type::Variant` — an enum variant +# - `Type` — a plain type/struct/enum +# - `Type.field` — a struct field +# - `module::function` — a free function +# +# An entry of `[]` means there is intentionally no counterpart on that side. +# +# This mapping is intended for automatic comparison of function and struct +# documentation across the three implementation layers, and is maintained via +# the `update-api-mapping` and `sync-product-docs` skills under `.claude/skills/`. + +# ============================================================================= +# Module: audit_trails::main (audit-trail-move/sources/audit_trail.move) +# ============================================================================= + +[audit_trails.main.ImmutableMetadata] +rust = [ + "ImmutableMetadata", + "ImmutableMetadata::new", + "ImmutableMetadata::tag", + "ImmutableMetadata::to_ptb", +] +wasm = [ + "WasmImmutableMetadata", +] + +[audit_trails.main.AuditTrail] +rust = [ + "OnChainAuditTrail", + "AuditTrailHandle", + "AuditTrailHandle::new", + "AuditTrailHandle::using_capability", + "AuditTrailHandle::get", + "AuditTrailHandle::records", + "AuditTrailHandle::locking", + "AuditTrailHandle::access", + "AuditTrailHandle::tags", +] +wasm = [ + "WasmOnChainAuditTrail", + "WasmOnChainAuditTrail::id", + "WasmOnChainAuditTrail::creator", + "WasmOnChainAuditTrail::created_at", + "WasmOnChainAuditTrail::sequence_number", + "WasmOnChainAuditTrail::locking_config", + "WasmOnChainAuditTrail::records", + "WasmOnChainAuditTrail::tags", + "WasmOnChainAuditTrail::roles", + "WasmOnChainAuditTrail::immutable_metadata", + "WasmOnChainAuditTrail::updatable_metadata", + "WasmOnChainAuditTrail::version", + "WasmAuditTrailHandle", + "WasmAuditTrailHandle::get", + "WasmAuditTrailHandle::records", + "WasmAuditTrailHandle::access", + "WasmAuditTrailHandle::locking", + "WasmAuditTrailHandle::tags", +] + +[audit_trails.main.AuditTrailCreated] +rust = [ + "AuditTrailCreated", + "TrailCreated", + "TrailCreated::fetch_audit_trail", +] +wasm = [ + "WasmAuditTrailCreated", +] + +[audit_trails.main.AuditTrailDeleted] +rust = [ + "AuditTrailDeleted", +] +wasm = [ + "WasmAuditTrailDeleted", +] + +[audit_trails.main.AuditTrailMigrated] +rust = [ + "AuditTrailMigrated", +] +wasm = [ + "WasmAuditTrailMigrated", +] + +[audit_trails.main.MetadataUpdated] +rust = [ + "MetadataUpdated", +] +wasm = [ + "WasmMetadataUpdated", +] + +[audit_trails.main.LockingConfigUpdated] +rust = [ + "LockingConfigUpdated", +] +wasm = [ + "WasmLockingConfigUpdated", +] + +[audit_trails.main.RecordAdded] +rust = [ + "RecordAdded", +] +wasm = [ + "WasmRecordAdded", +] + +[audit_trails.main.RecordDeleted] +rust = [ + "RecordDeleted", +] +wasm = [ + "WasmRecordDeleted", +] + +[audit_trails.main.RecordTagAdded] +rust = [ + "RecordTagAdded", +] +wasm = [ + "WasmRecordTagAdded", +] + +[audit_trails.main.RecordTagRemoved] +rust = [ + "RecordTagRemoved", +] +wasm = [ + "WasmRecordTagRemoved", +] + +[audit_trails.main.RevokedCapabilitiesCleanedUp] +rust = [ + "RevokedCapabilitiesCleanedUp", +] +wasm = [ + "WasmRevokedCapabilitiesCleanedUp", +] + +[audit_trails.main.CapabilityIssuedReceipt] +rust = [ + "CapabilityIssued", +] +wasm = [ + "WasmCapabilityIssued", +] + +[audit_trails.main.new_trail_metadata] +rust = [ + "ImmutableMetadata::new", + "ImmutableMetadata::to_ptb", + "AuditTrailBuilder::with_trail_metadata", + "AuditTrailBuilder::with_trail_metadata_parts", +] +wasm = [ + "WasmImmutableMetadata", + "WasmAuditTrailBuilder::with_trail_metadata", +] + +[audit_trails.main.create] +rust = [ + "AuditTrailBuilder", + "AuditTrailBuilder::with_initial_record", + "AuditTrailBuilder::with_initial_record_parts", + "AuditTrailBuilder::with_locking_config", + "AuditTrailBuilder::with_trail_metadata", + "AuditTrailBuilder::with_trail_metadata_parts", + "AuditTrailBuilder::with_updatable_metadata", + "AuditTrailBuilder::with_record_tags", + "AuditTrailBuilder::with_admin", + "AuditTrailBuilder::finish", + "CreateTrail", + "CreateTrail::new", + "TrailCreated", + "AuditTrailClient::create_trail", + "AuditTrailClientReadOnly::trail", + "AuditTrailClient::trail", +] +wasm = [ + "WasmAuditTrailBuilder", + "WasmAuditTrailBuilder::with_initial_record_string", + "WasmAuditTrailBuilder::with_initial_record_bytes", + "WasmAuditTrailBuilder::with_trail_metadata", + "WasmAuditTrailBuilder::with_updatable_metadata", + "WasmAuditTrailBuilder::with_locking_config", + "WasmAuditTrailBuilder::with_record_tags", + "WasmAuditTrailBuilder::with_admin", + "WasmAuditTrailBuilder::finish", + "WasmCreateTrail", + "WasmCreateTrail::new", + "WasmCreateTrail::build_programmable_transaction", + "WasmCreateTrail::apply_with_events", + "WasmAuditTrailClient::create_trail", + "WasmAuditTrailClient::trail", +] + +[audit_trails.main.initial_admin_role_name] +rust = [ + "RoleMap.initial_admin_role_name", +] +wasm = [ + "WasmRoleMap.initial_admin_role_name", +] + +[audit_trails.main.migrate] +rust = [ + "Migrate", + "Migrate::new", + "AuditTrailHandle::migrate", +] +wasm = [ + "WasmMigrate", + "WasmMigrate::build_programmable_transaction", + "WasmMigrate::apply_with_events", + "WasmAuditTrailHandle::migrate", +] + +[audit_trails.main.add_record] +rust = [ + "AddRecord", + "AddRecord::new", + "TrailRecords::add", +] +wasm = [ + "WasmAddRecord", + "WasmAddRecord::build_programmable_transaction", + "WasmAddRecord::apply_with_events", + "WasmTrailRecords::add", +] + +[audit_trails.main.delete_record] +rust = [ + "DeleteRecord", + "DeleteRecord::new", + "TrailRecords::delete", +] +wasm = [ + "WasmDeleteRecord", + "WasmDeleteRecord::build_programmable_transaction", + "WasmDeleteRecord::apply_with_events", + "WasmTrailRecords::delete", +] + +[audit_trails.main.delete_records_batch] +rust = [ + "DeleteRecordsBatch", + "DeleteRecordsBatch::new", + "TrailRecords::delete_records_batch", +] +wasm = [ + "WasmDeleteRecordsBatch", + "WasmDeleteRecordsBatch::build_programmable_transaction", + "WasmDeleteRecordsBatch::apply_with_events", + "WasmTrailRecords::delete_batch", +] + +[audit_trails.main.delete_audit_trail] +rust = [ + "DeleteAuditTrail", + "DeleteAuditTrail::new", + "AuditTrailHandle::delete_audit_trail", +] +wasm = [ + "WasmDeleteAuditTrail", + "WasmDeleteAuditTrail::build_programmable_transaction", + "WasmDeleteAuditTrail::apply_with_events", + "WasmAuditTrailHandle::delete_audit_trail", +] + +[audit_trails.main.is_record_locked] +rust = [ + "TrailLocking::is_record_locked", +] +wasm = [ + "WasmTrailLocking::is_record_locked", +] + +[audit_trails.main.update_locking_config] +rust = [ + "UpdateLockingConfig", + "UpdateLockingConfig::new", + "TrailLocking::update", +] +wasm = [ + "WasmUpdateLockingConfig", + "WasmUpdateLockingConfig::build_programmable_transaction", + "WasmUpdateLockingConfig::apply_with_events", + "WasmTrailLocking::update", +] + +[audit_trails.main.update_delete_record_window] +rust = [ + "UpdateDeleteRecordWindow", + "UpdateDeleteRecordWindow::new", + "TrailLocking::update_delete_record_window", +] +wasm = [ + "WasmUpdateDeleteRecordWindow", + "WasmUpdateDeleteRecordWindow::build_programmable_transaction", + "WasmUpdateDeleteRecordWindow::apply_with_events", + "WasmTrailLocking::update_delete_record_window", +] + +[audit_trails.main.update_delete_trail_lock] +rust = [ + "UpdateDeleteTrailLock", + "UpdateDeleteTrailLock::new", + "TrailLocking::update_delete_trail_lock", +] +wasm = [ + "WasmUpdateDeleteTrailLock", + "WasmUpdateDeleteTrailLock::build_programmable_transaction", + "WasmUpdateDeleteTrailLock::apply_with_events", + "WasmTrailLocking::update_delete_trail_lock", +] + +[audit_trails.main.update_write_lock] +rust = [ + "UpdateWriteLock", + "UpdateWriteLock::new", + "TrailLocking::update_write_lock", +] +wasm = [ + "WasmUpdateWriteLock", + "WasmUpdateWriteLock::build_programmable_transaction", + "WasmUpdateWriteLock::apply_with_events", + "WasmTrailLocking::update_write_lock", +] + +[audit_trails.main.update_metadata] +rust = [ + "UpdateMetadata", + "UpdateMetadata::new", + "AuditTrailHandle::update_metadata", +] +wasm = [ + "WasmUpdateMetadata", + "WasmUpdateMetadata::build_programmable_transaction", + "WasmUpdateMetadata::apply_with_events", + "WasmAuditTrailHandle::update_metadata", +] + +[audit_trails.main.add_record_tag] +rust = [ + "AddRecordTag", + "AddRecordTag::new", + "TrailTags::add", +] +wasm = [ + "WasmAddRecordTag", + "WasmAddRecordTag::build_programmable_transaction", + "WasmAddRecordTag::apply_with_events", + "WasmTrailTags::add", +] + +[audit_trails.main.remove_record_tag] +rust = [ + "RemoveRecordTag", + "RemoveRecordTag::new", + "TrailTags::remove", +] +wasm = [ + "WasmRemoveRecordTag", + "WasmRemoveRecordTag::build_programmable_transaction", + "WasmRemoveRecordTag::apply_with_events", + "WasmTrailTags::remove", +] + +[audit_trails.main.create_role] +rust = [ + "CreateRole", + "CreateRole::new", + "RoleHandle::create", + "TrailAccess::for_role", +] +wasm = [ + "WasmCreateRole", + "WasmCreateRole::build_programmable_transaction", + "WasmCreateRole::apply_with_events", + "WasmRoleHandle::create", + "WasmTrailAccess::for_role", +] + +[audit_trails.main.update_role_permissions] +rust = [ + "UpdateRole", + "UpdateRole::new", + "RoleHandle::update_permissions", +] +wasm = [ + "WasmUpdateRole", + "WasmUpdateRole::build_programmable_transaction", + "WasmUpdateRole::apply_with_events", + "WasmRoleHandle::update_permissions", +] + +[audit_trails.main.delete_role] +rust = [ + "DeleteRole", + "DeleteRole::new", + "RoleHandle::delete", +] +wasm = [ + "WasmDeleteRole", + "WasmDeleteRole::build_programmable_transaction", + "WasmDeleteRole::apply_with_events", + "WasmRoleHandle::delete", +] + +[audit_trails.main.new_capability] +rust = [ + "IssueCapability", + "IssueCapability::new", + "RoleHandle::issue_capability", + "CapabilityIssueOptions", + "CapabilityIssued", +] +wasm = [ + "WasmIssueCapability", + "WasmIssueCapability::build_programmable_transaction", + "WasmIssueCapability::apply_with_events", + "WasmRoleHandle::issue_capability", + "WasmCapabilityIssueOptions", + "WasmCapabilityIssued", +] + +[audit_trails.main.revoke_capability] +rust = [ + "RevokeCapability", + "RevokeCapability::new", + "TrailAccess::revoke_capability", + "CapabilityRevoked", +] +wasm = [ + "WasmRevokeCapability", + "WasmRevokeCapability::build_programmable_transaction", + "WasmRevokeCapability::apply_with_events", + "WasmTrailAccess::revoke_capability", + "WasmCapabilityRevoked", +] + +[audit_trails.main.destroy_capability] +rust = [ + "DestroyCapability", + "DestroyCapability::new", + "TrailAccess::destroy_capability", + "CapabilityDestroyed", +] +wasm = [ + "WasmDestroyCapability", + "WasmDestroyCapability::build_programmable_transaction", + "WasmDestroyCapability::apply_with_events", + "WasmTrailAccess::destroy_capability", + "WasmCapabilityDestroyed", +] + +[audit_trails.main.destroy_initial_admin_capability] +rust = [ + "DestroyInitialAdminCapability", + "DestroyInitialAdminCapability::new", + "TrailAccess::destroy_initial_admin_capability", +] +wasm = [ + "WasmDestroyInitialAdminCapability", + "WasmDestroyInitialAdminCapability::build_programmable_transaction", + "WasmDestroyInitialAdminCapability::apply_with_events", + "WasmTrailAccess::destroy_initial_admin_capability", +] + +[audit_trails.main.revoke_initial_admin_capability] +rust = [ + "RevokeInitialAdminCapability", + "RevokeInitialAdminCapability::new", + "TrailAccess::revoke_initial_admin_capability", +] +wasm = [ + "WasmRevokeInitialAdminCapability", + "WasmRevokeInitialAdminCapability::build_programmable_transaction", + "WasmRevokeInitialAdminCapability::apply_with_events", + "WasmTrailAccess::revoke_initial_admin_capability", +] + +[audit_trails.main.cleanup_revoked_capabilities] +rust = [ + "CleanupRevokedCapabilities", + "CleanupRevokedCapabilities::new", + "TrailAccess::cleanup_revoked_capabilities", +] +wasm = [ + "WasmCleanupRevokedCapabilities", + "WasmCleanupRevokedCapabilities::build_programmable_transaction", + "WasmCleanupRevokedCapabilities::apply_with_events", + "WasmTrailAccess::cleanup_revoked_capabilities", +] + +[audit_trails.main.record_count] +rust = [ + "TrailRecords::record_count", +] +wasm = [ + "WasmTrailRecords::record_count", +] + +[audit_trails.main.sequence_number] +rust = [ + "OnChainAuditTrail.sequence_number", +] +wasm = [ + "WasmOnChainAuditTrail::sequence_number", +] + +[audit_trails.main.creator] +rust = [ + "OnChainAuditTrail.creator", +] +wasm = [ + "WasmOnChainAuditTrail::creator", +] + +[audit_trails.main.created_at] +rust = [ + "OnChainAuditTrail.created_at", +] +wasm = [ + "WasmOnChainAuditTrail::created_at", +] + +[audit_trails.main.id] +rust = [ + "OnChainAuditTrail.id", +] +wasm = [ + "WasmOnChainAuditTrail::id", +] + +[audit_trails.main.name] +rust = [ + "OnChainAuditTrail.immutable_metadata", + "ImmutableMetadata.name", +] +wasm = [ + "WasmOnChainAuditTrail::immutable_metadata", + "WasmImmutableMetadata.name", +] + +[audit_trails.main.description] +rust = [ + "OnChainAuditTrail.immutable_metadata", + "ImmutableMetadata.description", +] +wasm = [ + "WasmOnChainAuditTrail::immutable_metadata", + "WasmImmutableMetadata.description", +] + +[audit_trails.main.metadata] +rust = [ + "OnChainAuditTrail.updatable_metadata", +] +wasm = [ + "WasmOnChainAuditTrail::updatable_metadata", +] + +[audit_trails.main.locking_config] +rust = [ + "OnChainAuditTrail.locking_config", + "TrailLocking", +] +wasm = [ + "WasmOnChainAuditTrail::locking_config", + "WasmTrailLocking", +] + +[audit_trails.main.tags] +rust = [ + "OnChainAuditTrail.tags", + "TagRegistry", +] +wasm = [ + "WasmOnChainAuditTrail::tags", + "WasmTrailTags::list", + "WasmRecordTagEntry", +] + +[audit_trails.main.is_empty] +rust = [ + "OnChainAuditTrail.records", + "TrailRecords::record_count", +] +wasm = [ + "WasmTrailRecords::record_count", +] + +[audit_trails.main.first_sequence] +rust = [ + "OnChainAuditTrail.records", +] +wasm = [ + "WasmLinkedTable.head", +] + +[audit_trails.main.last_sequence] +rust = [ + "OnChainAuditTrail.records", +] +wasm = [ + "WasmLinkedTable.tail", +] + +[audit_trails.main.get_record] +rust = [ + "TrailRecords::get", +] +wasm = [ + "WasmTrailRecords::get", +] + +[audit_trails.main.has_record] +rust = [ + "TrailRecords::get", +] +wasm = [ + "WasmTrailRecords::get", +] + +[audit_trails.main.records] +rust = [ + "OnChainAuditTrail.records", + "TrailRecords", + "TrailRecords::list", + "TrailRecords::list_with_limit", + "TrailRecords::list_page", + "PaginatedRecord", +] +wasm = [ + "WasmOnChainAuditTrail::records", + "WasmTrailRecords", + "WasmTrailRecords::list", + "WasmTrailRecords::list_with_limit", + "WasmTrailRecords::list_page", + "WasmLinkedTable", + "WasmPaginatedRecord", +] + +[audit_trails.main.access] +rust = [ + "OnChainAuditTrail.roles", + "RoleMap", + "TrailAccess", + "AuditTrailHandle::access", +] +wasm = [ + "WasmOnChainAuditTrail::roles", + "WasmRoleMap", + "WasmTrailAccess", + "WasmAuditTrailHandle::access", +] + +[audit_trails.main.access_mut] +rust = [ + "TrailAccess", +] +wasm = [ + "WasmTrailAccess", +] + +# ============================================================================= +# Module: audit_trails::locking (audit-trail-move/sources/locking.move) +# ============================================================================= + +[audit_trails.locking.LockingWindow] +rust = [ + "LockingWindow", + "LockingWindow::to_ptb", + "LockingWindow::validate", +] +wasm = [ + "WasmLockingWindow", + "WasmLockingWindowType", +] + +[audit_trails.locking.LockingConfig] +rust = [ + "LockingConfig", + "LockingConfig::to_ptb", + "LockingConfig::validate", + "TimeLock::validate_as_delete_trail_lock", +] +wasm = [ + "WasmLockingConfig", + "WasmLockingConfig::new", +] + +[audit_trails.locking.window_none] +rust = [ + "LockingWindow::None", +] +wasm = [ + "WasmLockingWindow::with_none", +] + +[audit_trails.locking.window_time_based] +rust = [ + "LockingWindow::TimeBased", +] +wasm = [ + "WasmLockingWindow::with_time_based", +] + +[audit_trails.locking.window_count_based] +rust = [ + "LockingWindow::CountBased", + "LockingWindow::validate", +] +wasm = [ + "WasmLockingWindow::with_count_based", +] + +[audit_trails.locking.new] +rust = [ + "LockingConfig", + "LockingConfig::to_ptb", + "LockingConfig::validate", + "TimeLock::validate_as_delete_trail_lock", +] +wasm = [ + "WasmLockingConfig::new", +] + +[audit_trails.locking.is_delete_trail_locked] +rust = [] +wasm = [] + +[audit_trails.locking.is_write_locked] +rust = [] +wasm = [] + +# ============================================================================= +# Module: audit_trails::permission (audit-trail-move/sources/permission.move) +# ============================================================================= + +[audit_trails.permission.Permission] +rust = [ + "Permission", + "Permission::function_name", + "Permission::tag", + "Permission::to_ptb", +] +wasm = [ + "WasmPermission", +] + +[audit_trails.permission.empty] +rust = [ + "PermissionSet", + "PermissionSet::default", +] +wasm = [ + "WasmPermissionSet::new", +] + +[audit_trails.permission.add] +rust = [ + "PermissionSet.permissions", +] +wasm = [ + "WasmPermissionSet.permissions", +] + +[audit_trails.permission.from_vec] +rust = [ + "PermissionSet", + "PermissionSet::to_move_vec", +] +wasm = [ + "WasmPermissionSet", + "WasmPermissionSet::new", +] + +[audit_trails.permission.has_permission] +rust = [ + "PermissionSet.permissions", +] +wasm = [ + "WasmPermissionSet.permissions", +] + +[audit_trails.permission.admin_permissions] +rust = [ + "PermissionSet::admin_permissions", +] +wasm = [ + "WasmPermissionSet::admin_permissions", +] + +[audit_trails.permission.record_admin_permissions] +rust = [ + "PermissionSet::record_admin_permissions", +] +wasm = [ + "WasmPermissionSet::record_admin_permissions", +] + +[audit_trails.permission.locking_admin_permissions] +rust = [ + "PermissionSet::locking_admin_permissions", +] +wasm = [ + "WasmPermissionSet::locking_admin_permissions", +] + +[audit_trails.permission.role_admin_permissions] +rust = [ + "PermissionSet::role_admin_permissions", + "RoleAdminPermissions", +] +wasm = [ + "WasmPermissionSet::role_admin_permissions", + "WasmRoleAdminPermissions", +] + +[audit_trails.permission.tag_admin_permissions] +rust = [ + "PermissionSet::tag_admin_permissions", +] +wasm = [ + "WasmPermissionSet::tag_admin_permissions", +] + +[audit_trails.permission.cap_admin_permissions] +rust = [ + "PermissionSet::cap_admin_permissions", + "CapabilityAdminPermissions", +] +wasm = [ + "WasmPermissionSet::cap_admin_permissions", + "WasmCapabilityAdminPermissions", +] + +[audit_trails.permission.metadata_admin_permissions] +rust = [ + "PermissionSet::metadata_admin_permissions", +] +wasm = [ + "WasmPermissionSet::metadata_admin_permissions", +] + +[audit_trails.permission.delete_audit_trail] +rust = [ + "Permission::DeleteAuditTrail", +] +wasm = [ + "WasmPermission::DeleteAuditTrail", +] + +[audit_trails.permission.delete_all_records] +rust = [ + "Permission::DeleteAllRecords", +] +wasm = [ + "WasmPermission::DeleteAllRecords", +] + +[audit_trails.permission.add_record] +rust = [ + "Permission::AddRecord", +] +wasm = [ + "WasmPermission::AddRecord", +] + +[audit_trails.permission.delete_record] +rust = [ + "Permission::DeleteRecord", +] +wasm = [ + "WasmPermission::DeleteRecord", +] + +[audit_trails.permission.correct_record] +rust = [ + "Permission::CorrectRecord", +] +wasm = [ + "WasmPermission::CorrectRecord", +] + +[audit_trails.permission.update_locking_config] +rust = [ + "Permission::UpdateLockingConfig", +] +wasm = [ + "WasmPermission::UpdateLockingConfig", +] + +[audit_trails.permission.update_locking_config_for_delete_record] +rust = [ + "Permission::UpdateLockingConfigForDeleteRecord", +] +wasm = [ + "WasmPermission::UpdateLockingConfigForDeleteRecord", +] + +[audit_trails.permission.update_locking_config_for_delete_trail] +rust = [ + "Permission::UpdateLockingConfigForDeleteTrail", +] +wasm = [ + "WasmPermission::UpdateLockingConfigForDeleteTrail", +] + +[audit_trails.permission.update_locking_config_for_write] +rust = [ + "Permission::UpdateLockingConfigForWrite", +] +wasm = [ + "WasmPermission::UpdateLockingConfigForWrite", +] + +[audit_trails.permission.add_record_tags] +rust = [ + "Permission::AddRecordTags", +] +wasm = [ + "WasmPermission::AddRecordTags", +] + +[audit_trails.permission.delete_record_tags] +rust = [ + "Permission::DeleteRecordTags", +] +wasm = [ + "WasmPermission::DeleteRecordTags", +] + +[audit_trails.permission.add_roles] +rust = [ + "Permission::AddRoles", +] +wasm = [ + "WasmPermission::AddRoles", +] + +[audit_trails.permission.update_roles] +rust = [ + "Permission::UpdateRoles", +] +wasm = [ + "WasmPermission::UpdateRoles", +] + +[audit_trails.permission.delete_roles] +rust = [ + "Permission::DeleteRoles", +] +wasm = [ + "WasmPermission::DeleteRoles", +] + +[audit_trails.permission.add_capabilities] +rust = [ + "Permission::AddCapabilities", +] +wasm = [ + "WasmPermission::AddCapabilities", +] + +[audit_trails.permission.revoke_capabilities] +rust = [ + "Permission::RevokeCapabilities", +] +wasm = [ + "WasmPermission::RevokeCapabilities", +] + +[audit_trails.permission.update_metadata] +rust = [ + "Permission::UpdateMetadata", +] +wasm = [ + "WasmPermission::UpdateMetadata", +] + +[audit_trails.permission.delete_metadata] +rust = [ + "Permission::DeleteMetadata", +] +wasm = [ + "WasmPermission::DeleteMetadata", +] + +[audit_trails.permission.migrate_audit_trail] +rust = [ + "Permission::Migrate", +] +wasm = [ + "WasmPermission::Migrate", +] + +# ============================================================================= +# Module: audit_trails::record (audit-trail-move/sources/record.move) +# ============================================================================= + +[audit_trails.record.Data] +rust = [ + "Data", + "Data::tag", + "Data::into_ptb", + "Data::ensure_matches_tag", + "Data::as_bytes", + "Data::as_text", +] +wasm = [ + "WasmData", + "WasmData::value", + "WasmData::to_string", + "WasmData::to_bytes", +] + +[audit_trails.record.new_bytes] +rust = [ + "Data::bytes", + "Data::Bytes", +] +wasm = [ + "WasmData::from_bytes", +] + +[audit_trails.record.new_text] +rust = [ + "Data::text", + "Data::Text", +] +wasm = [ + "WasmData::from_string", +] + +[audit_trails.record.bytes] +rust = [ + "Data::as_bytes", + "Data::Bytes", +] +wasm = [ + "WasmData::to_bytes", +] + +[audit_trails.record.text] +rust = [ + "Data::as_text", + "Data::Text", +] +wasm = [ + "WasmData::to_string", +] + +[audit_trails.record.Record] +rust = [ + "Record", +] +wasm = [ + "WasmRecord", +] + +[audit_trails.record.InitialRecord] +rust = [ + "InitialRecord", + "InitialRecord::new", + "InitialRecord::tag", + "InitialRecord::into_ptb", + "AuditTrailBuilder::with_initial_record", + "AuditTrailBuilder::with_initial_record_parts", +] +wasm = [ + "WasmAuditTrailBuilder::with_initial_record_string", + "WasmAuditTrailBuilder::with_initial_record_bytes", +] + +[audit_trails.record.new_initial_record] +rust = [ + "InitialRecord::new", + "InitialRecord::into_ptb", + "AuditTrailBuilder::with_initial_record", + "AuditTrailBuilder::with_initial_record_parts", +] +wasm = [ + "WasmAuditTrailBuilder::with_initial_record_string", + "WasmAuditTrailBuilder::with_initial_record_bytes", +] + +[audit_trails.record.data] +rust = [ + "Record.data", +] +wasm = [ + "WasmRecord.data", +] + +[audit_trails.record.metadata] +rust = [ + "Record.metadata", +] +wasm = [ + "WasmRecord.metadata", +] + +[audit_trails.record.tag] +rust = [ + "Record.tag", +] +wasm = [ + "WasmRecord.tag", +] + +[audit_trails.record.sequence_number] +rust = [ + "Record.sequence_number", +] +wasm = [ + "WasmRecord.sequence_number", +] + +[audit_trails.record.added_by] +rust = [ + "Record.added_by", +] +wasm = [ + "WasmRecord.added_by", +] + +[audit_trails.record.added_at] +rust = [ + "Record.added_at", +] +wasm = [ + "WasmRecord.added_at", +] + +[audit_trails.record.correction] +rust = [ + "Record.correction", +] +wasm = [ + "WasmRecord.correction", +] + +[audit_trails.record.RecordCorrection] +rust = [ + "RecordCorrection", + "RecordCorrection::with_replaces", + "RecordCorrection::is_correction", + "RecordCorrection::is_replaced", +] +wasm = [ + "WasmRecordCorrection", +] + +[audit_trails.record.empty] +rust = [ + "RecordCorrection::default", +] +wasm = [ + "WasmRecordCorrection", +] + +[audit_trails.record.with_replaces] +rust = [ + "RecordCorrection::with_replaces", +] +wasm = [ + "WasmRecordCorrection.replaces", +] + +[audit_trails.record.replaces] +rust = [ + "RecordCorrection.replaces", +] +wasm = [ + "WasmRecordCorrection.replaces", +] + +[audit_trails.record.is_replaced_by] +rust = [ + "RecordCorrection.is_replaced_by", +] +wasm = [ + "WasmRecordCorrection.is_replaced_by", +] + +[audit_trails.record.is_correction] +rust = [ + "RecordCorrection::is_correction", +] +wasm = [] + +[audit_trails.record.is_replaced] +rust = [ + "RecordCorrection::is_replaced", +] +wasm = [] + +# ============================================================================= +# Module: audit_trails::record_tags (audit-trail-move/sources/record_tags.move) +# ============================================================================= + +[audit_trails.record_tags.RoleTags] +rust = [ + "RoleTags", + "RoleTags::new", + "RoleTags::allows", + "RoleTags::tag", + "RoleTags::to_ptb", +] +wasm = [ + "WasmRoleTags", + "WasmRoleTags::new", +] + +[audit_trails.record_tags.new_role_tags] +rust = [ + "RoleTags::new", + "RoleTags::to_ptb", +] +wasm = [ + "WasmRoleTags::new", +] + +[audit_trails.record_tags.tags] +rust = [ + "RoleTags.tags", +] +wasm = [ + "WasmRoleTags.tags", +] + +[audit_trails.record_tags.TagRegistry] +rust = [ + "TagRegistry", + "TagRegistry::len", + "TagRegistry::is_empty", + "TagRegistry::contains_key", + "TagRegistry::get", + "TagRegistry::iter", + "TrailTags", +] +wasm = [ + "WasmRecordTagEntry", + "WasmTrailTags", + "WasmTrailTags::list", + "WasmOnChainAuditTrail::tags", +] + +[audit_trails.record_tags.tag_map] +rust = [ + "TagRegistry.tag_map", +] +wasm = [ + "WasmTrailTags::list", +] diff --git a/audit-trail-move/scripts/publish_package.sh b/audit-trail-move/scripts/publish_package.sh new file mode 100755 index 00000000..5996192f --- /dev/null +++ b/audit-trail-move/scripts/publish_package.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Copyright 2020-2026 IOTA Stiftung +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +script_dir=$(cd "$(dirname "$0")" && pwd) +package_dir="$script_dir/.." + +active_env=$(iota client active-env --json | jq -r '.') + +publish_args=( + iota client publish + --silence-warnings + --json + --gas-budget 500000000 +) + +if [[ "$active_env" == "localnet" ]]; then + publish_args+=(--with-unpublished-dependencies) +fi + +response=$("${publish_args[@]}" "$package_dir") + +audit_trail_package_id=$( + echo "$response" | jq -r ' + .objectChanges[] + | select(.type == "published") + | .packageId + ' +) + +if [[ -z "$audit_trail_package_id" || "$audit_trail_package_id" == "null" ]]; then + echo "$response" >&2 + echo "failed to extract IotaAuditTrails package ID from publish response" >&2 + exit 1 +fi + +export IOTA_AUDIT_TRAIL_PKG_ID="$audit_trail_package_id" +printf 'export IOTA_AUDIT_TRAIL_PKG_ID=%s\n' "$IOTA_AUDIT_TRAIL_PKG_ID" + +if [[ "$active_env" == "localnet" ]]; then + tf_components_package_id="$audit_trail_package_id" + + export IOTA_TF_COMPONENTS_PKG_ID="$tf_components_package_id" + printf 'export IOTA_TF_COMPONENTS_PKG_ID=%s\n' "$IOTA_TF_COMPONENTS_PKG_ID" +fi diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move new file mode 100644 index 00000000..829e85ad --- /dev/null +++ b/audit-trail-move/sources/audit_trail.move @@ -0,0 +1,1593 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Audit Trails with role-based access control and timelock +/// A trail is a tamper-proof, sequential chain of notarized records where each +/// entry references its predecessor, ensuring verifiable continuity and +/// integrity. +module audit_trails::main; + +use audit_trails::{ + locking::{ + Self, + LockingConfig, + LockingWindow, + set_config, + set_delete_record_window, + set_delete_trail_lock, + set_write_lock + }, + permission::{Self, Permission}, + record::{Self, Record, InitialRecord}, + record_tags::{Self, RoleTags, TagRegistry} +}; +use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; +use std::string::String; +use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; + +// ===== Errors ===== + +#[error] +const ERecordNotFound: vector = b"Record not found at the given sequence number"; +#[error] +const ERecordLocked: vector = b"The record is locked and cannot be deleted"; +#[error] +const ETrailNotEmpty: vector = b"Audit trail cannot be deleted while records still exist"; +#[error] +const ETrailDeleteLocked: vector = b"The audit trail is delete-locked"; +#[error] +const ETrailWriteLocked: vector = b"The audit trail is write-locked"; +#[error] +const EPackageVersionMismatch: vector = + b"The package version of the trail does not match the expected version"; +#[error] +const ERecordTagNotAllowed: vector = + b"The provided capability cannot create records with the requested tag"; +#[error] +const ERecordTagNotDefined: vector = b"The requested tag is not defined for this audit trail"; +#[error] +const ERecordTagAlreadyDefined: vector = + b"The requested tag is already defined for this audit trail"; +#[error] +const ERecordTagInUse: vector = + b"The requested tag cannot be removed because it is already used by an existing record or role"; + +// ===== Constants ===== + +const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; + +// Package version, incremented when the package is updated +const PACKAGE_VERSION: u64 = 1; + +// ===== Core Structures ===== + +/// Metadata set at trail creation +public struct ImmutableMetadata has copy, drop, store { + name: String, + description: Option, +} + +/// A shared, tamper-evident ledger for storing sequential records with +/// role-based access control. +/// +/// It maintains an ordered sequence of records, each assigned a unique +/// auto-incrementing sequence number. +/// Uses capability-based RBAC to manage access to the trail and its records. +public struct AuditTrail has key { + id: UID, + /// Address that created this trail + creator: address, + /// Creation timestamp in milliseconds + created_at: u64, + /// Monotonic counter for sequence assignment (never decrements) + sequence_number: u64, + /// LinkedTable mapping sequence numbers to records + records: LinkedTable>, + /// Canonical list of tags that may be attached to records in this trail with their combined usage counts + tags: TagRegistry, + /// Deletion locking rules + locking_config: LockingConfig, + /// A list of role definitions consisting of a unique role specifier and a list of associated permissions + roles: RoleMap, + /// Set at creation, cannot be changed + immutable_metadata: Option, + /// Can be updated by holders of MetadataUpdate permission + updatable_metadata: Option, + /// Package version + version: u64, +} + +// ===== Events ===== + +/// Emitted when a new trail is created +public struct AuditTrailCreated has copy, drop { + trail_id: ID, + creator: address, + timestamp: u64, +} + +/// Emitted when the audit trail is deleted +public struct AuditTrailDeleted has copy, drop { + trail_id: ID, + timestamp: u64, +} + +/// Emitted when a trail is migrated to the current package version +public struct AuditTrailMigrated has copy, drop { + trail_id: ID, + migrated_by: address, + timestamp: u64, +} + +/// Emitted when mutable trail metadata is updated +public struct MetadataUpdated has copy, drop { + trail_id: ID, + updated_by: address, + timestamp: u64, +} + +/// Emitted when the trail's locking configuration is updated +public struct LockingConfigUpdated has copy, drop { + trail_id: ID, + updated_by: address, + timestamp: u64, +} + +/// Emitted when a record is added to the trail +public struct RecordAdded has copy, drop { + trail_id: ID, + sequence_number: u64, + added_by: address, + timestamp: u64, +} + +/// Emitted when a record is deleted from the trail +public struct RecordDeleted has copy, drop { + trail_id: ID, + sequence_number: u64, + deleted_by: address, + timestamp: u64, +} + +/// Emitted when a record tag is added to the trail's registry +public struct RecordTagAdded has copy, drop { + trail_id: ID, + added_by: address, + timestamp: u64, +} + +/// Emitted when a record tag is removed from the trail's registry +public struct RecordTagRemoved has copy, drop { + trail_id: ID, + removed_by: address, + timestamp: u64, +} + +/// Emitted when expired revoked-capability entries are removed from the denylist +public struct RevokedCapabilitiesCleanedUp has copy, drop { + trail_id: ID, + cleaned_count: u64, + cleaned_by: address, + timestamp: u64, +} + +/// Returned when a capability is issued through the audit-trail API +public struct CapabilityIssuedReceipt has copy, drop { + target_key: ID, + capability_id: ID, + role: String, + issued_to: Option
, + valid_from: Option, + valid_until: Option, +} + +// ===== Constructors ===== + +/// Creates an `ImmutableMetadata` value to be passed to `create`. +/// +/// Returns the constructed `ImmutableMetadata`. +public fun new_trail_metadata(name: String, description: Option): ImmutableMetadata { + ImmutableMetadata { name, description } +} + +// ===== Trail Creation ===== + +/// Creates a new audit trail with an optional initial record and shares it on-chain. +/// +/// Initialises the trail's role map with a single role named "Admin" associated with +/// the permission set defined by the `permission::admin_permissions()` function. The +/// creator receives an initial admin capability that may be used to define further +/// roles and to issue capabilities to other users. +/// +/// When `initial_record` is provided it is stored at sequence number `0`; otherwise +/// the trail is created empty. If the initial record carries a tag, that tag must +/// already be listed in `tags` and its usage count is bumped accordingly. +/// +/// Aborts with: +/// * `ERecordTagNotDefined` when `initial_record` carries a tag that is not listed +/// in `tags`. +/// +/// Emits an `AuditTrailCreated` event on success. +/// +/// Returns the tuple `(admin_cap, trail_id)`: the initial admin `Capability` and the +/// ID of the newly shared `AuditTrail` object. +public fun create( + initial_record: Option>, + locking_config: LockingConfig, + trail_metadata: Option, + updatable_metadata: Option, + tags: vector, + clock: &Clock, + ctx: &mut TxContext, +): (Capability, ID) { + let creator = ctx.sender(); + let timestamp = clock::timestamp_ms(clock); + + let trail_uid = object::new(ctx); + let trail_id = object::uid_to_inner(&trail_uid); + let mut tags = record_tags::new_tag_registry(tags); + + let mut records = linked_table::new>(ctx); + let mut sequence_number = 0; + + if (initial_record.is_some()) { + let record = record::into_record( + initial_record.destroy_some(), + 0, + creator, + timestamp, + ); + + if (record::tag(&record).is_some()) { + let initial_tag = option::borrow(record::tag(&record)); + assert!(record_tags::contains(&tags, initial_tag), ERecordTagNotDefined); + record_tags::increment_usage_count(&mut tags, initial_tag); + }; + + linked_table::push_back(&mut records, 0, record); + sequence_number = 1; + } else { + initial_record.destroy_none(); + }; + + let role_admin_permissions = role_map::new_role_admin_permissions( + permission::add_roles(), + permission::delete_roles(), + permission::update_roles(), + ); + + let capability_admin_permissions = role_map::new_capability_admin_permissions( + permission::add_capabilities(), + permission::revoke_capabilities(), + ); + + let (roles, admin_cap) = role_map::new( + trail_id, + initial_admin_role_name(), + permission::admin_permissions(), + role_admin_permissions, + capability_admin_permissions, + ctx, + ); + + let trail = AuditTrail { + id: trail_uid, + creator, + created_at: timestamp, + sequence_number, + records, + tags, + locking_config, + roles, + immutable_metadata: trail_metadata, + updatable_metadata, + version: PACKAGE_VERSION, + }; + + transfer::share_object(trail); + + event::emit(AuditTrailCreated { + trail_id, + creator, + timestamp, + }); + + (admin_cap, trail_id) +} + +/// Returns the name reserved for the initial admin role created by `create`. +/// +/// Returns the constant string `"Admin"`. +public fun initial_admin_role_name(): String { + INITIAL_ADMIN_ROLE_NAME.to_string() +} + +/// Migrates the trail's stored data layout to the current package version. +/// +/// Bumps the trail's `version` field from a previous package version to +/// `PACKAGE_VERSION`. Intended to be called once after a package upgrade. +/// +/// Requires a capability granting the `Migrate` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is already at `PACKAGE_VERSION`. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// +/// Emits an `AuditTrailMigrated` event on success. +entry fun migrate( + self: &mut AuditTrail, + cap: &Capability, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version < PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::migrate_audit_trail(), + clock, + ctx, + ); + let trail_id = self.id(); + let timestamp = clock::timestamp_ms(clock); + self.version = PACKAGE_VERSION; + + event::emit(AuditTrailMigrated { + trail_id, + migrated_by: ctx.sender(), + timestamp, + }); +} + +fun emit_locking_config_updated(trail_id: ID, updated_by: address, timestamp: u64) { + event::emit(LockingConfigUpdated { + trail_id, + updated_by, + timestamp, + }); +} + +fun is_record_tag_allowed( + self: &AuditTrail, + cap: &Capability, + tag: &Option, +): bool { + if (tag.is_none()) { + return true + }; + + let requested_tag = option::borrow(tag); + assert!(record_tags::contains(&self.tags, requested_tag), ERecordTagNotDefined); + record_tags::role_allows(&self.roles, cap, requested_tag) +} + +fun remove_record( + self: &mut AuditTrail, + sequence_number: u64, + deleted_by: address, + timestamp: u64, + trail_id: ID, +) { + let record = linked_table::remove(&mut self.records, sequence_number); + + if (record.tag().is_some()) { + record_tags::decrement_usage_count(&mut self.tags, option::borrow(record.tag())); + }; + + record.destroy(); + + event::emit(RecordDeleted { + trail_id, + sequence_number, + deleted_by, + timestamp, + }); +} + +/// Returns the lowest sequence_number within the last `count` records, +/// given that sequence_numbers decrease monotonically, walking from +/// the tail toward the head. Returns 0 if the table is empty or `count` is 0. +fun get_lowest_sequence_number_in_count_window( + records: &LinkedTable>, + count: u64, +): u64 { + if (count == 0) { + return 0 + }; + + let mut current = *linked_table::back(records); + let mut remaining = count - 1; + let mut lowest = 0; + + while (current.is_some()) { + let current_sequence_number = current.destroy_some(); + lowest = current_sequence_number; + + if (remaining == 0) { + break + }; + + current = *linked_table::prev(records, current_sequence_number); + remaining = remaining - 1; + }; + + lowest +} + +/// Precomputes the count-window threshold for `lock_window`. +/// +/// Returns `Some(lowest_sequence_number_in_window)` when `lock_window` is a +/// count-based window with a positive count, or `None` otherwise. A record +/// with `sequence_number >= threshold` is count-locked. +fun compute_count_lock_threshold( + records: &LinkedTable>, + lock_window: &LockingWindow, +): Option { + let count_opt = lock_window.count_window(); + if (count_opt.is_some() && *count_opt.borrow() > 0) { + option::some(get_lowest_sequence_number_in_count_window(records, count_opt.destroy_some())) + } else { + option::none() + } +} + +// Returns true if the record at `sequence_number` is locked by the +// `lock_window`. Uses the precomputed `count_lock_threshold` to evaluate +// count based windows and the `current_time` values to evaluate time +// based windows. +// +// Aborts if `sequence_number` is not in `records`. +fun is_record_locked_in_window( + records: &LinkedTable>, + sequence_number: u64, + lock_window: &LockingWindow, + count_lock_threshold: &Option, + current_time: u64, +): bool { + // This is the shared lock-evaluation core used by `is_record_locked` and + // `delete_records_batch`. Add new lock kinds here so both call sites pick + // them up automatically. + if (count_lock_threshold.is_some() && sequence_number >= *count_lock_threshold.borrow()) { + return true + }; + + let record = records.borrow(sequence_number); + lock_window.is_time_locked(record.added_at(), current_time) +} + +// ===== Record Operations ===== + +/// Adds a record to the trail at the next available sequence number. +/// +/// Records are appended sequentially with auto-assigned sequence numbers. When +/// `record_tag` is set, the trail's tag-registry usage count for that tag is +/// incremented. +/// +/// Requires a capability granting the `AddRecord` permission and, when `record_tag` +/// is set, a role whose `RoleTags` allow that tag. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `ETrailWriteLocked` while `write_lock` is active. +/// * `ERecordTagNotDefined` when `record_tag` is not in the trail's tag registry. +/// * `ERecordTagNotAllowed` when `cap`'s role does not allow `record_tag`. +/// +/// Emits a `RecordAdded` event on success. +/// +/// Returns the same receipt that is emitted as the `RecordAdded` event. +public fun add_record( + self: &mut AuditTrail, + cap: &Capability, + stored_data: D, + record_metadata: Option, + record_tag: Option, + clock: &Clock, + ctx: &mut TxContext, +): RecordAdded { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::add_record(), + clock, + ctx, + ); + assert!(!locking::is_write_locked(&self.locking_config, clock), ETrailWriteLocked); + assert!(is_record_tag_allowed(self, cap, &record_tag), ERecordTagNotAllowed); + + let caller = ctx.sender(); + let timestamp = clock::timestamp_ms(clock); + let trail_id = self.id(); + let seq = self.sequence_number; + + if (record_tag.is_some()) { + record_tags::increment_usage_count(&mut self.tags, option::borrow(&record_tag)); + }; + + let record = record::new( + stored_data, + record_metadata, + record_tag, + seq, + caller, + timestamp, + record::empty(), + ); + + linked_table::push_back(&mut self.records, seq, record); + self.sequence_number = self.sequence_number + 1; + + let output = RecordAdded { + trail_id, + sequence_number: seq, + added_by: caller, + timestamp, + }; + + event::emit(copy output); + output +} + +/// Deletes the record at `sequence_number` from the trail. +/// +/// When the deleted record carries a tag, the trail's tag-registry usage count for +/// that tag is decremented. +/// +/// Requires a capability granting the `DeleteRecord` permission and, when the stored +/// record carries a tag, a role whose `RoleTags` allow that tag. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `ERecordNotFound` when no record exists at `sequence_number`. +/// * `ERecordTagNotAllowed` when `cap`'s role does not allow the stored record's +/// tag. +/// * `ERecordLocked` while the configured delete-record window still protects the +/// record. +/// +/// Emits a `RecordDeleted` event on success. +public fun delete_record( + self: &mut AuditTrail, + cap: &Capability, + sequence_number: u64, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::delete_record(), + clock, + ctx, + ); + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + assert!( + is_record_tag_allowed( + self, + cap, + self.records.borrow(sequence_number).tag(), + ), + ERecordTagNotAllowed, + ); + assert!(!self.is_record_locked(sequence_number, clock), ERecordLocked); + + let caller = ctx.sender(); + let timestamp = clock::timestamp_ms(clock); + let trail_id = self.id(); + + self.remove_record(sequence_number, caller, timestamp, trail_id); +} + +/// Deletes up to `limit` records from the front of the trail. +/// +/// Walks the record list from the front and silently skips records still inside the +/// delete-record window or outside the capability's allowed tag set. Tag usage +/// counts are decremented for tagged records that are actually deleted. +/// +/// `limit` caps the number of records actually deleted, not the number of records +/// inspected. Records at the front of the trail that are not eligible for deletion +/// are walked past without counting toward `limit`, so more than `limit` records may +/// be visited before `limit` deletions accumulate. +/// +/// Requires a capability granting the `DeleteAllRecords` permission and, for every +/// tagged record actually deleted, a role whose `RoleTags` allow that tag. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// +/// Emits one `RecordDeleted` event per deletion. +/// +/// Returns the sequence numbers actually deleted, in deletion order. The returned +/// vector may be shorter than `limit` (or empty) if records are skipped or the +/// trail runs out of records before `limit` is reached. +/// +/// Locking semantics +/// ----------------- +/// The set of locked records is fixed at the start of the transaction: +/// +/// * If a count-based `LockingWindow` is configured, the protected window is +/// the last `count` records present *when this call begins*. Records that +/// this same call deletes do not have an impact onto other records. +/// The oldest protected record in the count-based `LockingWindow` is +/// determined up front and its sequence_number is reused as delete criteria +/// for every other candidate record. Concurrent transactions that add +/// records or update the locking configuration are observed by *subsequent* +/// transactions only. +/// * Time-based locks are evaluated against the clock timestamp captured at +/// the start of the call, so a record's lock status is also stable for the +/// duration of the batch. +/// +/// Equivalence with `delete_record` +/// -------------------------------- +/// Running `delete_records_batch(limit)` produces the same final trail state as invoking +/// `delete_record` once for every sequence number this batch would delete, +/// as long as the locking configuration is not mutated and no new records are added +/// to the trail between the batch calls. +/// This holds because the count-window's lower bound is monotonic under deletion: +/// in-window records are locked and therefore never deleted, so deleting any +/// out-of-window record leaves the window's contents unchanged. +/// +/// Caveats +/// ------- +/// * **Partial progress.** The function always returns success even when +/// fewer than `limit` records are deleted. Callers that need to detect +/// "nothing left to delete" should inspect the length of the returned +/// vector — an empty vector means every front-to-back candidate was either +/// locked or tag-filtered out. +/// * **Tag filtering is silent.** Records whose tag is not in `cap`'s allowed +/// set are skipped without error. A capability with a narrow tag scope can +/// therefore make the batch appear to "stop early" while locked-and-disallowed +/// records still exist further back. +/// * **Gas and object-size limits.** The call walks the trail from the front +/// and deletes inline. Large `limit` values can exhaust the per-transaction +/// gas budget or hit object-mutation limits. Prefer lower `limit` values +/// resulting in modest batch sizes and repeat the call. +/// * **Front-to-back order is fixed.** There is no way to target specific +/// sequence numbers through this API — use `delete_record` for that. +public fun delete_records_batch( + self: &mut AuditTrail, + cap: &Capability, + limit: u64, + clock: &Clock, + ctx: &mut TxContext, +): vector { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::delete_all_records(), + clock, + ctx, + ); + + let mut deleted = 0; + let mut deleted_sequence_numbers = vector::empty(); + let caller = ctx.sender(); + let timestamp = clock.timestamp_ms(); + let trail_id = self.id(); + + let lock_window = *self.locking_config.delete_record_window(); + + // Precompute the count-window threshold once. Iteration deletes from the + // front while the back is preserved, so the threshold stays valid. + let count_lock_threshold = compute_count_lock_threshold(&self.records, &lock_window); + + let mut current = *self.records.front(); + + while (deleted < limit && current.is_some()) { + let sequence_number = current.destroy_some(); + current = *self.records.next(sequence_number); + + if ( + is_record_locked_in_window( + &self.records, + sequence_number, + &lock_window, + &count_lock_threshold, + timestamp, + ) + ) { + continue + }; + + if ( + !is_record_tag_allowed( + self, + cap, + self.records.borrow(sequence_number).tag(), + ) + ) { + continue + }; + self.remove_record(sequence_number, caller, timestamp, trail_id); + deleted_sequence_numbers.push_back(sequence_number); + + deleted = deleted + 1; + }; + + deleted_sequence_numbers +} + +/// Deletes an empty audit trail and removes the shared object on-chain. +/// +/// The trail must contain no records before it can be deleted. +/// +/// Requires a capability granting the `DeleteAuditTrail` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `ETrailDeleteLocked` while the configured `delete_trail_lock` is active. +/// * `ETrailNotEmpty` when records still exist. +/// +/// Emits an `AuditTrailDeleted` event on success. +public fun delete_audit_trail( + self: AuditTrail, + cap: &Capability, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::delete_audit_trail(), + clock, + ctx, + ); + assert!(!locking::is_delete_trail_locked(&self.locking_config, clock), ETrailDeleteLocked); + assert!(linked_table::is_empty(&self.records), ETrailNotEmpty); + + let trail_id = self.id(); + let timestamp = clock::timestamp_ms(clock); + + let AuditTrail { + id, + creator: _, + created_at: _, + sequence_number: _, + records, + tags, + locking_config: _, + roles, + immutable_metadata: _, + updatable_metadata: _, + version: _, + } = self; + + roles.destroy(); + linked_table::destroy_empty(records); + tags.destroy(); + + object::delete(id); + + event::emit(AuditTrailDeleted { trail_id, timestamp }); +} + +// ===== Locking ===== + +/// Checks whether the record at `sequence_number` is currently locked against deletion. +/// +/// Evaluates the trail's `delete_record_window` against the records currently +/// present in the trail and the current clock time. Count-based windows lock the +/// last N records currently present in linked-table order. +/// +/// Aborts with: +/// * `ERecordNotFound` when no record exists at `sequence_number`. +/// +/// Returns `true` when the record falls inside the active delete-record window. +public fun is_record_locked( + self: &AuditTrail, + sequence_number: u64, + clock: &Clock, +): bool { + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + + let current_time = clock.timestamp_ms(); + let lock_window = self.locking_config.delete_record_window(); + let count_lock_threshold = compute_count_lock_threshold(&self.records, lock_window); + + is_record_locked_in_window( + &self.records, + sequence_number, + lock_window, + &count_lock_threshold, + current_time, + ) +} + +/// Replaces the trail's whole locking configuration with `new_config`. +/// +/// Requires a capability granting the `UpdateLockingConfig` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `EUntilDestroyedNotSupportedForDeleteTrail` when +/// `new_config.delete_trail_lock` is `TimeLock::UntilDestroyed`. +/// +/// Emits a `LockingConfigUpdated` event on success. +public fun update_locking_config( + self: &mut AuditTrail, + cap: &Capability, + new_config: LockingConfig, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::update_locking_config(), + clock, + ctx, + ); + set_config(&mut self.locking_config, new_config); + + emit_locking_config_updated(self.id(), ctx.sender(), clock::timestamp_ms(clock)); +} + +/// Replaces the trail's `delete_record_window` configuration. +/// +/// Requires a capability granting the `UpdateLockingConfigForDeleteRecord` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// +/// Emits a `LockingConfigUpdated` event on success. +public fun update_delete_record_window( + self: &mut AuditTrail, + cap: &Capability, + new_delete_record_lock: LockingWindow, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::update_locking_config_for_delete_record(), + clock, + ctx, + ); + set_delete_record_window(&mut self.locking_config, new_delete_record_lock); + + emit_locking_config_updated(self.id(), ctx.sender(), clock::timestamp_ms(clock)); +} + +/// Replaces the trail's `delete_trail_lock` timelock. +/// +/// Requires a capability granting the `UpdateLockingConfigForDeleteTrail` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `EUntilDestroyedNotSupportedForDeleteTrail` when `new_delete_trail_lock` is +/// `TimeLock::UntilDestroyed`. +/// +/// Emits a `LockingConfigUpdated` event on success. +public fun update_delete_trail_lock( + self: &mut AuditTrail, + cap: &Capability, + new_delete_trail_lock: TimeLock, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::update_locking_config_for_delete_trail(), + clock, + ctx, + ); + set_delete_trail_lock(&mut self.locking_config, new_delete_trail_lock); + + emit_locking_config_updated(self.id(), ctx.sender(), clock::timestamp_ms(clock)); +} + +/// Replaces the trail's `write_lock` timelock. +/// +/// While the new lock is active, `add_record` aborts with `ETrailWriteLocked`. +/// +/// Requires a capability granting the `UpdateLockingConfigForWrite` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// +/// Emits a `LockingConfigUpdated` event on success. +public fun update_write_lock( + self: &mut AuditTrail, + cap: &Capability, + new_write_lock: TimeLock, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::update_locking_config_for_write(), + clock, + ctx, + ); + set_write_lock(&mut self.locking_config, new_write_lock); + + emit_locking_config_updated(self.id(), ctx.sender(), clock::timestamp_ms(clock)); +} + +/// Replaces or clears the trail's mutable metadata field. +/// +/// Passing `option::none()` clears `updatable_metadata`. +/// +/// Requires a capability granting the `UpdateMetadata` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// +/// Emits a `MetadataUpdated` event on success. +public fun update_metadata( + self: &mut AuditTrail, + cap: &Capability, + new_metadata: Option, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::update_metadata(), + clock, + ctx, + ); + self.updatable_metadata = new_metadata; + + event::emit(MetadataUpdated { + trail_id: self.id(), + updated_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }); +} + +/// Adds a new record tag to the trail's tag registry. +/// +/// The tag is inserted with a usage count of zero. +/// +/// Requires a capability granting the `AddRecordTags` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `ERecordTagAlreadyDefined` when `tag` is already in the registry. +/// +/// Emits a `RecordTagAdded` event on success. +public fun add_record_tag( + self: &mut AuditTrail, + cap: &Capability, + tag: String, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + + self.roles.assert_capability_valid(cap, &permission::add_record_tags(), clock, ctx); + + assert!(!self.tags.contains(&tag), ERecordTagAlreadyDefined); + self.tags.insert_tag(tag, 0); + + event::emit(RecordTagAdded { + trail_id: self.id(), + added_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }); +} + +/// Removes a record tag from the trail's tag registry. +/// +/// The tag must not currently be referenced by any record or role-tag restriction. +/// +/// Requires a capability granting the `DeleteRecordTags` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `ERecordTagNotDefined` when `tag` is not in the registry. +/// * `ERecordTagInUse` when it is still referenced by an existing record or +/// role-tag restriction. +/// +/// Emits a `RecordTagRemoved` event on success. +public fun remove_record_tag( + self: &mut AuditTrail, + cap: &Capability, + tag: String, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + + self.roles.assert_capability_valid(cap, &permission::delete_record_tags(), clock, ctx); + + assert!(self.tags.contains(&tag), ERecordTagNotDefined); + assert!(!self.tags.is_in_use(&tag), ERecordTagInUse); + + self.tags.remove_tag(&tag); + + event::emit(RecordTagRemoved { + trail_id: self.id(), + removed_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }); +} + +// ===== Role and Capability Administration ===== + +/// Creates a new role on the trail with the provided permissions and optional record-tag allowlist. +/// +/// Each tag listed in `role_tags` bumps that tag's usage counter in the trail's tag +/// registry. +/// +/// Requires a capability granting the `AddRoles` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `ERecordTagNotDefined` when any tag listed in `role_tags` is not in the +/// trail's tag registry. +/// +/// Emits a `tf_components::role_map::RoleCreated` event on success. +public fun create_role( + self: &mut AuditTrail, + cap: &Capability, + role: String, + permissions: VecSet, + role_tags: Option, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + + assert!(self.tags.contains_all_role_tags(&role_tags), ERecordTagNotDefined); + + role_map::create_role( + self.access_mut(), + cap, + role, + permissions, + copy role_tags, + clock, + ctx, + ); + + if (role_tags.is_some()) { + let tags = role_tags.borrow().tags().keys(); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + self.tags.increment_usage_count(&tags[i]); + i = i + 1; + }; + }; +} + +/// Updates the permissions and record-tag allowlist of an existing role. +/// +/// Tag usage counters are adjusted to reflect the difference between the old and the +/// new role-tag sets. +/// +/// Requires a capability granting the `UpdateRoles` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `tf_components::role_map::ERoleDoesNotExist` when `role` is not defined on +/// the trail. +/// * `tf_components::role_map::EInitialAdminPermissionsInconsistent` when updating +/// the initial-admin role with `new_permissions` that does not include every +/// permission configured in the trail's role- and capability-admin permission sets. +/// * `ERecordTagNotDefined` when any tag in the new `role_tags` is not in the +/// trail's tag registry. +/// +/// Emits a `tf_components::role_map::RoleUpdated` event on success. +public fun update_role_permissions( + self: &mut AuditTrail, + cap: &Capability, + role: String, + new_permissions: VecSet, + role_tags: Option, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + + assert!(self.tags.contains_all_role_tags(&role_tags), ERecordTagNotDefined); + let old_record_tags = *role_map::get_role_data(self.access(), &role); + role_map::update_role( + self.access_mut(), + cap, + &role, + new_permissions, + copy role_tags, + clock, + ctx, + ); + + if (old_record_tags.is_some()) { + let tags = old_record_tags.borrow().tags().keys(); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + self.tags.decrement_usage_count(&tags[i]); + i = i + 1; + }; + }; + + if (role_tags.is_some()) { + let tags = role_tags.borrow().tags().keys(); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + self.tags.increment_usage_count(&tags[i]); + i = i + 1; + }; + }; +} + +/// Deletes an existing role from the trail. +/// +/// Decrements the usage count of every tag that was referenced by the role's +/// `RoleTags`. The reserved initial-admin role (`INITIAL_ADMIN_ROLE_NAME`) cannot be +/// deleted. +/// +/// Requires a capability granting the `DeleteRoles` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `tf_components::role_map::ERoleDoesNotExist` when `role` is not defined on +/// the trail. +/// * `tf_components::role_map::EInitialAdminRoleCannotBeDeleted` when targeting the +/// reserved initial-admin role. +/// +/// Emits a `tf_components::role_map::RoleDeleted` event on success. +public fun delete_role( + self: &mut AuditTrail, + cap: &Capability, + role: String, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + let old_record_tags = *role_map::get_role_data(self.access(), &role); + role_map::delete_role(self.access_mut(), cap, &role, clock, ctx); + + if (old_record_tags.is_some()) { + let tags = old_record_tags.borrow().tags().keys(); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + self.tags.decrement_usage_count(&tags[i]); + i = i + 1; + }; + }; +} + +/// Issues a new capability for an existing role and transfers it to its recipient. +/// +/// The capability object is transferred to `issued_to` if provided, otherwise to the +/// caller. `valid_from` and `valid_until` (milliseconds since the Unix epoch) configure +/// usage restrictions that are enforced on-chain whenever the capability is later +/// presented for authorization. +/// +/// Requires a capability granting the `AddCapabilities` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `tf_components::role_map::ERoleDoesNotExist` when `role` is not defined on +/// the trail. +/// * `tf_components::capability::EValidityPeriodInconsistent` when `valid_from` +/// and `valid_until` are not consistent. +/// +/// Emits a `tf_components::role_map::CapabilityIssued` event on success. +/// +/// Returns the same receipt that is emitted as the `tf_components::role_map::CapabilityIssued` event. +public fun new_capability( + self: &mut AuditTrail, + cap: &Capability, + role: String, + issued_to: Option
, + valid_from: Option, + valid_until: Option, + clock: &Clock, + ctx: &mut TxContext, +): CapabilityIssuedReceipt { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + + let recipient = if (issued_to.is_some()) { + let address_ref = issued_to.borrow(); + *address_ref + } else { + ctx.sender() + }; + + let new_cap = role_map::new_capability( + self.access_mut(), + cap, + &role, + issued_to, + valid_from, + valid_until, + clock, + ctx, + ); + let output = CapabilityIssuedReceipt { + target_key: self.id(), + capability_id: new_cap.id(), + role: *new_cap.role(), + issued_to: *new_cap.issued_to(), + valid_from: *new_cap.valid_from(), + valid_until: *new_cap.valid_until(), + }; + transfer::public_transfer(new_cap, recipient); + output +} + +/// Revokes an issued capability by ID. +/// +/// Writes `cap_to_revoke` into the trail's revoked-capability denylist. +/// `cap_to_revoke_valid_until` should be the capability's original expiry so that +/// `cleanup_revoked_capabilities` can later prune the entry once that timestamp has +/// elapsed; pass `option::none()` (encoded as `0`) to keep the entry permanently. +/// +/// The function does not verify that `cap_to_revoke` actually identifies an existing +/// capability issued by this trail — any ID will be accepted and stored. Callers are +/// expected to track issued capability IDs (and their optional expiries) off-chain: +/// the trail uses a denylist, not an allowlist, to keep storage costs low when many +/// capabilities are issued. +/// +/// Initial admin capabilities cannot be revoked via this function; use +/// `revoke_initial_admin_capability` instead. +/// +/// Requires a capability granting the `RevokeCapabilities` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `tf_components::role_map::ECapabilityToRevokeHasAlreadyBeenRevoked` when +/// `cap_to_revoke` is already on the denylist. +/// * `tf_components::role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed` +/// when `cap_to_revoke` identifies an initial admin capability. +/// +/// Emits a `tf_components::role_map::CapabilityRevoked` event on success. +public fun revoke_capability( + self: &mut AuditTrail, + cap: &Capability, + cap_to_revoke: ID, + cap_to_revoke_valid_until: Option, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::revoke_capability( + self.access_mut(), + cap, + cap_to_revoke, + cap_to_revoke_valid_until, + clock, + ctx, + ); +} + +/// Destroys a capability object and removes any matching entry from the denylist. +/// +/// If `cap_to_destroy` is currently on the trail's revoked-capability denylist, its +/// entry is removed as part of the destruction. Initial admin capabilities cannot be +/// destroyed via this function; use `destroy_initial_admin_capability` instead. +/// +/// Requires a capability granting the `RevokeCapabilities` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `tf_components::role_map::ECapabilityTargetKeyMismatch` when `cap_to_destroy` +/// was not issued for this trail. +/// * `tf_components::role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed` +/// when `cap_to_destroy` is an initial admin capability. +/// +/// Emits a `tf_components::role_map::CapabilityDestroyed` event on success. +public fun destroy_capability( + self: &mut AuditTrail, + cap: &Capability, + cap_to_destroy: Capability, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self + .roles + .assert_capability_valid( + cap, + &permission::revoke_capabilities(), + clock, + ctx, + ); + role_map::destroy_capability(self.access_mut(), cap_to_destroy); +} + +/// Destroys an initial admin capability owned by the caller. +/// +/// Self-service operation: the owner passes in their own initial admin capability; +/// no additional authorization is required. If the capability is currently on the +/// trail's revoked-capability denylist, its entry is removed as part of the +/// destruction. +/// +/// WARNING: If all initial admin capabilities are destroyed, the trail will be +/// permanently sealed with no admin access possible. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * `tf_components::role_map::ECapabilityTargetKeyMismatch` when `cap_to_destroy` +/// was not issued for this trail. +/// * `tf_components::role_map::ECapabilityIsNotInitialAdmin` when `cap_to_destroy` +/// is not an initial admin capability. +/// +/// Emits a `tf_components::role_map::CapabilityDestroyed` event on success. +public fun destroy_initial_admin_capability( + self: &mut AuditTrail, + cap_to_destroy: Capability, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::destroy_initial_admin_capability(self.access_mut(), cap_to_destroy); +} + +/// Revokes an initial admin capability by ID. +/// +/// See `revoke_capability` for the meaning of `cap_to_revoke` and +/// `cap_to_revoke_valid_until` and the off-chain tracking of issued capabilities the +/// trail expects. +/// +/// WARNING: If all initial admin capabilities are revoked, the trail will be +/// permanently sealed with no admin access possible. +/// +/// Requires a capability granting the `RevokeCapabilities` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// * `tf_components::role_map::ECapabilityIsNotInitialAdmin` when `cap_to_revoke` +/// does not identify an initial admin capability. +/// * `tf_components::role_map::ECapabilityToRevokeHasAlreadyBeenRevoked` when it is +/// already on the denylist. +/// +/// Emits a `tf_components::role_map::CapabilityRevoked` event on success. +public fun revoke_initial_admin_capability( + self: &mut AuditTrail, + cap: &Capability, + cap_to_revoke: ID, + cap_to_revoke_valid_until: Option, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::revoke_initial_admin_capability( + self.access_mut(), + cap, + cap_to_revoke, + cap_to_revoke_valid_until, + clock, + ctx, + ); +} + +/// Removes already-expired entries from the trail's revoked-capability denylist. +/// +/// Iterates through the denylist and removes every entry whose `valid_until` +/// timestamp is non-zero and less than the current clock time. Entries with +/// `valid_until == 0` (capabilities that had no expiry) are kept since they remain +/// potentially valid and must stay on the denylist. See `revoke_capability` for the +/// rationale behind off-chain tracking of issued capabilities. +/// +/// Requires a capability granting the `RevokeCapabilities` permission. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * any error documented by `RoleMap::assert_capability_valid` when `cap` fails +/// authorization checks. +/// +/// Emits a RevokedCapabilitiesCleanedUp event on success. +/// +/// Returns the same receipt that is emitted as the `RevokedCapabilitiesCleanedUp` event. +public fun cleanup_revoked_capabilities( + self: &mut AuditTrail, + cap: &Capability, + clock: &Clock, + ctx: &TxContext, +): RevokedCapabilitiesCleanedUp { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + let revoked_count_before = linked_table::length(role_map::revoked_capabilities(self.access())); + self + .access_mut() + .cleanup_revoked_capabilities( + cap, + clock, + ctx, + ); + let revoked_count_after = linked_table::length(role_map::revoked_capabilities(self.access())); + let output = RevokedCapabilitiesCleanedUp { + trail_id: self.id(), + cleaned_count: revoked_count_before - revoked_count_after, + cleaned_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }; + event::emit(copy output); + output +} + +// ===== Trail Query Functions ===== + +/// Returns the total number of records currently stored in the trail. +public fun record_count(self: &AuditTrail): u64 { + linked_table::length(&self.records) +} + +/// Returns the next sequence number that will be assigned to a new record. +/// +/// The sequence number is a monotonic counter that never decrements. +public fun sequence_number(self: &AuditTrail): u64 { + self.sequence_number +} + +/// Returns the address that created this trail. +public fun creator(self: &AuditTrail): address { + self.creator +} + +/// Returns the trail's creation timestamp in milliseconds since the Unix epoch. +public fun created_at(self: &AuditTrail): u64 { + self.created_at +} + +/// Returns the trail's on-chain object ID. +public fun id(self: &AuditTrail): ID { + object::uid_to_inner(&self.id) +} + +/// Returns the trail's immutable name from `ImmutableMetadata`, when set. +public fun name(self: &AuditTrail): Option { + self.immutable_metadata.map!(|metadata| metadata.name) +} + +/// Returns the trail's immutable description from `ImmutableMetadata`, when set. +public fun description(self: &AuditTrail): Option { + if (self.immutable_metadata.is_some()) { + option::borrow(&self.immutable_metadata).description + } else { + option::none() + } +} + +/// Returns a reference to the trail's mutable `updatable_metadata` field. +public fun metadata(self: &AuditTrail): &Option { + &self.updatable_metadata +} + +/// Returns a reference to the trail's `LockingConfig`. +public fun locking_config(self: &AuditTrail): &LockingConfig { + &self.locking_config +} + +/// Returns a reference to the trail's record-tag registry with combined usage counts. +public fun tags(self: &AuditTrail): &TagRegistry { + &self.tags +} + +/// Checks whether the trail contains any records. +/// +/// Returns `true` when the trail's record list is empty. +public fun is_empty(self: &AuditTrail): bool { + linked_table::is_empty(&self.records) +} + +/// Returns the sequence number of the first record in the trail, when any. +public fun first_sequence(self: &AuditTrail): Option { + *linked_table::front(&self.records) +} + +/// Returns the sequence number of the last record in the trail, when any. +public fun last_sequence(self: &AuditTrail): Option { + *linked_table::back(&self.records) +} + +// ===== Record Query Functions ===== + +/// Returns a reference to the record stored at `sequence_number`. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// * `ERecordNotFound` when no record exists at `sequence_number`. +public fun get_record(self: &AuditTrail, sequence_number: u64): &Record { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + linked_table::borrow(&self.records, sequence_number) +} + +/// Checks whether a record exists at the given sequence number. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +/// +/// Returns `true` when a record is stored at `sequence_number`. +public fun has_record(self: &AuditTrail, sequence_number: u64): bool { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + linked_table::contains(&self.records, sequence_number) +} + +/// Returns a reference to the trail's record table indexed by sequence number. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +public fun records(self: &AuditTrail): &LinkedTable> { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + &self.records +} +// ===== Access Control Functions ===== + +/// Returns a reference to the `RoleMap` managing roles and capabilities for this trail. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +public fun access(self: &AuditTrail): &RoleMap { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + &self.roles +} + +/// Returns a mutable reference to the `RoleMap` managing roles and capabilities for this trail. +/// +/// Aborts with: +/// * `EPackageVersionMismatch` when the trail is at a different package version. +public(package) fun access_mut( + self: &mut AuditTrail, +): &mut RoleMap { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + &mut self.roles +} diff --git a/audit-trail-move/sources/locking.move b/audit-trail-move/sources/locking.move new file mode 100644 index 00000000..171b979c --- /dev/null +++ b/audit-trail-move/sources/locking.move @@ -0,0 +1,222 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Locking configuration for audit trail records +module audit_trails::locking; + +use iota::clock::Clock; +use tf_components::timelock::{Self, TimeLock}; + +// ===== Errors ===== + +/// UntilDestroyed cannot be used for trail deletion protection. +const EUntilDestroyedNotSupportedForDeleteTrail: u64 = 0; + +/// A count-based locking window must protect at least one record. +const ECountWindowMustBePositive: u64 = 1; + +/// Defines a delete-record locking window (time-based, count-based, or none). +/// +/// A window describes the period during which a record stays locked against +/// deletion. Records outside the window may be deleted, subject to remaining +/// permission and tag checks. +public enum LockingWindow has copy, drop, store { + None, + TimeBased { seconds: u64 }, + /// Locks the last `count` records currently present in trail order. + CountBased { count: u64 }, +} + +/// Top-level locking configuration for the audit trail +public struct LockingConfig has drop, store { + /// Locking rules for record deletion + delete_record_window: LockingWindow, + /// Timelock protecting deletion of the trail itself + delete_trail_lock: TimeLock, + /// Timelock protecting record writes (add_record) + write_lock: TimeLock, +} + +// ===== LockingWindow Constructors ===== + +/// Creates a locking window that imposes no time- or count-based restrictions. +/// +/// Returns the `LockingWindow::None` variant. +public fun window_none(): LockingWindow { + LockingWindow::None +} + +/// Creates a time-based locking window. +/// +/// Records that were added less than `seconds` seconds ago are considered locked. +/// +/// Returns the `LockingWindow::TimeBased` variant. +public fun window_time_based(seconds: u64): LockingWindow { + LockingWindow::TimeBased { seconds } +} + +/// Creates a count-based locking window. +/// +/// The trail locks the last `count` records currently present in linked-table +/// order. To express "no deletion lock", use `window_none()` instead of +/// passing `count == 0`. +/// +/// Aborts +/// ------ +/// * `ECountWindowMustBePositive` if `count == 0`. A zero-count window would +/// protect no records and is functionally identical to `window_none()`; +/// rejecting it at construction prevents silently misconfigured trails. +/// +/// Returns the `LockingWindow::CountBased` variant. +public fun window_count_based(count: u64): LockingWindow { + assert!(count > 0, ECountWindowMustBePositive); + LockingWindow::CountBased { count } +} + +// ===== LockingConfig Constructors ===== + +/// Creates a new locking configuration. +/// +/// `TimeLock::UntilDestroyed` is reserved for the write lock and is not accepted as +/// `delete_trail_lock`. +/// +/// Aborts with: +/// * `EUntilDestroyedNotSupportedForDeleteTrail` when `delete_trail_lock` is +/// `TimeLock::UntilDestroyed`. +/// +/// Returns the constructed `LockingConfig`. +public fun new( + delete_record_window: LockingWindow, + delete_trail_lock: TimeLock, + write_lock: TimeLock, +): LockingConfig { + assert!( + !timelock::is_until_destroyed(&delete_trail_lock), + EUntilDestroyedNotSupportedForDeleteTrail, + ); + + LockingConfig { + delete_record_window, + delete_trail_lock, + write_lock, + } +} + +// ===== LockingWindow Getters ===== + +/// Returns the time window in seconds when `window` is `LockingWindow::TimeBased`, +/// otherwise `option::none()`. +public(package) fun time_window_seconds(window: &LockingWindow): Option { + match (window) { + LockingWindow::TimeBased { seconds } => option::some(*seconds), + _ => option::none(), + } +} + +/// Returns the count window when `window` is `LockingWindow::CountBased`, +/// otherwise `option::none()`. +public(package) fun count_window(window: &LockingWindow): Option { + match (window) { + LockingWindow::CountBased { count } => option::some(*count), + _ => option::none(), + } +} + +// ===== LockingConfig Getters ===== + +/// Returns a reference to the configuration's record-deletion locking window. +public(package) fun delete_record_window(config: &LockingConfig): &LockingWindow { + &config.delete_record_window +} + +/// Returns a reference to the configuration's trail-deletion timelock. +public(package) fun delete_trail_lock(config: &LockingConfig): &TimeLock { + &config.delete_trail_lock +} + +/// Returns a reference to the configuration's write timelock. +public(package) fun write_lock(config: &LockingConfig): &TimeLock { + &config.write_lock +} + +// ===== LockingConfig Setters ===== + +/// Sets the configuration's record-deletion locking window to `window`. +public(package) fun set_delete_record_window(config: &mut LockingConfig, window: LockingWindow) { + config.delete_record_window = window; +} + +/// Sets the configuration's trail-deletion timelock to `lock`. +/// +/// `TimeLock::UntilDestroyed` is reserved for the write lock and is not accepted here. +/// +/// Aborts with: +/// * `EUntilDestroyedNotSupportedForDeleteTrail` when `lock` is +/// `TimeLock::UntilDestroyed`. +public(package) fun set_delete_trail_lock(config: &mut LockingConfig, lock: TimeLock) { + assert!(!timelock::is_until_destroyed(&lock), EUntilDestroyedNotSupportedForDeleteTrail); + + config.delete_trail_lock = lock; +} + +/// Sets the configuration's write timelock to `lock`. +public(package) fun set_write_lock(config: &mut LockingConfig, lock: TimeLock) { + config.write_lock = lock; +} + +/// Replaces the entire locking configuration with `new_config`. +/// +/// Internally applies `set_delete_record_window`, `set_delete_trail_lock` and +/// `set_write_lock`, so the constraints documented for those setters apply. +/// +/// Aborts with: +/// * `EUntilDestroyedNotSupportedForDeleteTrail` when +/// `new_config.delete_trail_lock` is `TimeLock::UntilDestroyed`. +public(package) fun set_config(config: &mut LockingConfig, new_config: LockingConfig) { + let LockingConfig { + delete_record_window, + delete_trail_lock, + write_lock, + } = new_config; + + set_delete_record_window(config, delete_record_window); + set_delete_trail_lock(config, delete_trail_lock); + set_write_lock(config, write_lock); +} + +// ===== Locking Logic (LockingWindow) ===== + +/// Checks whether a record is locked by the time-based window. +/// +/// Returns `true` when `window` is `LockingWindow::TimeBased` and the record's age +/// is below the configured number of seconds. +public(package) fun is_time_locked( + window: &LockingWindow, + record_timestamp: u64, + current_time: u64, +): bool { + match (window) { + LockingWindow::TimeBased { seconds } => { + let time_window_ms = (*seconds) * 1000; + let record_age = current_time - record_timestamp; + record_age < time_window_ms + }, + _ => false, + } +} + +// ===== Locking Logic (LockingConfig) ===== + +/// Checks whether trail deletion is currently blocked by `delete_trail_lock`. +/// +/// Returns `true` while the configured timelock has not yet elapsed. +public fun is_delete_trail_locked(config: &LockingConfig, clock: &Clock): bool { + timelock::is_timelocked(delete_trail_lock(config), clock) +} + +/// Checks whether record writes are currently blocked by `write_lock`. +/// +/// Returns `true` while the configured timelock has not yet elapsed. +public fun is_write_locked(config: &LockingConfig, clock: &Clock): bool { + timelock::is_timelocked(write_lock(config), clock) +} diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move new file mode 100644 index 00000000..63166d7c --- /dev/null +++ b/audit-trail-move/sources/permission.move @@ -0,0 +1,298 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Permission system for role-based access control +module audit_trails::permission; + +use iota::vec_set::{Self, VecSet}; + +/// Existing permissions for the `AuditTrail` object +public enum Permission has copy, drop, store { + // --- Whole Audit Trail related - Proposed role: `Admin` --- + /// Destroy the whole `AuditTrail` object + DeleteAuditTrail, + /// Delete records in batches for cleanup workflows + DeleteAllRecords, + // --- Record Management - Proposed role: `RecordAdmin` --- + /// Add records to the trail + AddRecord, + /// Delete records from the trail + DeleteRecord, + /// Correct existing records in the trail + CorrectRecord, + // --- Locking Config - Proposed role: `LockingAdmin` --- + /// Update the whole locking configuration + UpdateLockingConfig, + /// Update the delete_record_lock configuration which is part of the locking configuration + UpdateLockingConfigForDeleteRecord, + /// Update the delete_lock configuration for the whole `AuditTrail` object + UpdateLockingConfigForDeleteTrail, + /// Update the write_lock configuration for the whole `AuditTrail` object + UpdateLockingConfigForWrite, + // --- Role Management - Proposed role: `RoleAdmin` --- + /// Add new roles with associated permissions + AddRoles, + /// Update permissions associated with existing roles + UpdateRoles, + /// Delete existing roles + DeleteRoles, + // --- Capability Management - Proposed role: `CapAdmin` --- + /// Issue new capabilities + AddCapabilities, + /// Revoke existing capabilities + RevokeCapabilities, + // --- Meta Data related - Proposed role: `MetadataAdmin` --- + /// Update the updatable metadata field + UpdateMetadata, + /// Delete the updatable metadata field + DeleteMetadata, + /// Migrate the audit trail to a new version of the contract + Migrate, + // --- Record Tag Management - Proposed role: `TagAdmin` --- + /// Add new record tags to the trail registry + AddRecordTags, + /// Remove record tags from the trail registry + DeleteRecordTags, +} + +/// Creates an empty permission set. +/// +/// Returns an empty `VecSet`. +public fun empty(): VecSet { + vec_set::empty() +} + +/// Inserts `perm` into the permission set. +/// +/// Aborts with: +/// * an internal `iota::vec_set` error when `perm` is already present in `set`. +public fun add(set: &mut VecSet, perm: Permission) { + vec_set::insert(set, perm); +} + +/// Builds a permission set from a vector of permissions. +/// +/// Aborts with: +/// * an internal `iota::vec_set` error when `perms` contains duplicates. +/// +/// Returns a `VecSet` containing every entry of `perms`. +public fun from_vec(perms: vector): VecSet { + let mut set = vec_set::empty(); + let mut i = 0; + let len = perms.length(); + while (i < len) { + vec_set::insert(&mut set, perms[i]); + i = i + 1; + }; + set +} + +/// Checks whether the permission set contains the given permission. +/// +/// Returns `true` when `perm` is in `set`. +public fun has_permission(set: &VecSet, perm: &Permission): bool { + vec_set::contains(set, perm) +} + +// ------ Functions creating permission sets for often used roles --------- + +/// Creates the permission set typically used for the `Admin` role. +/// +/// Includes the following permissions: +/// * `AddCapabilities` +/// * `RevokeCapabilities` +/// * `AddRecordTags` +/// * `DeleteRecordTags` +/// * `AddRoles` +/// * `UpdateRoles` +/// * `DeleteRoles` +/// * `Migrate` +public fun admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(add_capabilities()); + perms.insert(revoke_capabilities()); + perms.insert(add_record_tags()); + perms.insert(delete_record_tags()); + perms.insert(add_roles()); + perms.insert(update_roles()); + perms.insert(delete_roles()); + perms.insert(migrate_audit_trail()); + perms +} + +/// Creates the permission set typically used for the `RecordAdmin` role. +/// +/// Includes the following permissions: +/// * `AddRecord` +/// * `DeleteRecord` +/// * `CorrectRecord` +public fun record_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(add_record()); + perms.insert(delete_record()); + perms.insert(correct_record()); + perms +} + +/// Creates the permission set typically used for the `LockingAdmin` role. +/// +/// Includes the following permissions: +/// * `UpdateLockingConfig` +/// * `UpdateLockingConfigForDeleteTrail` +/// * `UpdateLockingConfigForDeleteRecord` +/// * `UpdateLockingConfigForWrite` +public fun locking_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(update_locking_config()); + perms.insert(update_locking_config_for_delete_trail()); + perms.insert(update_locking_config_for_delete_record()); + perms.insert(update_locking_config_for_write()); + perms +} + +/// Creates the permission set typically used for the `RoleAdmin` role. +/// +/// Includes the following permissions: +/// * `AddRoles` +/// * `UpdateRoles` +/// * `DeleteRoles` +public fun role_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(add_roles()); + perms.insert(update_roles()); + perms.insert(delete_roles()); + perms +} + +/// Creates the permission set typically used for the `TagAdmin` role. +/// +/// Includes the following permissions: +/// * `AddRecordTags` +/// * `DeleteRecordTags` +public fun tag_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(add_record_tags()); + perms.insert(delete_record_tags()); + perms +} + +/// Creates the permission set typically used for the `CapAdmin` role. +/// +/// Includes the following permissions: +/// * `AddCapabilities` +/// * `RevokeCapabilities` +public fun cap_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(add_capabilities()); + perms.insert(revoke_capabilities()); + perms +} + +/// Creates the permission set typically used for the `MetadataAdmin` role. +/// +/// Includes the following permissions: +/// * `UpdateMetadata` +/// * `DeleteMetadata` +public fun metadata_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(update_metadata()); + perms.insert(delete_metadata()); + perms +} + +// ------- Constructor functions for all Permission variants ------------- + +/// Returns a permission allowing to destroy the whole `AuditTrail` object +public fun delete_audit_trail(): Permission { + Permission::DeleteAuditTrail +} + +/// Returns a permission allowing to delete records in batches +public fun delete_all_records(): Permission { + Permission::DeleteAllRecords +} + +/// Returns a permission allowing to add records to the trail +public fun add_record(): Permission { + Permission::AddRecord +} + +/// Returns a permission allowing to delete records from the trail +public fun delete_record(): Permission { + Permission::DeleteRecord +} + +/// Returns a permission allowing to correct existing records in the trail +public fun correct_record(): Permission { + Permission::CorrectRecord +} + +/// Returns a permission allowing to update the whole locking configuration +public fun update_locking_config(): Permission { + Permission::UpdateLockingConfig +} + +/// Returns a permission allowing to update the delete_lock configuration for records +public fun update_locking_config_for_delete_record(): Permission { + Permission::UpdateLockingConfigForDeleteRecord +} + +/// Returns a permission allowing to update the delete_lock configuration for the whole `AuditTrail` object +public fun update_locking_config_for_delete_trail(): Permission { + Permission::UpdateLockingConfigForDeleteTrail +} + +/// Returns a permission allowing to update the write_lock configuration for the whole `AuditTrail` object +public fun update_locking_config_for_write(): Permission { + Permission::UpdateLockingConfigForWrite +} + +/// Returns a permission allowing to add new record tags to the trail registry +public fun add_record_tags(): Permission { + Permission::AddRecordTags +} + +/// Returns a permission allowing to remove record tags from the trail registry +public fun delete_record_tags(): Permission { + Permission::DeleteRecordTags +} + +/// Returns a permission allowing to add new roles with associated permissions +public fun add_roles(): Permission { + Permission::AddRoles +} + +/// Returns a permission allowing to update permissions associated with existing roles +public fun update_roles(): Permission { + Permission::UpdateRoles +} + +/// Returns a permission allowing to delete existing roles +public fun delete_roles(): Permission { + Permission::DeleteRoles +} + +/// Returns a permission allowing to issue new capabilities +public fun add_capabilities(): Permission { + Permission::AddCapabilities +} + +/// Returns a permission allowing to revoke existing capabilities +public fun revoke_capabilities(): Permission { + Permission::RevokeCapabilities +} + +/// Returns a permission allowing to update the updatable_metadata field +public fun update_metadata(): Permission { + Permission::UpdateMetadata +} + +/// Returns a permission allowing to delete the updatable_metadata field +public fun delete_metadata(): Permission { + Permission::DeleteMetadata +} + +/// Returns a permission allowing to migrate the audit trail to a new version of the contract +public fun migrate_audit_trail(): Permission { + Permission::Migrate +} diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move new file mode 100644 index 00000000..c15c24d9 --- /dev/null +++ b/audit-trail-move/sources/record.move @@ -0,0 +1,252 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Record module for audit trail entries +/// +/// A Record represents a single entry in an audit trail, stored in a +/// LinkedTable and addressed by trail_id + sequence_number. +module audit_trails::record; + +use iota::vec_set::{Self, VecSet}; +use std::string::String; + +/// Flexible record payload that can store either raw bytes or text. +public enum Data has copy, drop, store { + Bytes(vector), + Text(String), +} + +/// Creates a `Data` value carrying the given byte payload. +/// +/// Returns the `Data::Bytes` variant. +public fun new_bytes(bytes: vector): Data { + Data::Bytes(bytes) +} + +/// Creates a `Data` value carrying the given text payload. +/// +/// Returns the `Data::Text` variant. +public fun new_text(text: String): Data { + Data::Text(text) +} + +/// Extracts the byte payload from a `Data` value when present. +/// +/// Returns `option::some(bytes)` when `data` is `Data::Bytes`, otherwise +/// `option::none()`. +public fun bytes(data: &Data): Option> { + match (data) { + Data::Bytes(bytes) => option::some(*bytes), + Data::Text(_) => option::none(), + } +} + +/// Extracts the text payload from a `Data` value when present. +/// +/// Returns `option::some(text)` when `data` is `Data::Text`, otherwise +/// `option::none()`. +public fun text(data: &Data): Option { + match (data) { + Data::Bytes(_) => option::none(), + Data::Text(text) => option::some(*text), + } +} + +/// A single record in the audit trail +public struct Record has store { + /// Arbitrary data stored on-chain + data: D, + /// Optional metadata for this specific record + metadata: Option, + /// Optional immutable tag associated with this record + tag: Option, + /// Position in the trail (0-indexed, never reused) + sequence_number: u64, + /// Who added this record + added_by: address, + /// When this record was added (milliseconds) + added_at: u64, + /// Correction tracker for this record + correction: RecordCorrection, +} + +/// Input used when creating a trail with an initial record. +public struct InitialRecord has copy, drop, store { + data: D, + metadata: Option, + tag: Option, +} + +// ===== Constructors ===== + +/// Creates an `InitialRecord` to be passed to `audit_trails::create`. +/// +/// Returns the constructed `InitialRecord`. +public fun new_initial_record( + data: D, + metadata: Option, + tag: Option, +): InitialRecord { + InitialRecord { data, metadata, tag } +} + +/// Creates a new `Record` from its constituent fields. +/// +/// Returns the constructed `Record`. +public(package) fun new( + data: D, + metadata: Option, + tag: Option, + sequence_number: u64, + added_by: address, + added_at: u64, + correction: RecordCorrection, +): Record { + Record { + data, + metadata, + tag, + sequence_number, + added_by, + added_at, + correction, + } +} + +/// Converts an `InitialRecord` into a stored `Record` with an empty correction tracker. +/// +/// Returns the resulting `Record` ready to be inserted into the trail's record table. +public(package) fun into_record( + initial_record: InitialRecord, + sequence_number: u64, + added_by: address, + added_at: u64, +): Record { + let InitialRecord { data, metadata, tag } = initial_record; + new( + data, + metadata, + tag, + sequence_number, + added_by, + added_at, + empty(), + ) +} + +// ===== Getters ===== + +/// Returns a reference to the data payload stored in the record. +public fun data(self: &Record): &D { + &self.data +} + +/// Returns a reference to the record's optional metadata field. +public fun metadata(self: &Record): &Option { + &self.metadata +} + +/// Returns a reference to the record's optional tag field. +public fun tag(record: &Record): &Option { + &record.tag +} + +/// Returns the record's position in the trail (zero-indexed sequence number). +public fun sequence_number(self: &Record): u64 { + self.sequence_number +} + +/// Returns the address that added the record to the trail. +public fun added_by(self: &Record): address { + self.added_by +} + +/// Returns the record's creation timestamp in milliseconds since the Unix epoch. +public fun added_at(self: &Record): u64 { + self.added_at +} + +/// Returns a reference to the record's bidirectional correction tracker. +public fun correction(self: &Record): &RecordCorrection { + &self.correction +} + +/// Destroys a `Record` by destructuring it. +public(package) fun destroy(self: Record) { + let Record { + data: _, + metadata: _, + tag: _, + sequence_number: _, + added_by: _, + added_at: _, + correction: _, + } = self; +} + +/// Bidirectional correction tracking for audit records +public struct RecordCorrection has copy, drop, store { + replaces: VecSet, + is_replaced_by: Option, +} + +/// Creates an empty correction tracker for a record that does not correct any other. +/// +/// Returns a `RecordCorrection` with an empty `replaces` set and no +/// `is_replaced_by` reference. +public fun empty(): RecordCorrection { + RecordCorrection { + replaces: vec_set::empty(), + is_replaced_by: option::none(), + } +} + +/// Creates a correction tracker for a record that replaces other records. +/// +/// Returns a `RecordCorrection` whose `replaces` set is `replaced_seq_nums` and +/// whose `is_replaced_by` is unset. +public fun with_replaces(replaced_seq_nums: VecSet): RecordCorrection { + RecordCorrection { + replaces: replaced_seq_nums, + is_replaced_by: option::none(), + } +} + +/// Returns a reference to the set of sequence numbers this record replaces. +public fun replaces(correction: &RecordCorrection): &VecSet { + &correction.replaces +} + +/// Returns the sequence number of the record that replaced this one, when any. +public fun is_replaced_by(correction: &RecordCorrection): Option { + correction.is_replaced_by +} + +/// Checks whether this record corrects (replaces) at least one other record. +/// +/// Returns `true` when `replaces` is non-empty. +public fun is_correction(correction: &RecordCorrection): bool { + !vec_set::is_empty(&correction.replaces) +} + +/// Checks whether this record has been replaced by another record. +/// +/// Returns `true` when `is_replaced_by` is set. +public fun is_replaced(correction: &RecordCorrection): bool { + correction.is_replaced_by.is_some() +} + +/// Records that this record has been replaced by `replacement_seq`. +public(package) fun set_replaced_by(correction: &mut RecordCorrection, replacement_seq: u64) { + correction.is_replaced_by = option::some(replacement_seq); +} + +/// Adds `seq_num` to the set of records this record replaces. +public(package) fun add_replaces(correction: &mut RecordCorrection, seq_num: u64) { + correction.replaces.insert(seq_num); +} + +/// Destroys a `RecordCorrection` by destructuring it. +public(package) fun destroy_record_correction(correction: RecordCorrection) { + let RecordCorrection { replaces: _, is_replaced_by: _ } = correction; +} diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move new file mode 100644 index 00000000..b59acdd1 --- /dev/null +++ b/audit-trail-move/sources/record_tags.move @@ -0,0 +1,180 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Record tag types and helper predicates for Audit Trails. +module audit_trails::record_tags; + +use audit_trails::permission::Permission; +use iota::{vec_map::{Self, VecMap}, vec_set::{Self, VecSet}}; +use std::string::String; +use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; + +// ----------- RoleTags ------- + +/// Stores all record tag related data associated with a role in the RoleMap. +/// Contains a list of allowlisted tags for the role. +public struct RoleTags has copy, drop, store { + tags: VecSet, +} + +/// Creates a new `RoleTags` allowlisting the given record tags. +/// +/// Returns the constructed `RoleTags`. +public fun new_role_tags(tags: vector): RoleTags { + RoleTags { + tags: vec_set::from_keys(tags), + } +} + +/// Returns a reference to the set of record tags allowlisted by this `RoleTags`. +public fun tags(self: &RoleTags): &VecSet { + &self.tags +} + +// ----------- TagRegistry ------- + +/// A registry of tags available for use on an audit trail, along with usage counts +/// to track how many records and roles are currently using each tag. +/// Usage counts for roles and tags are summed and build a combined usage count. +public struct TagRegistry has copy, drop, store { + tag_map: VecMap, +} + +/// Returns a reference to the registry's mapping of tag names to combined usage counts. +public fun tag_map(self: &TagRegistry): &VecMap { + &self.tag_map +} + +/// Creates a `TagRegistry` listing the given tags with zero usage counts. +/// +/// Returns the constructed `TagRegistry`. +public(package) fun new_tag_registry(mut tags: vector): TagRegistry { + let mut usage = vec_map::empty(); + tags.reverse(); + + while (tags.length() != 0) { + vec_map::insert(&mut usage, tags.pop_back(), 0); + }; + + TagRegistry { tag_map: usage } +} + +/// Destroys the `TagRegistry`. +/// +/// Empties the internal tag map and then destroys the empty container. +public(package) fun destroy(mut self: TagRegistry) { + while (!self.tag_map.is_empty()) { + let (_, _) = self.tag_map.pop(); + }; + self.tag_map.destroy_empty(); +} + +/// Inserts `tag` into the registry with the given initial `usage_count`. +public(package) fun insert_tag(self: &mut TagRegistry, tag: String, usage_count: u64) { + self.tag_map.insert(tag, usage_count); +} + +/// Removes `tag` from the registry. +public(package) fun remove_tag(self: &mut TagRegistry, tag: &String) { + self.tag_map.remove(tag); +} + +/// Returns the set of tag names currently registered, as a `vector`. +public(package) fun tag_keys(self: &TagRegistry): vector { + iota::vec_map::keys(&self.tag_map) +} + +/// Checks whether every tag listed in `role_tags` is registered. +/// +/// `option::none()` is treated as the empty set and trivially satisfies the check. +/// +/// Returns `true` when every tag in `role_tags` is contained in the registry, or +/// when `role_tags` is `option::none()`. +public(package) fun contains_all_role_tags(self: &TagRegistry, role_tags: &Option): bool { + if (!role_tags.is_some()) { + return true + }; + + let tags = &option::borrow(role_tags).tags; + let allowed_tag_keys = iota::vec_set::keys(tags); + let mut i = 0; + let tag_count = allowed_tag_keys.length(); + + while (i < tag_count) { + if (!iota::vec_map::contains(&self.tag_map, &allowed_tag_keys[i])) { + return false + }; + i = i + 1; + }; + + true +} + +/// Checks whether `tag` is registered in the `TagRegistry`. +/// +/// Returns `true` when `tag` is present. +public(package) fun contains(self: &TagRegistry, tag: &String): bool { + iota::vec_map::contains(&self.tag_map, tag) +} + +/// Returns the combined usage count (sum of role and record usages) for `tag`. +/// +/// Returns `option::none()` when `tag` is not in the registry. +public(package) fun usage_count(self: &TagRegistry, tag: &String): Option { + if (self.tag_map.contains(tag)) { + option::some(*self.tag_map.get(tag)) + } else { + option::none() + } +} + +/// Increments the combined usage count for `tag` by one. +/// +/// Has no effect when `tag` is not in the registry. +public(package) fun increment_usage_count(self: &mut TagRegistry, tag: &String) { + if (self.tag_map.contains(tag)) { + let counters = vec_map::get_mut(&mut self.tag_map, tag); + *counters = *counters + 1; + }; +} + +/// Decrements the combined usage count for `tag` by one. +/// +/// Has no effect when `tag` is not in the registry. +public(package) fun decrement_usage_count(self: &mut TagRegistry, tag: &String) { + if (self.tag_map.contains(tag)) { + let counters = vec_map::get_mut(&mut self.tag_map, tag); + *counters = *counters - 1; + }; +} + +/// Checks whether `tag` is currently referenced by any record or role. +/// +/// Returns `false` when `tag` is not in the registry or when its combined usage +/// count is zero. +public(package) fun is_in_use(self: &TagRegistry, tag: &String): bool { + (*self.usage_count(tag).borrow_with_default(&0)) > 0 +} + +// ----------- RoleMap related ------- + +/// Checks whether the role associated with `cap` allows the given record `tag`. +/// +/// Looks up the `RoleTags` stored as role-data for `cap`'s role and tests whether +/// `tag` is part of that role's allowlist. +/// +/// Returns `true` when the role has `RoleTags` whose set contains `tag`, otherwise +/// `false` (including when the role has no `RoleTags`). +public(package) fun role_allows( + roles: &RoleMap, + cap: &Capability, + tag: &String, +): bool { + let role_tags = role_map::get_role_data(roles, cap.role()); + if (!role_tags.is_some()) { + return false + }; + + let tags = &option::borrow(role_tags).tags; + iota::vec_set::contains(tags, tag) +} diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move new file mode 100644 index 00000000..ced8d580 --- /dev/null +++ b/audit-trail-move/tests/capability_tests.move @@ -0,0 +1,1380 @@ +#[allow(lint(abort_without_constant))] +#[test_only] +module audit_trails::capability_tests; + +use audit_trails::{ + locking, + main::AuditTrail, + permission, + record::{Self, Data}, + test_utils::{ + Self, + setup_test_audit_trail, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock + } +}; +use iota::test_scenario::{Self as ts, Scenario}; +use std::string; +use tf_components::{capability::Capability, timelock}; + +/// Helper function to setup an audit trail with a RecordAdmin role and a capability +/// with a time window restriction transferred to the record_user. +/// Returns the trail_id. +fun setup_trail_with_record_admin_capability_and_time_window_restriction( + scenario: &mut Scenario, + admin_user: address, + record_user: address, + valid_from_ms: u64, + valid_until_ms: u64, +): ID { + // Setup + let trail_id = setup_trail_with_record_admin_role(scenario, admin_user); + + // Issue capability with time window + ts::next_tx(scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(scenario); + + let cap = trail + .access_mut() + .new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), // no address restriction + std::option::some(valid_from_ms), + std::option::some(valid_until_ms), + &clock, + ts::ctx(scenario), + ); + + // Verify capability properties + assert!(cap.issued_to().is_none(), 0); + assert!(cap.valid_from() == std::option::some(valid_from_ms), 1); + assert!(cap.valid_until() == std::option::some(valid_until_ms), 2); + + transfer::public_transfer(cap, record_user); + cleanup_capability_trail_and_clock(scenario, admin_cap, trail, clock); + }; + + trail_id +} + +/// Helper function to setup an audit trail with a RecordAdmin role. +/// Returns the trail_id. +fun setup_trail_with_record_admin_role(scenario: &mut Scenario, admin_user: address): ID { + // Setup: Create audit trail with admin capability + let trail_id = { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + + let (admin_cap, trail_id) = setup_test_audit_trail( + scenario, + locking_config, + std::option::none(), + ); + + transfer::public_transfer(admin_cap, admin_user); + trail_id + }; + + // Create a custom role for testing + ts::next_tx(scenario, admin_user); + { + let admin_cap = ts::take_from_sender(scenario); + let mut trail = ts::take_shared>(scenario); + let clock = iota::clock::create_for_testing(ts::ctx(scenario)); + + let record_admin_perms = permission::record_admin_permissions(); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + record_admin_perms, + std::option::none(), + &clock, + ts::ctx(scenario), + ); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(scenario, admin_cap); + ts::return_shared(trail); + }; + + trail_id +} + +#[test] +fun test_new_capability() { + let admin_user = @0xAD; + let user1 = @0xB0B; + let user2 = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + let trail_id = { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + trail_id + }; + + // Create a role to issue capabilities for + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Issue first capability + ts::next_tx(&mut scenario, admin_user); + let cap1_id = { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap1 = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + assert!(cap1.role() == string::utf8(b"RecordAdmin"), 1); + assert!(cap1.target_key() == trail_id, 2); + + let cap1_id = object::id(&cap1); + + transfer::public_transfer(cap1, user1); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + + cap1_id + }; + + // Issue second capability and verify both have unique IDs + ts::next_tx(&mut scenario, admin_user); + let _cap2_id = { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap2 = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + let cap2_id = object::id(&cap2); + + // Verify capabilities have unique IDs + assert!(cap1_id != cap2_id, 3); + + transfer::public_transfer(cap2, user2); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + + cap2_id + }; + + ts::end(scenario); +} + +#[test] +fun test_revoke_capability() { + let admin_user = @0xAD; + let user1 = @0xB0B; + let user2 = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue two capabilities + ts::next_tx(&mut scenario, admin_user); + let (cap1_id, cap2_id) = { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap1 = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + let cap1_id = object::id(&cap1); + transfer::public_transfer(cap1, user1); + + let cap2 = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + let cap2_id = object::id(&cap2); + transfer::public_transfer(cap2, user2); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + + (cap1_id, cap2_id) + }; + + // Test: Revoke first capability and verify it's tracked in the deny list + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let cap1 = ts::take_from_address(&scenario, user1); + + // Verify the deny list is empty before revocation + let cap_count_before = trail.access().revoked_capabilities().length(); + assert!(cap_count_before == 0, 0); + + // Revoke the capability + trail + .access_mut() + .revoke_capability( + &admin_cap, + cap1.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability has been added to the deny list + assert!(trail.access().revoked_capabilities().length() == cap_count_before + 1, 1); + assert!(trail.access().revoked_capabilities().contains(cap1_id), 2); + + ts::return_to_address(user1, cap1); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Verify revoked capability object still exists (just invalidated) + ts::next_tx(&mut scenario, user1); + { + assert!(ts::has_most_recent_for_sender(&scenario), 3); + }; + + // Test: Revoke second capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let cap2 = ts::take_from_address(&scenario, user2); + + let cap_count_before = trail.access().revoked_capabilities().length(); + assert!(cap_count_before == 1, 4); // only the first revoked capability (cap1) should be in the list + + trail + .access_mut() + .revoke_capability( + &admin_cap, + cap2.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability has been added to the deny list + assert!(trail.access().revoked_capabilities().length() == cap_count_before + 1, 5); + assert!(trail.access().revoked_capabilities().contains(cap2_id), 6); + + ts::return_to_address(user2, cap2); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_destroy_capability() { + let admin_user = @0xAD; + let user1 = @0xB0B; + let user2 = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue two capabilities + ts::next_tx(&mut scenario, admin_user); + let (_cap1_id, _cap2_id) = { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap1 = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + let cap1_id = object::id(&cap1); + transfer::public_transfer(cap1, user1); + + let cap2 = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + let cap2_id = object::id(&cap2); + transfer::public_transfer(cap2, user2); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + + (cap1_id, cap2_id) + }; + + // User1 destroys their capability + ts::next_tx(&mut scenario, user1); + { + let mut trail = ts::take_shared>(&scenario); + let cap1 = ts::take_from_sender(&scenario); + + // Destroy the capability + trail.access_mut().destroy_capability(cap1); + + ts::return_shared(trail); + }; + + // Verify destroyed capability no longer exists + ts::next_tx(&mut scenario, user1); + { + assert!(!ts::has_most_recent_for_sender(&scenario), 0); + }; + + // Test: User2 destroys their own capability + ts::next_tx(&mut scenario, user2); + { + let mut trail = ts::take_shared>(&scenario); + let cap2 = ts::take_from_sender(&scenario); + + trail.access_mut().destroy_capability(cap2); + + ts::return_shared(trail); + }; + + // Verify destroyed capability no longer exists + ts::next_tx(&mut scenario, user2); + { + assert!(!ts::has_most_recent_for_sender(&scenario), 1); + }; + + ts::end(scenario); +} + +/// Test capability lifecycle: creation, usage, and destruction in a complete workflow. +/// +/// This test validates: +/// - Multiple capabilities can be created for different roles +/// - Capabilities can be used to perform authorized actions +/// - Capabilities can be revoked or destroyed +/// - revoked_capabilities tracking remains accurate throughout the lifecycle +#[test] +fun test_capability_lifecycle() { + let admin_user = @0xAD; + let record_admin_user = @0xB0B; + let role_admin_user = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + // Setup: Create audit trail + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Create an additional RoleAdmin role + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleAdmin"), + permission::role_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Issue capabilities + ts::next_tx(&mut scenario, admin_user); + let (_record_cap_id, role_cap_id) = { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + let record_cap_id = object::id(&record_cap); + transfer::public_transfer(record_cap, record_admin_user); + + let role_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RoleAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + let role_cap_id = object::id(&role_cap); + transfer::public_transfer(role_cap, role_admin_user); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + + (record_cap_id, role_cap_id) + }; + + // Use RecordAdmin capability to add a record + ts::next_tx(&mut scenario, record_admin_user); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); + + let test_data = record::new_text(string::utf8(b"Test record")); + trail.add_record( + &record_cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + // RecordAdmin destroys their capability + ts::next_tx(&mut scenario, record_admin_user); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + trail.access_mut().destroy_capability(record_cap); + + ts::return_shared(trail); + }; + + // Admin revokes RoleAdmin capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let role_cap = ts::take_from_address(&scenario, role_admin_user); + + // Initially the deny list should be empty + assert!(trail.access().revoked_capabilities().length() == 0, 0); + + trail + .access_mut() + .revoke_capability( + &admin_cap, + role_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify role_cap is in the deny list now + assert!(trail.access().revoked_capabilities().length() == 1, 1); + assert!(trail.access().revoked_capabilities().contains(role_cap_id), 2); + + ts::return_to_address(role_admin_user, role_cap); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test, expected_failure(abort_code = audit_trails::role_map::ECapabilityIssuedToMismatch)] +fun test_capability_issued_to_only() { + let admin_user = @0xAD; + let authorized_user = @0xB0B; + let unauthorized_user = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue capability restricted to authorized_user only + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = test_utils::new_capability_for_address( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + authorized_user, + std::option::none(), // no time restriction + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability properties + assert!(cap.issued_to() == std::option::some(authorized_user), 0); + assert!(cap.valid_from().is_none(), 1); + assert!(cap.valid_until().is_none(), 2); + + transfer::public_transfer(cap, authorized_user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Authorized user can use the capability + ts::next_tx(&mut scenario, authorized_user); + { + let (record_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let test_data = record::new_text(string::utf8(b"Authorized record")); + trail.add_record( + &record_cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Transfer the capability to he unauthorized_user to prepare the next test + transfer::public_transfer(record_cap, unauthorized_user); + + // Cleanup + iota::clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + // Unauthorized user cannot use the capability + ts::next_tx(&mut scenario, unauthorized_user); + { + let (record_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // This should fail as unauthorized_user has the wrong address + let test_data = record::new_text(string::utf8(b"Unauthorized record")); + trail.add_record( + &record_cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +// ===== Error Case Tests ===== + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityHasBeenRevoked)] +fun test_revoked_capability_cannot_be_used() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create role and issue capability to user + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let user_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Revoke the capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let user_cap = ts::take_from_address(&scenario, user); + + trail + .access_mut() + .revoke_capability( + &admin_cap, + user_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + ts::return_to_address(user, user_cap); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Try to use revoked capability - should fail + ts::next_tx(&mut scenario, user); + { + let (user_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); + + trail.add_record( + &user_cap, + record::new_text(string::utf8(b"Should fail")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ERoleDoesNotExist)] +fun test_new_capability_for_nonexistent_role() { + let admin_user = @0xAD; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let bad_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NonExistentRole"), + &clock, + ts::ctx(&mut scenario), + ); + + bad_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_revoke_capability_permission_denied() { + let admin_user = @0xAD; + let user1 = @0xB0B; + let user2 = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create two roles: one without revoke permission, one with record permissions + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"NoRevokePerm"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let user1_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NoRevokePerm"), + &clock, + ts::ctx(&mut scenario), + ); + + let user2_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user1_cap, user1); + transfer::public_transfer(user2_cap, user2); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // User1 (without revoke permission) tries to revoke User2's capability + ts::next_tx(&mut scenario, user1); + { + let user1_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let user2_cap = ts::take_from_address(&scenario, user2); + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + trail + .access_mut() + .revoke_capability( + &user1_cap, + user2_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + ts::return_to_address(user2, user2_cap); + ts::return_to_sender(&scenario, user1_cap); + ts::return_shared(trail); + iota::clock::destroy_for_testing(clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_new_capability_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create role without add_capabilities permission + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"NoCapPerm"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let user_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NoCapPerm"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // User tries to issue a new capability without permission + ts::next_tx(&mut scenario, user); + { + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let new_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &user_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + new_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test capability with only valid_from restriction (time-restricted from a point). +/// +/// This test validates: +/// - Capability can be used after valid_from timestamp +/// - Capability is not restricted by address or end time +/// - Capability cannot be used before valid_from timestamp +#[test, expected_failure(abort_code = audit_trails::role_map::ECapabilityTimeConstraintsNotMet)] +fun test_capability_valid_from_only() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_from_time = test_utils::initial_time_for_testing() + 5000; + + // Setup + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue capability with valid_from restriction only + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail + .access_mut() + .new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), // no address restriction + std::option::some(valid_from_time), + std::option::none(), // no valid_until + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability properties + assert!(cap.issued_to().is_none(), 0); + assert!(cap.valid_from() == std::option::some(valid_from_time), 1); + assert!(cap.valid_until().is_none(), 2); + + transfer::public_transfer(cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Use the capability after valid_from + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(test_utils::initial_time_for_testing() + 6000); + + let test_data = record::new_text(string::utf8(b"Test record after valid_from")); + trail.add_record( + &cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + // Try to use the capability before valid_from + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); + + // This should fail as the capability is not valid yet + let test_data = record::new_text(string::utf8(b"Test record before valid_from")); + trail.add_record( + &cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test capability with only valid_until restriction (time-restricted until a point). +/// +/// This test validates: +/// - Capability can be used before valid_until timestamp +/// - Capability is not restricted by address or start time +/// - Capability cannot be used after valid_until timestamp +#[test, expected_failure(abort_code = audit_trails::role_map::ECapabilityTimeConstraintsNotMet)] +fun test_capability_valid_until_only() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_until_time_ms = test_utils::initial_time_for_testing() + 10000; + + // Setup + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue capability with valid_until restriction + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = test_utils::new_capability_valid_until( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + valid_until_time_ms, + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability properties + assert!(cap.issued_to().is_none(), 0); + assert!(cap.valid_from().is_none(), 1); + assert!(cap.valid_until() == std::option::some(valid_until_time_ms), 2); + + transfer::public_transfer(cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Use the capability before valid_until + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_until_time_ms - 1000000); + + let test_data = record::new_text(string::utf8(b"Test record before valid_until")); + trail.add_record( + &cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + // Try to use the capability after valid_until + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_until_time_ms + 100000); + + // This should fail as the capability has expired + let test_data = record::new_text(string::utf8(b"Test record after valid_until")); + trail.add_record( + &cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test capability with valid_from and valid_until restrictions (time window). +/// +/// This test validates: +/// - Capability can be used between valid_from and valid_until +/// - Capability is not restricted by address +#[test] +fun test_capability_time_window() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_from_time = test_utils::initial_time_for_testing() + 5000; + let valid_until_time = test_utils::initial_time_for_testing() + 10000; + + // Setup + let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( + &mut scenario, + admin_user, + user, + valid_from_time, + valid_until_time, + ); + + // Use the capability within the valid time window + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_from_time + 2500); + + let test_data = record::new_text(string::utf8(b"Test record within time window")); + trail.add_record( + &cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test capability with valid_from and valid_until restrictions (time window). +/// +/// This test validates: +/// - Capability cannot be used before valid_from +#[test, expected_failure(abort_code = audit_trails::role_map::ECapabilityTimeConstraintsNotMet)] +fun test_capability_time_window_before_valid_from() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_from_time_ms = test_utils::initial_time_for_testing() + 5000; + let valid_until_time_ms = test_utils::initial_time_for_testing() + 10000; + + // Setup + let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( + &mut scenario, + admin_user, + user, + valid_from_time_ms, + valid_until_time_ms, + ); + + // Use the capability before valid_from + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_from_time_ms - 1000); + + let test_data = record::new_text(string::utf8(b"Test record before valid_from")); + trail.add_record( + &cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test capability with valid_from and valid_until restrictions (time window). +/// +/// This test validates: +/// - Capability cannot be used after valid_until +#[test, expected_failure(abort_code = audit_trails::role_map::ECapabilityTimeConstraintsNotMet)] +fun test_capability_time_window_after_valid_until() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_from_time_ms = test_utils::initial_time_for_testing() + 5000; + let valid_until_time_ms = test_utils::initial_time_for_testing() + 10000; + + // Setup + let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( + &mut scenario, + admin_user, + user, + valid_from_time_ms, + valid_until_time_ms, + ); + + // Use the capability after valid_until + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_until_time_ms + 1000); + + let test_data = record::new_text(string::utf8(b"Test record after valid_until")); + trail.add_record( + &cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test Capability::is_valid_for_timestamp function. +/// +/// This test validates: +/// - Returns true when timestamp is within valid range +/// - Returns false when timestamp is before valid_from +/// - Returns false when timestamp is after valid_until +/// - Returns true when no time restrictions exist +#[test] +fun test_is_valid_for_timestamp() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let base_time = test_utils::initial_time_for_testing(); + let valid_from_time = base_time + 5000; + let valid_until_time = base_time + 10000; + + // Setup + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Test with time-restricted capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail + .access_mut() + .new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), + std::option::some(valid_from_time), + std::option::some(valid_until_time), + &clock, + ts::ctx(&mut scenario), + ); + + // Before valid_from + assert!(!cap.is_valid_for_timestamp(valid_from_time - 1), 0); + + // At valid_from (inclusive) + assert!(cap.is_valid_for_timestamp(valid_from_time), 1); + + // During validity period + assert!(cap.is_valid_for_timestamp(valid_from_time + 2500), 2); + + // Before valid_until (exclusive) + assert!(cap.is_valid_for_timestamp(valid_until_time - 1), 3); + + // At valid_until (inclusive) + assert!(cap.is_valid_for_timestamp(valid_until_time), 4); + + // After valid_until + assert!(!cap.is_valid_for_timestamp(valid_until_time + 1), 5); + + transfer::public_transfer(cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Test with unrestricted capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let unrestricted_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Should be valid at any timestamp + assert!(unrestricted_cap.is_valid_for_timestamp(0), 6); + assert!(unrestricted_cap.is_valid_for_timestamp(base_time), 7); + assert!(unrestricted_cap.is_valid_for_timestamp(valid_until_time + 99999), 8); + + transfer::public_transfer(unrestricted_cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test Capability::is_currently_valid function. +/// +/// This test validates: +/// - Returns true when current time is within valid range +/// - Returns false when current time is outside valid range +/// - Works correctly with Clock object +#[test] +fun test_is_currently_valid() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let base_time = test_utils::initial_time_for_testing(); + let valid_from_time = base_time + 5000; + let valid_until_time = base_time + 10000; + + // Setup + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue time-restricted capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail + .access_mut() + .new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), + std::option::some(valid_from_time), + std::option::some(valid_until_time), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Test before valid_from + ts::next_tx(&mut scenario, user); + { + let cap = ts::take_from_sender(&scenario); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(valid_from_time - 1000); + + assert!(!cap.is_currently_valid(&clock), 0); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, cap); + }; + + // Test during valid period + ts::next_tx(&mut scenario, user); + { + let cap = ts::take_from_sender(&scenario); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(valid_from_time + 2500); + + assert!(cap.is_currently_valid(&clock), 1); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, cap); + }; + + // Test after valid_until + ts::next_tx(&mut scenario, user); + { + let cap = ts::take_from_sender(&scenario); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(valid_until_time + 1000); + + assert!(!cap.is_currently_valid(&clock), 2); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, cap); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move new file mode 100644 index 00000000..1f557637 --- /dev/null +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -0,0 +1,494 @@ +#[allow(lint(abort_without_constant))] +#[test_only] +module audit_trails::create_audit_trail_tests; + +use audit_trails::{ + locking, + main::{Self, AuditTrail, initial_admin_role_name}, + permission, + record::{Self, Data}, + test_utils::{ + setup_test_audit_trail, + initial_time_for_testing, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock, + new_capability_for_address + } +}; +use iota::{clock, test_scenario as ts}; +use std::string; +use tf_components::timelock; + +#[test] +fun test_create_without_initial_record() { + let user = @0xA; + let mut scenario = ts::begin(user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + + // Verify capability was created + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.target_key() == trail_id, 1); + + // Clean up + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + + // Verify trail was created correctly + assert!(trail.creator() == user, 2); + assert!(trail.created_at() == initial_time_for_testing(), 3); + assert!(trail.record_count() == 0, 4); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_tag_admin_role_can_manage_available_record_tags() { + let admin = @0xA; + let tag_admin = @0xB; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TagAdmin"), + permission::tag_admin_permissions(), + option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let tag_admin_cap = new_capability_for_address( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TagAdmin"), + tag_admin, + option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(tag_admin_cap, tag_admin); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, tag_admin); + { + let (tag_admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.add_record_tag( + &tag_admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + let available_tags = trail.tags().tag_keys(); + assert!(available_tags.length() == 1, 0); + assert!(available_tags.contains(&string::utf8(b"finance")), 1); + + trail.remove_record_tag( + &tag_admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + let available_tags = trail.tags().tag_keys(); + assert!(available_tags.length() == 0, 2); + + cleanup_capability_trail_and_clock(&scenario, tag_admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_with_initial_record() { + let user = @0xB; + let mut scenario = ts::begin(user); + + { + let locking_config = locking::new( + locking::window_time_based(86400), + timelock::none(), + timelock::none(), + ); // 1 day in seconds + let initial_data = record::new_text(string::utf8(b"Hello, World!")); + + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(initial_data), + ); + + // Verify capability + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.target_key() == trail_id, 1); + + // Clean up + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + + // Verify trail with initial record + assert!(trail.creator() == user, 2); + assert!(trail.created_at() == initial_time_for_testing(), 3); + assert!(trail.record_count() == 1, 4); + + // Verify the initial record exists + assert!(trail.has_record(0), 5); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_with_tagged_initial_record_tracks_tag_usage() { + let user = @0xC; + let mut scenario = ts::begin(user); + + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing()); + + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let initial_record = record::new_initial_record( + record::new_text(string::utf8(b"Tagged initial record")), + option::none(), + option::some(string::utf8(b"finance")), + ); + + let (admin_cap, trail_id) = main::create( + option::some(initial_record), + locking_config, + option::none(), + option::none(), + vector[string::utf8(b"finance")], + &clock, + ts::ctx(&mut scenario), + ); + + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.target_key() == trail_id, 1); + admin_cap.destroy_for_testing(); + clock::destroy_for_testing(clock); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + let finance_tag = string::utf8(b"finance"); + + assert!(trail.record_count() == 1, 2); + assert!(trail.tags().usage_count(&finance_tag) == option::some(1), 3); + assert!(trail.tags().is_in_use(&finance_tag), 4); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test, expected_failure(abort_code = audit_trails::main::ERecordTagInUse)] +fun test_create_with_tagged_initial_record_blocks_tag_removal() { + let admin = @0xD; + let mut scenario = ts::begin(admin); + + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing()); + + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let initial_record = record::new_initial_record( + record::new_text(string::utf8(b"Tagged initial record")), + option::none(), + option::some(string::utf8(b"finance")), + ); + + let (admin_cap, _) = main::create( + option::some(initial_record), + locking_config, + option::none(), + option::none(), + vector[string::utf8(b"finance")], + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(admin_cap, admin); + clock::destroy_for_testing(clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + trail.remove_record_tag( + &admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_minimal_metadata() { + let user = @0xC; + let mut scenario = ts::begin(user); + + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(3000); + + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + + let (admin_cap, _trail_id) = main::create( + option::none(), + locking_config, + option::none(), + option::none(), + vector[], + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability was created + assert!(admin_cap.role() == initial_admin_role_name(), 0); + + // Clean up + admin_cap.destroy_for_testing(); + clock::destroy_for_testing(clock); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + + // Verify trail was created + assert!(trail.creator() == user, 1); + assert!(trail.created_at() == 3000, 2); + assert!(trail.record_count() == 0, 3); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_with_locking_enabled() { + let user = @0xD; + let mut scenario = ts::begin(user); + + { + let locking_config = locking::new( + locking::window_time_based(604800), + timelock::none(), + timelock::none(), + ); // 7 days in seconds + let (admin_cap, _trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + + // Clean up + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + + // Verify trail with locking enabled + assert!(trail.creator() == user, 0); + assert!(trail.record_count() == 0, 1); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_multiple_trails() { + let user = @0xE; + let mut scenario = ts::begin(user); + + let mut trail_ids = vector::empty(); + + // Create first trail + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap1, trail_id1) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + + trail_ids.push_back(trail_id1); + admin_cap1.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, user); + + // Create second trail + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap2, trail_id2) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + + trail_ids.push_back(trail_id2); + + // Verify trails have different IDs + assert!(trail_ids[0] != trail_ids[1], 0); + + admin_cap2.destroy_for_testing(); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_metadata_admin_role() { + let creator = @0xA; + let user = @0xB; + let mut scenario = ts::begin(creator); + + // Creator creates the audit trail + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + + // Verify admin capability was created + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.target_key() == trail_id, 1); + + // Transfer the admin capability to the user + transfer::public_transfer(admin_cap, user); + }; + + // User receives the capability and creates the MetadataAdmin role + ts::next_tx(&mut scenario, user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + // Create the MetadataAdmin role using the admin capability + let metadata_admin_role_name = string::utf8(b"MetadataAdmin"); + let metadata_admin_perms = audit_trails::permission::metadata_admin_permissions(); + + trail + .access_mut() + .create_role( + &admin_cap, + metadata_admin_role_name, + metadata_admin_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the role was created by fetching its permissions + let role_perms = trail.access().get_role_permissions(&string::utf8(b"MetadataAdmin")); + + // Verify the role has the correct permissions + assert!( + audit_trails::permission::has_permission( + role_perms, + &audit_trails::permission::update_metadata(), + ), + 2, + ); + assert!( + audit_trails::permission::has_permission( + role_perms, + &audit_trails::permission::delete_metadata(), + ), + 3, + ); + assert!(iota::vec_set::size(role_perms) == 2, 4); + + // Clean up + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move new file mode 100644 index 00000000..6146e78d --- /dev/null +++ b/audit-trail-move/tests/locking_tests.move @@ -0,0 +1,1244 @@ +#[allow(lint(abort_without_constant))] +#[test_only] +module audit_trails::locking_tests; + +use audit_trails::{ + locking, + main::{Self, AuditTrail}, + permission, + record::{Self, Data}, + test_utils::{ + Self, + setup_test_audit_trail, + initial_time_for_testing, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock, + cleanup_trail_and_clock + } +}; +use iota::{clock, test_scenario as ts}; +use std::string; +use tf_components::{capability::Capability, timelock}; + +// ===== Time-Based Locking Tests ===== + +#[test] +fun test_time_based_locking_within_window() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with 1 hour time-based locking + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Test"))), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 1 second after creation - locked + clock.set_for_testing(initial_time_for_testing() + 1000); + assert!(trail.is_record_locked(0, &clock), 0); + + // 30 minutes after - locked + clock.set_for_testing(initial_time_for_testing() + 1800 * 1000); + assert!(trail.is_record_locked(0, &clock), 1); + + // 59 minutes after - locked + clock.set_for_testing(initial_time_for_testing() + 3540 * 1000); + assert!(trail.is_record_locked(0, &clock), 2); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_time_based_locking_outside_window() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with 1 hour time-based locking + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Test"))), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 1 hour + 1 second after creation - unlocked + clock.set_for_testing(initial_time_for_testing() + 3601 * 1000); + assert!(!trail.is_record_locked(0, &clock), 0); + + // 2 hours after - unlocked + clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); + assert!(!trail.is_record_locked(0, &clock), 1); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Count-Based Locking Tests ===== + +#[test] +fun test_count_based_locking() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with count-based locking (last 2 locked) + { + let locking_config = locking::new( + locking::window_count_based(2), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role and capability + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Add 5 records and verify locking + ts::next_tx(&mut scenario, admin); + { + let record_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + // With 5 records and last 2 locked: + // Records 0, 1, 2 = unlocked (have 4, 3, 2 records after them) + // Records 3, 4 = locked (have 1, 0 records after them) + assert!(!trail.is_record_locked(0, &clock), 0); + assert!(!trail.is_record_locked(1, &clock), 1); + assert!(!trail.is_record_locked(2, &clock), 2); + assert!(trail.is_record_locked(3, &clock), 3); + assert!(trail.is_record_locked(4, &clock), 4); + + clock::destroy_for_testing(clock); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_count_based_locking_single_record() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with "last 3 locked" - single record should be locked + { + let locking_config = locking::new( + locking::window_count_based(3), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Single"))), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + assert!(trail.is_record_locked(0, &clock), 0); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== No Locking Tests ===== + +#[test] +fun test_no_locking() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Test"))), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing()); + + // No locking config = never locked + assert!(!trail.is_record_locked(0, &clock), 0); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Update Locking Config Tests ===== + +#[test] +fun test_update_locking_config() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with no locking + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Test"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create LockingAdmin role + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::update_locking_config()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"LockingAdmin"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let locking_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"LockingAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(locking_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Update from no-locking to time-based + ts::next_tx(&mut scenario, admin); + { + let (locking_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Initially unlocked + assert!(!trail.is_record_locked(0, &clock), 0); + + // Update to 1 hour time-based locking + trail.update_locking_config( + &locking_cap, + locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()), + &clock, + ts::ctx(&mut scenario), + ); + + // Now locked + assert!(trail.is_record_locked(0, &clock), 1); + + // locking_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, locking_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_update_locking_config_permission_denied() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role WITHOUT UpdateLockingConfig permission + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"NoLockingPerm"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let no_locking_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NoLockingPerm"), + &clock, + ts::ctx(&mut scenario), + ); + transfer::public_transfer(no_locking_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Try to update locking config - should fail + ts::next_tx(&mut scenario, admin); + { + let (no_locking_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.update_locking_config( + &no_locking_cap, + locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, no_locking_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_update_delete_record_window() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with no locking + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Test"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role with UpdateLockingConfigForDeleteRecord permission + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[ + permission::update_locking_config_for_delete_record(), + ]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"DeleteLockAdmin"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let delete_lock_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteLockAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(delete_lock_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Update delete_record_lock + ts::next_tx(&mut scenario, admin); + { + let (delete_lock_cap, mut trail, mut clock) = fetch_capability_trail_and_clock( + &mut scenario, + ); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Initially unlocked + assert!(!trail.is_record_locked(0, &clock), 0); + + // Update to count-based (last 5 locked) + trail.update_delete_record_window( + &delete_lock_cap, + locking::window_count_based(5), + &clock, + ts::ctx(&mut scenario), + ); + + // Now locked (single record, last 5 are locked) + assert!(trail.is_record_locked(0, &clock), 1); + + cleanup_capability_trail_and_clock(&scenario, delete_lock_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_update_delete_record_window_permission_denied() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role with update_locking_config but NOT update_locking_config_for_delete_record + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::update_locking_config()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"WrongPerm"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let wrong_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"WrongPerm"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(wrong_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Try to update delete_record_lock - should fail + ts::next_tx(&mut scenario, admin); + { + let (wrong_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.update_delete_record_window( + &wrong_cap, + locking::window_count_based(5), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, wrong_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_time_lock_boundary_just_before_expiry() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with 1 hour time-based locking + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Test"))), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 1 millisecond before lock expires - should still be locked + // 3600 * 1000 - 1 = 3599999 ms + clock.set_for_testing(initial_time_for_testing() + 3600 * 1000 - 1); + assert!(trail.is_record_locked(0, &clock), 0); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Variant Locking Tests ===== + +#[test] +fun test_time_based_locking_all_recent_records_locked() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with time-based (1 hour) locking + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role and add records + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Add 5 records + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + transfer::public_transfer(record_cap, admin); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Test: Records locked by time-based window + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // Shortly after creation - all records are time-locked + clock.set_for_testing(initial_time_for_testing() + 2000); + + // All records should be locked (time lock active for all) + assert!(trail.is_record_locked(0, &clock), 0); + assert!(trail.is_record_locked(1, &clock), 1); + assert!(trail.is_record_locked(2, &clock), 2); + assert!(trail.is_record_locked(3, &clock), 3); + assert!(trail.is_record_locked(4, &clock), 4); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_count_based_locking_last_records_remain_locked() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with count-based (last 2) locking + { + let locking_config = locking::new( + locking::window_count_based(2), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role and add records + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Add 5 records + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + transfer::public_transfer(record_cap, admin); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Test: Count lock active for last 2 records + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 2 hours later, count lock behavior should be unchanged + clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); + + // Records 0, 1, 2 should be unlocked (not in last 2) + assert!(!trail.is_record_locked(0, &clock), 0); + assert!(!trail.is_record_locked(1, &clock), 1); + assert!(!trail.is_record_locked(2, &clock), 2); + + // Records 3, 4 should still be locked (count lock - last 2) + assert!(trail.is_record_locked(3, &clock), 3); + assert!(trail.is_record_locked(4, &clock), 4); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_count_based_locking_old_record_can_delete() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_count_based(2), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); + + assert!(!trail.is_record_locked(0, &clock), 0); + assert!(trail.has_record(0), 1); + + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + assert!(!trail.has_record(0), 2); + + clock::destroy_for_testing(clock); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_count_based_locking_uses_current_records_after_tail_deletion() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + let record_lock_admin_role = string::utf8(b"RecordLockAdmin"); + let record_lock_admin_perms = permission::from_vec(vector[ + permission::add_record(), + permission::delete_record(), + permission::update_locking_config_for_delete_record(), + ]); + + trail + .access_mut() + .create_role( + &admin_cap, + record_lock_admin_role, + record_lock_admin_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_lock_admin_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordLockAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_lock_admin_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + trail.delete_record(&record_lock_admin_cap, 4, &clock, ts::ctx(&mut scenario)); + trail.update_delete_record_window( + &record_lock_admin_cap, + locking::window_count_based(2), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(!trail.has_record(4), 0); + assert!(!trail.is_record_locked(1, &clock), 1); + assert!(trail.is_record_locked(2, &clock), 2); + assert!(trail.is_record_locked(3, &clock), 3); + + record_lock_admin_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_time_based_locking_still_locked_before_expiry() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with time-based (1 hour) locking + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role and add records + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Add 5 records + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + transfer::public_transfer(record_cap, admin); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Test: Time lock still active before expiry + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // Only 30 minutes after creation - time lock still active + clock.set_for_testing(initial_time_for_testing() + 1800 * 1000); + + // Records are still locked because time window has not expired yet + assert!(trail.is_record_locked(0, &clock), 0); + assert!(trail.is_record_locked(1, &clock), 1); + assert!(trail.is_record_locked(2, &clock), 2); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_delete_records_batch_skips_locked_records() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_count_based(1), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Record 0"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + let record_maintenance_role = string::utf8(b"RecordMaintenanceAdmin"); + let record_maintenance_perms = permission::from_vec(vector[ + permission::add_record(), + permission::delete_all_records(), + ]); + + trail + .access_mut() + .create_role( + &admin_cap, + record_maintenance_role, + record_maintenance_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_maintenance_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordMaintenanceAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + trail.add_record( + &record_maintenance_cap, + record::new_text(string::utf8(b"Record 1")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + trail.add_record( + &record_maintenance_cap, + record::new_text(string::utf8(b"Record 2")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + let deleted = trail.delete_records_batch( + &record_maintenance_cap, + 10, + &clock, + ts::ctx(&mut scenario), + ); + assert!(vector::length(&deleted) == 2, 0); + assert!(*vector::borrow(&deleted, 0) == 0, 1); + assert!(*vector::borrow(&deleted, 1) == 1, 2); + assert!(trail.record_count() == 1, 3); + assert!(!trail.has_record(0), 4); + assert!(!trail.has_record(1), 5); + assert!(trail.has_record(2), 6); + + record_maintenance_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::main::ETrailNotEmpty)] +fun test_delete_audit_trail_fails_while_not_empty() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Record"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + let delete_trail_role = string::utf8(b"DeleteTrailOnly"); + let delete_trail_perms = permission::from_vec(vector[permission::delete_audit_trail()]); + trail + .access_mut() + .create_role( + &admin_cap, + delete_trail_role, + delete_trail_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let delete_trail_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteTrailOnly"), + &clock, + ts::ctx(&mut scenario), + ); + + main::delete_audit_trail(trail, &delete_trail_cap, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + delete_trail_cap.destroy_for_testing(); + admin_cap.destroy_for_testing(); + }; + + ts::end(scenario); +} + +#[test] +fun test_delete_audit_trail_after_batch_cleanup() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Record"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let delete_maintenance_role = string::utf8(b"DeleteMaintenance"); + let delete_maintenance_perms = permission::from_vec(vector[ + permission::delete_all_records(), + permission::delete_audit_trail(), + ]); + + trail + .access_mut() + .create_role( + &admin_cap, + delete_maintenance_role, + delete_maintenance_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let delete_maintenance_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteMaintenance"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + let deleted = trail.delete_records_batch( + &delete_maintenance_cap, + 100, + &clock, + ts::ctx(&mut scenario), + ); + assert!(vector::length(&deleted) == 1, 0); + assert!(*vector::borrow(&deleted, 0) == 0, 1); + assert!(trail.record_count() == 0, 2); + + main::delete_audit_trail(trail, &delete_maintenance_cap, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + delete_maintenance_cap.destroy_for_testing(); + admin_cap.destroy_for_testing(); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move new file mode 100644 index 00000000..f885c815 --- /dev/null +++ b/audit-trail-move/tests/metadata_tests.move @@ -0,0 +1,306 @@ +#[allow(lint(abort_without_constant))] +#[test_only] +module audit_trails::metadata_tests; + +use audit_trails::{ + locking, + permission, + test_utils::{ + Self, + setup_test_audit_trail, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock + } +}; +use iota::test_scenario as ts; +use std::{option::none, string}; +use tf_components::{capability::Capability, timelock}; + +// ===== Success Case Tests ===== + +#[test] +fun test_update_metadata_success() { + let admin_user = @0xAD; + let metadata_admin_user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup: Create audit trail + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create MetadataAdmin role and capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Create MetadataAdmin role with metadata permissions + let metadata_perms = permission::metadata_admin_permissions(); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"MetadataAdmin"), + metadata_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Issue capability to metadata admin user + let metadata_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"MetadataAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(metadata_cap, metadata_admin_user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Test: MetadataAdmin updates metadata + ts::next_tx(&mut scenario, metadata_admin_user); + { + let (metadata_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Update metadata + let new_metadata = std::option::some(string::utf8(b"Updated metadata value")); + trail.update_metadata( + &metadata_cap, + new_metadata, + &clock, + ts::ctx(&mut scenario), + ); + + // Verify metadata was updated + let current_metadata = trail.metadata(); + assert!(current_metadata.is_some(), 0); + assert!(*current_metadata.borrow() == string::utf8(b"Updated metadata value"), 1); + + cleanup_capability_trail_and_clock(&scenario, metadata_cap, trail, clock); + }; + + // Test: Update metadata again to verify multiple updates work + ts::next_tx(&mut scenario, metadata_admin_user); + { + let (metadata_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Update to different value + let new_metadata = std::option::some(string::utf8(b"Second update")); + trail.update_metadata( + &metadata_cap, + new_metadata, + &clock, + ts::ctx(&mut scenario), + ); + + // Verify metadata was updated + let current_metadata = trail.metadata(); + assert!(current_metadata.is_some(), 2); + assert!(*current_metadata.borrow() == string::utf8(b"Second update"), 3); + + cleanup_capability_trail_and_clock(&scenario, metadata_cap, trail, clock); + }; + + // Test: Set metadata to none + ts::next_tx(&mut scenario, metadata_admin_user); + { + let (metadata_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Set to none + trail.update_metadata( + &metadata_cap, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify metadata is now none + let current_metadata = trail.metadata(); + assert!(current_metadata.is_none(), 4); + + cleanup_capability_trail_and_clock(&scenario, metadata_cap, trail, clock); + }; + + ts::end(scenario); +} + +// ===== Error Case Tests ===== + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_update_metadata_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create role WITHOUT update_metadata permission + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Create role with only add_record permission (no update_metadata) + let perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"NoMetadataPerm"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let user_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NoMetadataPerm"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // User tries to update metadata - should fail + ts::next_tx(&mut scenario, user); + { + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // This should fail - no update_metadata permission + trail.update_metadata( + &user_cap, + std::option::some(string::utf8(b"Should fail")), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityHasBeenRevoked)] +fun test_update_metadata_revoked_capability() { + let admin_user = @0xAD; + let metadata_admin_user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup: Create audit trail + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create MetadataAdmin role and capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Create MetadataAdmin role + let metadata_perms = permission::metadata_admin_permissions(); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"MetadataAdmin"), + metadata_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Issue capability + let metadata_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"MetadataAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(metadata_cap, metadata_admin_user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Revoke the capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let metadata_cap = ts::take_from_address(&scenario, metadata_admin_user); + + trail + .access_mut() + .revoke_capability( + &admin_cap, + metadata_cap.id(), + none(), + &clock, + ts::ctx(&mut scenario), + ); + + ts::return_to_address(metadata_admin_user, metadata_cap); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Try to use revoked capability - should fail + ts::next_tx(&mut scenario, metadata_admin_user); + { + let (metadata_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // This should fail - capability has been revoked + trail.update_metadata( + &metadata_cap, + std::option::some(string::utf8(b"Should fail")), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, metadata_cap, trail, clock); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/permission_tests.move b/audit-trail-move/tests/permission_tests.move new file mode 100644 index 00000000..38c99135 --- /dev/null +++ b/audit-trail-move/tests/permission_tests.move @@ -0,0 +1,138 @@ +#[allow(lint(abort_without_constant))] +#[test_only] +module audit_trails::permission_tests; + +use audit_trails::permission; +use iota::vec_set; + +#[test] +fun test_has_permission_empty_set() { + let set = permission::empty(); + assert!(vec_set::size(&set) == 0, 0); +} + +#[test] +fun test_has_permission_single_permission() { + let mut set = permission::empty(); + let perm = permission::add_record(); + permission::add(&mut set, perm); + + assert!(permission::has_permission(&set, &perm), 0); +} + +#[test] +fun test_has_permission_not_in_set() { + let mut set = permission::empty(); + permission::add(&mut set, permission::add_record()); + + let perm = permission::delete_record(); + assert!(!permission::has_permission(&set, &perm), 0); +} + +#[test] +fun test_has_permission_multiple_permission() { + let mut set = permission::empty(); + permission::add(&mut set, permission::add_record()); + permission::add(&mut set, permission::delete_record()); + permission::add(&mut set, permission::delete_audit_trail()); + + assert!(permission::has_permission(&set, &permission::add_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_audit_trail()), 0); + assert!(!permission::has_permission(&set, &permission::correct_record()), 0); +} + +#[test] +fun test_has_permission_from_vec() { + let perms = vector[ + permission::add_record(), + permission::delete_record(), + permission::update_metadata(), + ]; + let set = permission::from_vec(perms); + + assert!(permission::has_permission(&set, &permission::add_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_record()), 0); + assert!(permission::has_permission(&set, &permission::update_metadata()), 0); + assert!(!permission::has_permission(&set, &permission::delete_audit_trail()), 0); +} + +#[test] +fun test_from_vec_empty() { + let perms = vector[]; + let set = permission::from_vec(perms); + + assert!(vec_set::size(&set) == 0, 0); +} + +#[test] +fun test_from_vec_single_permission() { + let perms = vector[permission::add_record()]; + let set = permission::from_vec(perms); + + assert!(vec_set::size(&set) == 1, 0); + assert!(permission::has_permission(&set, &permission::add_record()), 0); +} + +#[test] +fun test_from_vec_multiple_permission() { + let perms = vector[ + permission::add_record(), + permission::delete_record(), + permission::delete_audit_trail(), + ]; + let set = permission::from_vec(perms); + + assert!(vec_set::size(&set) == 3, 0); + assert!(permission::has_permission(&set, &permission::add_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_audit_trail()), 0); + assert!(!permission::has_permission(&set, &permission::correct_record()), 0); +} + +#[test] +fun test_metadata_admin_permissions() { + let perms = permission::metadata_admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::update_metadata()), 0); + assert!(permission::has_permission(&perms, &permission::delete_metadata()), 0); + assert!(iota::vec_set::size(&perms) == 2, 0); +} + +#[test] +fun test_tag_admin_permissions() { + let perms = permission::tag_admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::add_record_tags()), 0); + assert!(permission::has_permission(&perms, &permission::delete_record_tags()), 1); + assert!(iota::vec_set::size(&perms) == 2, 2); +} + +#[test] +fun test_admin_permissions_include_tag_management() { + let perms = permission::admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::add_record_tags()), 0); + assert!(permission::has_permission(&perms, &permission::delete_record_tags()), 1); +} + +#[test] +fun test_admin_permissions_include_migration() { + let perms = permission::admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::migrate_audit_trail()), 0); +} + +#[test] +#[expected_failure(abort_code = vec_set::EKeyAlreadyExists)] +fun test_from_vec_duplicate_permission() { + // VecSet should throw error EKeyAlreadyExists on duplicate insertions + let perms = vector[ + permission::add_record(), + permission::delete_record(), + permission::add_record(), // duplicate + ]; + let set = permission::from_vec(perms); + // The following line should not be reached due to the expected failure + assert!(vec_set::size(&set) == 2, 0); +} diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move new file mode 100644 index 00000000..cc218ce6 --- /dev/null +++ b/audit-trail-move/tests/record_tests.move @@ -0,0 +1,1611 @@ +#[allow(lint(abort_without_constant))] +#[test_only] +module audit_trails::record_tests; + +use audit_trails::{ + locking, + main::{Self, AuditTrail}, + permission, + record::{Self, Data}, + record_tags, + test_utils::{ + Self, + setup_test_audit_trail, + setup_test_audit_trail_with_tags, + initial_time_for_testing, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock, + cleanup_trail_and_clock + } +}; +use iota::{clock, test_scenario as ts}; +use std::string; +use tf_components::{capability::Capability, timelock}; + +// ===== Add Record Tests ===== + +#[test] +fun test_add_record_to_empty_trail() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Add record + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Verify initial state + assert!(trail.record_count() == 0, 0); + assert!(trail.is_empty(), 1); + + // Add record + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"First record")), + std::option::some(string::utf8(b"metadata")), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify record was added + assert!(trail.record_count() == 1, 2); + assert!(!trail.is_empty(), 3); + assert!(trail.has_record(0), 4); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_add_tagged_record_with_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + let stored_record = trail.get_record(0); + assert!(*record::tag(stored_record) == std::option::some(string::utf8(b"finance")), 0); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_delete_tagged_record_with_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedRecordAdmin"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedRecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + assert!(trail.record_count() == 0, 0); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_delete_records_batch_with_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TaggedDeleteAll"), + permission::from_vec(vector[ + permission::add_record(), + permission::delete_all_records(), + ]), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedDeleteAll"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + let deleted = trail.delete_records_batch(&cap, 10, &clock, ts::ctx(&mut scenario)); + assert!(vector::length(&deleted) == 1, 0); + assert!(*vector::borrow(&deleted, 0) == 0, 1); + assert!(trail.record_count() == 0, 2); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::main::ERecordTagNotAllowed)] +fun test_add_tagged_record_requires_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"PlainWriter"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"PlainWriter"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Denied tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::main::ERecordTagNotAllowed)] +fun test_delete_tagged_record_requires_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TaggedRecordAdmin"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"DeleteOnly"), + permission::from_vec(vector[permission::delete_record()]), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let tagged_record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedRecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + let delete_only_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteOnly"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(tagged_record_cap, admin); + transfer::public_transfer(delete_only_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_only_cap = ts::take_from_sender(&scenario); + let tagged_record_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &tagged_record_cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + tagged_record_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, delete_only_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_only_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 2000); + + trail.delete_record(&delete_only_cap, 0, &clock, ts::ctx(&mut scenario)); + + cleanup_capability_trail_and_clock(&scenario, delete_only_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_delete_records_batch_skips_records_without_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::from_vec(vector[permission::add_record()]), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"DeleteAllWithoutTags"), + permission::from_vec(vector[permission::delete_all_records()]), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let tagged_writer_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + &clock, + ts::ctx(&mut scenario), + ); + let delete_all_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteAllWithoutTags"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(tagged_writer_cap, admin); + transfer::public_transfer(delete_all_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_all_cap = ts::take_from_sender(&scenario); + let tagged_writer_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &tagged_writer_cap, + record::new_text(string::utf8(b"Untagged record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + trail.add_record( + &tagged_writer_cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + tagged_writer_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, delete_all_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_all_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 2000); + + let deleted = trail.delete_records_batch( + &delete_all_cap, + 10, + &clock, + ts::ctx(&mut scenario), + ); + assert!(vector::length(&deleted) == 1, 0); + assert!(*vector::borrow(&deleted, 0) == 0, 1); + assert!(trail.record_count() == 1, 2); + assert!(!trail.has_record(0), 3); + assert!(trail.has_record(1), 4); + + cleanup_capability_trail_and_clock(&scenario, delete_all_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::main::ERecordTagNotDefined)] +fun test_add_tagged_record_requires_trail_defined_tag() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Undefined tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::main::ERecordTagInUse)] +fun test_remove_record_tag_rejects_in_use_tag() { + let admin = @0xAD; + let writer = @0xB0B; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let writer_cap = test_utils::new_capability_for_address( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + writer, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(writer_cap, writer); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, writer); + { + let (writer_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &writer_cap, + record::new_text(string::utf8(b"Tagged")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(writer_cap, writer); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.remove_record_tag( + &admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_add_multiple_records() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Add multiple records + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Add 3 records + let mut i = 0u64; + while (i < 3) { + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + // Verify all records exist + assert!(trail.record_count() == 3, 0); + assert!(trail.has_record(0), 1); + assert!(trail.has_record(1), 2); + assert!(trail.has_record(2), 3); + assert!(!trail.has_record(3), 4); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_add_record_permission_denied() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role WITHOUT AddRecord permission + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::delete_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"NoAddPerm"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let no_add_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NoAddPerm"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(no_add_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Try to add record - should fail + ts::next_tx(&mut scenario, admin); + { + let (no_add_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // This should fail - no AddRecord permission + trail.add_record( + &no_add_cap, + record::new_text(string::utf8(b"Should fail")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, no_add_cap, trail, clock); + }; + + ts::end(scenario); +} + +// ===== Delete Record Tests ===== + +#[test] +fun test_delete_record_success() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with initial record + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Initial"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Delete record + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Verify initial state + assert!(trail.record_count() == 1, 0); + assert!(trail.has_record(0), 1); + + // Delete record + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + // Verify record was deleted + assert!(trail.record_count() == 0, 2); // actual count decreases + assert!(trail.sequence_number() == 1, 3); // sequence stays monotonic + assert!(!trail.has_record(0), 4); // record is gone + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_delete_record_permission_denied() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with initial record + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Initial"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role WITHOUT DeleteRecord permission + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"NoDeletePerm"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let no_delete_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NoDeletePerm"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(no_delete_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Try to delete record - should fail + ts::next_tx(&mut scenario, admin); + { + let (no_delete_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // This should fail - no DeleteRecord permission + trail.delete_record(&no_delete_cap, 0, &clock, ts::ctx(&mut scenario)); + + cleanup_capability_trail_and_clock(&scenario, no_delete_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordNotFound)] +fun test_delete_record_not_found() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail (no initial record) + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Try to delete non-existent record - should fail + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // This should fail - record doesn't exist + trail.delete_record(&record_cap, 999, &clock, ts::ctx(&mut scenario)); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordLocked)] +fun test_delete_record_time_locked() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with time-based locking and initial record + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); // 1 hour + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Locked record"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Try to delete locked record - should fail + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + // Time is only 1 second after creation - still within lock window + clock.set_for_testing(initial_time_for_testing() + 1000); // +1 second + + // This should fail - record is time-locked + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordLocked)] +fun test_delete_record_count_locked() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with count-based locking and initial record + { + let locking_config = locking::new( + locking::window_count_based(5), + timelock::none(), + timelock::none(), + ); // Last 5 records locked + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Locked record"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Try to delete locked record - should fail + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Only 1 record exists, and last 5 are locked, so it's locked + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_delete_record_after_time_lock_expires() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Locked record"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + clock.set_for_testing(initial_time_for_testing() + 3600 * 1000); + assert!(!trail.is_record_locked(0, &clock), 0); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 3601 * 1000); + + assert!(trail.has_record(0), 1); + assert!(!trail.is_record_locked(0, &clock), 2); + + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + assert!(!trail.has_record(0), 3); + + clock::destroy_for_testing(clock); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Delete Records Batch Tests ===== + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_delete_records_batch_requires_delete_all_records_permission() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Record"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::delete_audit_trail()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TrailDeleteOnly"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let delete_only_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TrailDeleteOnly"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(delete_only_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_only_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.delete_records_batch(&delete_only_cap, 10, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + delete_only_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Query Function Tests ===== + +#[test] +fun test_get_record() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with initial record + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let initial_data = record::new_bytes(b"Test data"); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(initial_data), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + + let record = trail.get_record(0); + let data = audit_trails::record::data(record); + + assert!(record::bytes(data) == option::some(b"Test data"), 0); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordNotFound)] +fun test_get_record_not_found() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail (no initial record) + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + + // This should fail - no records exist + let _record = trail.get_record(0); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_first_last_sequence() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin and test sequence functions + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Empty trail + assert!(trail.first_sequence().is_none(), 0); + assert!(trail.last_sequence().is_none(), 1); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Add first record + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"First")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(trail.first_sequence() == std::option::some(0), 2); + assert!(trail.last_sequence() == std::option::some(0), 3); + + // Add second record + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Second")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(trail.first_sequence() == std::option::some(0), 4); + assert!(trail.last_sequence() == std::option::some(1), 5); + + // Add third record + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Third")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(trail.first_sequence() == std::option::some(0), 6); + assert!(trail.last_sequence() == std::option::some(2), 7); + + record_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordNotFound)] +fun test_is_record_locked_not_found() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail (no initial record) + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // This should fail - record doesn't exist + let _locked = trail.is_record_locked(0, &clock); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move new file mode 100644 index 00000000..4fc47095 --- /dev/null +++ b/audit-trail-move/tests/role_tests.move @@ -0,0 +1,820 @@ +#[allow(lint(abort_without_constant))] +#[test_only] +module audit_trails::role_tests; + +use audit_trails::{ + locking, + main::{initial_admin_role_name, AuditTrail}, + permission, + record::{Self, Data}, + record_tags, + test_utils::{ + Self, + setup_test_audit_trail, + setup_test_audit_trail_with_tags, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock + } +}; +use iota::test_scenario as ts; +use std::string; +use tf_components::timelock; + +#[test] +fun test_role_based_permission_delegation() { + let admin_user = @0xAD; + let role_admin_user = @0xB0B; + let cap_admin_user = @0xCAB; + let record_admin_user = @0xDED; + + let mut scenario = ts::begin(admin_user); + + // Step 1: admin_user creates the audit trail + let trail_id = { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + + // Verify admin capability was created with correct role and trail reference + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.target_key() == trail_id, 1); + + // Transfer the admin capability to the user + transfer::public_transfer(admin_cap, admin_user); + + trail_id + }; + + // Step 2: Admin creates RoleAdmin and CapAdmin roles + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Verify initial state - should only have the initial admin role + assert!(trail.access().size() == 1, 2); + + // Create RoleAdmin role + let role_admin_perms = permission::role_admin_permissions(); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleAdmin"), + role_admin_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Create CapAdmin role + let cap_admin_perms = permission::cap_admin_permissions(); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"CapAdmin"), + cap_admin_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify both roles were created + assert!(trail.access().size() == 3, 3); // Initial admin + RoleAdmin + CapAdmin + assert!(trail.access().has_role(&string::utf8(b"RoleAdmin")), 4); + assert!(trail.access().has_role(&string::utf8(b"CapAdmin")), 5); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Step 3: Admin creates capability for RoleAdmin and CapAdmin and transfers to the respective users + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let role_admin_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RoleAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the capability was created with correct role and trail ID + assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 6); + assert!(role_admin_cap.target_key() == trail_id, 7); + + iota::transfer::public_transfer(role_admin_cap, role_admin_user); + + let cap_admin_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"CapAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the capability was created with correct role and trail ID + assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 8); + assert!(cap_admin_cap.target_key() == trail_id, 9); + + iota::transfer::public_transfer(cap_admin_cap, cap_admin_user); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Step 5: RoleAdmin creates RecordAdmin role (demonstrating delegated role management) + ts::next_tx(&mut scenario, role_admin_user); + { + let (role_admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Verify RoleAdmin has the correct role + assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 10); + + let record_admin_perms = permission::record_admin_permissions(); + trail + .access_mut() + .create_role( + &role_admin_cap, + string::utf8(b"RecordAdmin"), + record_admin_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify RecordAdmin role was created successfully + assert!(trail.access().size() == 4, 11); // Initial admin + RoleAdmin + CapAdmin + RecordAdmin + assert!(trail.access().has_role(&string::utf8(b"RecordAdmin")), 12); + + cleanup_capability_trail_and_clock(&scenario, role_admin_cap, trail, clock); + }; + + // Step 6: CapAdmin creates capability for RecordAdmin and transfers to record_admin_user + ts::next_tx(&mut scenario, cap_admin_user); + { + let (cap_admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Verify CapAdmin has the correct role + assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 13); + + let record_admin_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &cap_admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the capability was created with correct role and trail ID + assert!(record_admin_cap.role() == string::utf8(b"RecordAdmin"), 14); + assert!(record_admin_cap.target_key() == trail_id, 15); + + iota::transfer::public_transfer(record_admin_cap, record_admin_user); + + cleanup_capability_trail_and_clock(&scenario, cap_admin_cap, trail, clock); + }; + + // Step 7: RecordAdmin adds a new record to the audit trail (demonstrating delegated record management) + ts::next_tx(&mut scenario, record_admin_user); + { + let (record_admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock( + &mut scenario, + ); + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); + + // Verify RecordAdmin has the correct role + assert!(record_admin_cap.role() == string::utf8(b"RecordAdmin"), 16); + + // Verify initial record count + let initial_record_count = trail.records().length(); + + let test_data = record::new_text(string::utf8(b"Test record added by RecordAdmin")); + + trail.add_record( + &record_admin_cap, + test_data, + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the record was added successfully + assert!(trail.records().length() == initial_record_count + 1, 17); + + cleanup_capability_trail_and_clock(&scenario, record_admin_cap, trail, clock); + }; + + // Cleanup + ts::next_tx(&mut scenario, admin_user); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::main::ERecordTagNotDefined)] +fun test_create_role_rejects_undefined_record_tags() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let perms = permission::from_vec(vector[permission::add_record()]); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedRole"), + perms, + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +fun test_delete_role_success() { + let admin_user = @0xAD; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Verify initial state - only Admin role exists + assert!(trail.access().size() == 1, 0); + + // Create a role to delete + let perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleToDelete"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the role was created + assert!(trail.access().size() == 2, 1); + assert!(trail.access().has_role(&string::utf8(b"RoleToDelete")), 2); + + // Delete the role + trail + .access_mut() + .delete_role( + &admin_cap, + &string::utf8(b"RoleToDelete"), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the role was deleted + assert!(trail.access().size() == 1, 3); + assert!(!trail.access().has_role(&string::utf8(b"RoleToDelete")), 4); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::main::ERecordTagInUse)] +fun test_remove_record_tag_rejects_role_only_usage() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let perms = permission::from_vec(vector[permission::add_record()]); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedRole"), + perms, + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + trail.remove_record_tag( + &admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +// ===== Error Case Tests ===== + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_create_role_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create role without RolesAdd permission + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Create role WITHOUT add_roles permission + let perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"NoRolesPerm"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let user_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NoRolesPerm"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // User tries to create a role - should fail + ts::next_tx(&mut scenario, user); + { + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::add_record()]); + + // This should fail - no add_roles permission + trail + .access_mut() + .create_role( + &user_cap, + string::utf8(b"NewRole"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_delete_role_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create roles + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Create a role to delete + let perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleToDelete"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Create role WITHOUT delete_roles permission + let no_delete_perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"NoDeleteRolePerm"), + no_delete_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let user_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NoDeleteRolePerm"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // User tries to delete a role - should fail + ts::next_tx(&mut scenario, user); + { + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // This should fail - no delete_roles permission + trail + .access_mut() + .delete_role(&user_cap, &string::utf8(b"RoleToDelete"), &clock, ts::ctx(&mut scenario)); + + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ECapabilityPermissionDenied)] +fun test_update_role_permissions_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create roles + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Create a role to update + let perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleToUpdate"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Create role WITHOUT update_roles permission + let no_update_perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"NoUpdateRolePerm"), + no_update_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let user_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"NoUpdateRolePerm"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // User tries to update a role - should fail + ts::next_tx(&mut scenario, user); + { + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let new_perms = permission::from_vec(vector[permission::delete_record()]); + + // This should fail - no update_roles permission + trail + .access_mut() + .update_role( + &user_cap, + &string::utf8(b"RoleToUpdate"), + new_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ERoleDoesNotExist)] +fun test_get_role_permissions_nonexistent() { + let admin_user = @0xAD; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let trail = ts::take_shared>(&scenario); + + // This should fail - role doesn't exist + let _perms = trail.access().get_role_permissions(&string::utf8(b"NonExistentRole")); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_update_role_permissions_success() { + let admin_user = @0xAD; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // Create a role with add_record permission + let initial_perms = permission::from_vec(vector[permission::add_record()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TestRole"), + initial_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the role was created with add_record permission + let perms = trail.access().get_role_permissions(&string::utf8(b"TestRole")); + assert!(perms.contains(&permission::add_record()), 0); + assert!(!perms.contains(&permission::delete_record()), 1); + + // Update the role to have delete_record permission instead + let new_perms = permission::from_vec(vector[permission::delete_record()]); + trail + .access_mut() + .update_role( + &admin_cap, + &string::utf8(b"TestRole"), + new_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the permissions were updated + let updated_perms = trail.access().get_role_permissions(&string::utf8(b"TestRole")); + assert!(!updated_perms.contains(&permission::add_record()), 2); + assert!(updated_perms.contains(&permission::delete_record()), 3); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::main::ERecordTagNotDefined)] +fun test_update_role_permissions_rejects_undefined_record_tags() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TestRole"), + permission::from_vec(vector[permission::add_record()]), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail.update_role_permissions( + &admin_cap, + string::utf8(b"TestRole"), + permission::from_vec(vector[permission::add_record()]), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trails::role_map::ERoleDoesNotExist)] +fun test_update_role_permissions_nonexistent() { + let admin_user = @0xAD; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let new_perms = permission::from_vec(vector[permission::add_record()]); + + // This should fail - role doesn't exist + trail + .access_mut() + .update_role( + &admin_cap, + &string::utf8(b"NonExistentRole"), + new_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move new file mode 100644 index 00000000..c687ccf7 --- /dev/null +++ b/audit-trail-move/tests/test_utils.move @@ -0,0 +1,233 @@ +#[test_only] +module audit_trails::test_utils; + +use audit_trails::{locking, main::{Self, AuditTrail}, record::{Self, Data}}; +use iota::{clock::{Self, Clock}, test_scenario::{Self as ts, Scenario}}; +use std::string; +use tf_components::{capability::Capability, role_map::RoleMap}; + +const INITIAL_TIME_FOR_TESTING: u64 = 1234567; + +/// Test data type for audit trail records +public struct TestData has copy, drop, store { + value: u64, + message: vector, +} + +public(package) fun new_test_data(value: u64, message: vector): TestData { + TestData { + value, + message, + } +} + +public(package) fun test_data_value(data: &TestData): u64 { + data.value +} + +public(package) fun test_data_message(data: &TestData): vector { + data.message +} + +public(package) fun initial_time_for_testing(): u64 { + INITIAL_TIME_FOR_TESTING +} + +/// Setup a test audit trail with optional initial data +public(package) fun setup_test_audit_trail( + scenario: &mut Scenario, + locking_config: locking::LockingConfig, + initial_data: Option, +): (Capability, iota::object::ID) { + setup_test_audit_trail_impl(scenario, locking_config, initial_data, vector[]) +} + +/// Setup a test audit trail with optional initial data and available record tags. +public(package) fun setup_test_audit_trail_with_tags( + scenario: &mut Scenario, + locking_config: locking::LockingConfig, + initial_data: Option, + available_record_tags: vector, +): (Capability, iota::object::ID) { + setup_test_audit_trail_impl(scenario, locking_config, initial_data, available_record_tags) +} + +/// Setup a test audit trail backed by the `TestData` helper type. +public(package) fun setup_test_data_audit_trail( + scenario: &mut Scenario, + locking_config: locking::LockingConfig, + initial_data: Option, +): (Capability, iota::object::ID) { + setup_test_audit_trail_impl(scenario, locking_config, initial_data, vector[]) +} + +/// Setup a test audit trail backed by `TestData` with available record tags. +public(package) fun setup_test_data_audit_trail_with_tags( + scenario: &mut Scenario, + locking_config: locking::LockingConfig, + initial_data: Option, + available_record_tags: vector, +): (Capability, iota::object::ID) { + setup_test_audit_trail_impl( + scenario, + locking_config, + initial_data, + available_record_tags, + ) +} + +fun setup_test_audit_trail_impl( + scenario: &mut Scenario, + locking_config: locking::LockingConfig, + initial_data: Option, + available_record_tags: vector, +): (Capability, iota::object::ID) { + let (admin_cap, trail_id) = { + let mut clock = clock::create_for_testing(ts::ctx(scenario)); + clock.set_for_testing(INITIAL_TIME_FOR_TESTING); + + let trail_metadata = main::new_trail_metadata( + string::utf8(b"Setup Test Trail"), + option::some(string::utf8(b"Setup Test Trail Description")), + ); + + let initial_record = if (initial_data.is_some()) { + option::some( + record::new_initial_record( + initial_data.destroy_some(), + option::none(), + option::none(), + ), + ) + } else { + initial_data.destroy_none(); + option::none() + }; + + let (admin_cap, trail_id) = main::create( + initial_record, + locking_config, + option::some(trail_metadata), + option::none(), + available_record_tags, + &clock, + ts::ctx(scenario), + ); + + clock::destroy_for_testing(clock); + (admin_cap, trail_id) + }; + + (admin_cap, trail_id) +} + +/// Create a new unrestricted capability with a specific role without any +/// address, valid_from, or valid_until restrictions. +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +public fun new_capability_without_restrictions( + role_map: &mut RoleMap, + cap: &Capability, + role: &string::String, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + role_map.new_capability( + cap, + role, + std::option::none(), + std::option::none(), + std::option::none(), + clock, + ctx, + ) +} + +/// Create a new capability with a specific role that expires at a given timestamp (milliseconds since Unix epoch). +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +public(package) fun new_capability_valid_until( + role_map: &mut RoleMap, + cap: &Capability, + role: &string::String, + valid_until: u64, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + role_map.new_capability( + cap, + role, + std::option::none(), + std::option::none(), + std::option::some(valid_until), + clock, + ctx, + ) +} + +/// Create a new capability with a specific role restricted to an address. +/// Optionally set an expiration time (milliseconds since Unix epoch). +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +public fun new_capability_for_address( + role_map: &mut RoleMap, + cap: &Capability, + role: &string::String, + issued_to: address, + valid_until: Option, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + role_map.new_capability( + cap, + role, + std::option::some(issued_to), + std::option::none(), + valid_until, + clock, + ctx, + ) +} + +public(package) fun fetch_capability_trail_and_clock( + scenario: &mut Scenario, +): (Capability, AuditTrail, Clock) { + let admin_cap = ts::take_from_sender(scenario); + let trail = ts::take_shared>(scenario); + let clock = iota::clock::create_for_testing(ts::ctx(scenario)); + (admin_cap, trail, clock) +} + +public(package) fun cleanup_capability_trail_and_clock( + scenario: &Scenario, + cap: Capability, + trail: AuditTrail, + clock: Clock, +) { + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(scenario, cap); + ts::return_shared(trail); +} + +public(package) fun cleanup_trail_and_clock(trail: AuditTrail, clock: Clock) { + iota::clock::destroy_for_testing(clock); + ts::return_shared(trail); +} diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml new file mode 100644 index 00000000..ed248654 --- /dev/null +++ b/audit-trail-rs/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "audit_trails" +version = "0.1.0-alpha" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["iota", "tangle", "utxo", "audit-trail"] +license.workspace = true +readme = "./README.md" +repository.workspace = true +rust-version.workspace = true +description = "An audit trail toolkit for the IOTA Ledger." + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +bcs.workspace = true +iota-caip = { git = "https://github.com/iotaledger/iota-caip.git", default-features = false, features = ["iota"], optional = true } +iota_interaction = { workspace = true, default-features = false } +product_common = { workspace = true, default-features = false, features = ["transaction"] } +secret-storage = { workspace = true, default-features = false } +serde.workspace = true +serde-aux = { workspace = true, default-features = false } +serde_json.workspace = true +strum.workspace = true +thiserror.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +iota_interaction_rust = { workspace = true, default-features = false } +iota-sdk = { workspace = true } +tokio = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +iota_interaction_ts.workspace = true +js-sys = "0.3" +product_common = { workspace = true, default-features = false, features = ["bindings"] } +tokio = { version = "1.46.1", default-features = false, features = ["sync"] } + +[dev-dependencies] +async-trait.workspace = true +iota_interaction = { workspace = true } +product_common = { workspace = true, features = ["transaction", "test-utils"] } + +[build-dependencies] +product_common = { workspace = true, features = ["move-history-manager"] } + +[features] +default = ["send-sync"] +send-sync = [ + "send-sync-storage", + "product_common/send-sync", + "iota_interaction/send-sync-transaction", +] +# Enables `Send` + `Sync` bounds for the storage traits. +send-sync-storage = ["secret-storage/send-sync-storage"] +# Enables an high-level integration with IOTA gas-station. +gas-station = ["product_common/gas-station"] +# Uses a default HTTP Client instead of a user-provided one. +default-http-client = ["product_common/default-http-client"] +# Enables the interaction with IOTA Resource Locators. +irl = ["dep:iota-caip"] diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md new file mode 100644 index 00000000..6c4684f9 --- /dev/null +++ b/audit-trail-rs/README.md @@ -0,0 +1,272 @@ +# IOTA Audit Trails Rust Package + +## Introduction + +The Audit Trails Rust package provides the Rust client for structured record histories in the IOTA Notarization Toolkit. + +The package also provides an `AuditTrailBuilder` that creates audit trail objects on the IOTA ledger and an `AuditTrailHandle` +that interacts with existing trails. The handle maps to one on-chain audit trail and provides typed APIs for records, +access control, locking, tags, metadata, migration, and deletion. + +Use Audit Trails when you need a governed record history with sequential entries, role-based permissions, capabilities, +locking, and tagging. Use Single Notarization when you need one locked or dynamic notarized object for arbitrary data, +documents, hashes, or latest-state records. + +You can find the full IOTA Notarization Toolkit documentation [here](https://docs.iota.org/developer/iota-notarization). + +## Process Flows + +The following workflows demonstrate how `AuditTrailBuilder` and `AuditTrailHandle` instances create, update, govern, and +delete audit trail objects on the ledger. + +### Creating an `AuditTrail` Object + +An `AuditTrail` on-chain object is created on the ledger using the `AuditTrailClient::create_trail()` function. To +create an `AuditTrail` object, specify the following initial arguments with the `AuditTrailBuilder` setter functions. +The terms used here are defined in the [glossary below](#glossary). + +- Optional `Initial Record` that becomes sequence number `0` +- Optional `Immutable Metadata` +- Optional `Updatable Metadata` +- Optional `Locking Config` +- Optional `Record Tag Registry` +- Optional initial admin address + +After an `AuditTrail` object has been created, the creator receives an Admin capability object. This capability authorizes +administrative operations such as defining roles, issuing capabilities, updating locks, managing tags, and deleting the +trail. + +#### Creating a new `AuditTrail` Object on the Ledger + +The following sequence diagram explains the interaction between the involved technical components and the `Admin` when an +`AuditTrail` object is created on the ledger: + +```mermaid +sequenceDiagram + actor Admin + participant Lib as Rust-Library + participant Move as Move-SC + participant Net as Iota-Network + Admin ->>+ Lib: fn AuditTrailClientReadOnly::new(iota_client) + Lib ->>- Admin: AuditTrailClientReadOnly + Admin ->>+ Lib: fn AuditTrailClient::new(read_only_client, signer) + Lib ->>- Admin: AuditTrailClient + Admin ->>+ Lib: fn AuditTrailClient::create_trail() + Lib ->>- Admin: AuditTrailBuilder + Admin ->> Lib: fn AuditTrailBuilder::with_trail_metadata(metadata) + Admin ->> Lib: fn AuditTrailBuilder::with_updatable_metadata(metadata) + Admin ->> Lib: fn AuditTrailBuilder::with_initial_record(record) + Admin ->>+ Lib: fn AuditTrailBuilder::finish() + Lib ->>- Admin: TransactionBuilder + Admin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Note right of Admin: Alternatively fn execute_with_gas_station()
can be used to execute via Gas Station + Note right of Admin: Alternatively fn build()
can be used to only return the TransactionData and signatures + Lib ->>+ Move: main::create() + Move ->> Net: transfer::transfer(trail, sender) + Move ->> Net: transfer::transfer(admin_capability, admin) + Move ->>- Lib: TX Response + Lib ->>- Admin: TrailCreated + IotaTransactionBlockResponse +``` + +### Adding And Reading Records + +Records are managed through the trail-scoped record API returned by `AuditTrailHandle::records()`. A record append uses +`TrailRecords::add()`, while read paths use `TrailRecords::get()`, `TrailRecords::record_count()`, `TrailRecords::list()`, +or `TrailRecords::list_page()`. + +To add a record, the sender must hold a capability whose role allows record writes. Tagged records must use a tag already +defined in the trail's tag registry, and the sender's role must allow that tag. + +#### Appending a Record to an Existing `AuditTrail` Object + +The following sequence diagram shows the component interaction when a `Record Admin` appends a new record: + +```mermaid +sequenceDiagram + actor RecordAdmin + participant Lib as Rust-Library + participant Move as Move-SC + participant Net as Iota-Network + RecordAdmin ->>+ Lib: fn AuditTrailClient::trail(trail_id) + Lib ->>- RecordAdmin: AuditTrailHandle + RecordAdmin ->>+ Lib: fn AuditTrailHandle::records() + Lib ->>- RecordAdmin: TrailRecords + RecordAdmin ->>+ Lib: fn TrailRecords::add(data, metadata, tag) + Lib ->>- RecordAdmin: TransactionBuilder + RecordAdmin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Lib ->>+ Move: main::add_record() + Move ->> Move: validate capability, lock state, and tag policy + Move ->> Net: append record at next sequence number + Move ->> Net: event::emit(RecordAdded) + Move ->>- Lib: TX Response + Lib ->>- RecordAdmin: RecordAdded + IotaTransactionBlockResponse +``` + +#### Reading Records from an Existing `AuditTrail` Object + +The following sequence diagram explains the component interaction for `Verifiers` or other parties fetching trail +records: + +```mermaid +sequenceDiagram + actor Verifier + participant Lib as Rust-Library + participant Net as Iota-Network + Verifier ->>+ Lib: fn AuditTrailClientReadOnly::trail(trail_id) + Lib ->>- Verifier: AuditTrailHandle + Verifier ->>+ Lib: fn AuditTrailHandle::records() + Lib ->>- Verifier: TrailRecords + Verifier ->>+ Lib: fn TrailRecords::get(sequence_number) + Lib -->> Net: RPC Calls + Net -->> Lib: Record Data + Lib ->>- Verifier: Record +``` + +### Managing Access + +Access control is managed through the trail-scoped access API returned by `AuditTrailHandle::access()`. Roles define +which permissions are allowed, and capability objects delegate those roles to users or services. + +The built-in Admin role is initialized when the trail is created. Additional roles can be created with +`TrailAccess::for_role(name).create(...)`, updated with `update_permissions(...)`, and delegated with +`issue_capability(...)`. Issued capabilities can also be revoked, destroyed, or constrained by address and validity +window. + +#### Defining a Role and Issuing a Capability + +The following sequence diagram shows the component interaction when an `Admin` defines a role and issues a capability: + +```mermaid +sequenceDiagram + actor Admin + participant Lib as Rust-Library + participant Move as Move-SC + participant Net as Iota-Network + Admin ->>+ Lib: fn AuditTrailClient::trail(trail_id) + Lib ->>- Admin: AuditTrailHandle + Admin ->>+ Lib: fn AuditTrailHandle::access() + Lib ->>- Admin: TrailAccess + Admin ->>+ Lib: fn TrailAccess::for_role("RecordAdmin") + Lib ->>- Admin: RoleHandle + Admin ->>+ Lib: fn RoleHandle::create(permissions, role_tags) + Lib ->>- Admin: TransactionBuilder + Admin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Lib ->>+ Move: main::create_role() + Move ->> Net: event::emit(RoleCreated) + Move ->>- Lib: TX Response + Admin ->>+ Lib: fn RoleHandle::issue_capability(options) + Lib ->>- Admin: TransactionBuilder + Admin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Lib ->>+ Move: main::new_capability() + Move ->> Net: transfer::transfer(capability, issued_to) + Move ->> Net: event::emit(CapabilityIssued) + Move ->>- Lib: TX Response + Lib ->>- Admin: CapabilityIssued + IotaTransactionBlockResponse +``` + +### Locking And Deletion + +Locking is managed through the trail-scoped locking API returned by `AuditTrailHandle::locking()`. The lock configuration +controls three independent behaviors: + +- when records can be deleted +- when the entire trail can be deleted +- when new records can be written + +An audit trail can be deleted only after its records are removed and the delete-trail lock allows deletion. Records can +be deleted individually with `TrailRecords::delete()` or in batches with `TrailRecords::delete_records_batch()`. +For count-based delete windows, the Move package protects the last N records currently present in trail order. Deletions +can move older records into that protected window, and large count values increase delete gas linearly. + +#### Updating Locking Rules + +The following sequence diagram shows the component interaction when a `Locking Admin` updates the write lock: + +```mermaid +sequenceDiagram + actor LockingAdmin + participant Lib as Rust-Library + participant Move as Move-SC + participant Net as Iota-Network + LockingAdmin ->>+ Lib: fn AuditTrailClient::trail(trail_id) + Lib ->>- LockingAdmin: AuditTrailHandle + LockingAdmin ->>+ Lib: fn AuditTrailHandle::locking() + Lib ->>- LockingAdmin: TrailLocking + LockingAdmin ->>+ Lib: fn TrailLocking::update_write_lock(lock) + Lib ->>- LockingAdmin: TransactionBuilder + LockingAdmin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Lib ->>+ Move: main::update_write_lock() + Move ->> Move: validate capability and requested lock + Move ->> Net: update trail locking config + Move ->>- Lib: TX Response + Lib ->>- LockingAdmin: () + IotaTransactionBlockResponse +``` + +#### Deleting an `AuditTrail` Object + +The workflow of deletion an `AuditTrail` object can be described as: + +- Delete all eligible unlocked records with `TrailRecords::delete()` or `TrailRecords::delete_records_batch()` +- If not all records have been deleted: + - if a lock is configured, wait until the `Delete Trail Lock` allows trail deletion, + - if records are tag protected, notify a user with an appropriate role granting the needed tag access, + to delete the remaining records +- Delete the trail object with `AuditTrailHandle::delete_audit_trail()` + +The trail deletion process does not remove records automatically. The trail must be empty before +`delete_audit_trail()` can succeed. + +## Glossary + +- `AuditTrail` object: A shared on-chain object that stores ordered records, metadata, locking configuration, tag registry, + roles, and capability state. +- `Record`: A single trail entry stored at a sequence number. Records contain `Data`, optional record metadata, an + optional tag, and creation information. +- `Initial Record`: An optional record created together with the trail. When present, it is stored at sequence number + `0`. +- `Sequence Number`: The numeric position of a record inside a trail. Sequence numbers are used to fetch, delete, and + reason about records. +- `Admin Capability`: The capability object created at trail creation time. It authorizes administrative operations for + the trail. +- `Role`: A named permission set stored inside the trail. Roles define which operations a capability holder may perform. +- `Permission Set`: A collection of permissions such as adding records, deleting records, updating locks, managing tags, + managing metadata, or managing capabilities. +- `Capability`: An owned object that grants one role for one audit trail. Capabilities can optionally be restricted to an + address or a validity window. +- `Record Tag Registry`: The trail-owned list of tags that records may use. Tagged writes must reference a registered + tag. +- `Role Tags`: Optional role-scoped tag restrictions. They narrow which tagged records a role may operate on. +- `Locking Config`: The active locking rules for record deletion, trail deletion, and record writes. +- `Delete Record Window`: A locking rule that controls when individual records can be deleted. Count-based windows protect + the last N records currently present in trail order. +- `Delete Trail Lock`: A time lock that controls when the entire trail can be deleted. +- `Write Lock`: A time lock that controls when new records can be added. +- `Immutable Metadata`: Optional metadata stored at creation time and never updated after the trail is created. +- `Updatable Metadata`: Optional metadata stored on the trail that can be replaced or cleared after creation. +- `Trail Handle`: The typed Rust handle returned by `AuditTrailClient::trail(trail_id)`. It scopes record, access, + locking, tag, metadata, migration, and deletion operations to one audit trail. + +## Documentation And Resources + +- [Audit Trails Move Package](https://github.com/iotaledger/notarization/tree/main/audit-trail-move): On-chain contract package that defines the shared object model, permissions, locking, and events. +- [Audit Trails Wasm Package](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm): JavaScript and TypeScript bindings for browser and Node.js integrations. +- [Audit Trails Wasm Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/examples/README.md): Runnable audit-trail examples for JS and TS consumers. +- [Repository Examples](https://github.com/iotaledger/notarization/tree/main/examples/README.md): End-to-end examples across the Notarization Toolkit. + +This README is also used as the crate-level rustdoc entry point, while the source files provide detailed API documentation for all public types and methods. + +## Bindings + +[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) bindings of this Rust crate to other programming languages: + +- [Web Assembly](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm) (JavaScript/TypeScript) + +## Contributing + +We would love to have you help us with the development of the IOTA Notarization Toolkit. Each and every contribution is greatly valued. + +Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). + +To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included. + +The best place to get involved in discussions about this library or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). diff --git a/audit-trail-rs/build.rs b/audit-trail-rs/build.rs new file mode 100644 index 00000000..3486b438 --- /dev/null +++ b/audit-trail-rs/build.rs @@ -0,0 +1,29 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use product_common::move_history_manager::MoveHistoryManager; + +fn main() { + let move_lock_path = "../audit-trail-move/Move.lock"; + println!("[build.rs] move_lock_path: {move_lock_path}"); + let move_history_path = "../audit-trail-move/Move.history.json"; + println!("[build.rs] move_history_path: {move_history_path}"); + + MoveHistoryManager::new( + &PathBuf::from(move_lock_path), + &PathBuf::from(move_history_path), + // We will watch the default watch list (`get_default_aliases_to_watch()`) in this build script + // so we leave the `additional_aliases_to_watch` argument vec empty. + // Use for example `vec!["localnet".to_string()]` instead, if you don't want to ignore `localnet`. + vec![], + ) + .manage_history_file(|message| { + println!("[build.rs] {}", message); + }) + .expect("Successfully managed Move history file"); + + // Tell Cargo to rerun this build script if the Move.lock file changes. + println!("cargo::rerun-if-changed={move_lock_path}"); +} diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs new file mode 100644 index 00000000..5a2114f0 --- /dev/null +++ b/audit-trail-rs/src/client/full_client.rs @@ -0,0 +1,343 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! # Audit Trails Client +//! +//! The full client extends [`AuditTrailClientReadOnly`] with signing support and write +//! transaction builders. +//! +//! ## Transaction Flow +//! +//! Write APIs return a [`TransactionBuilder`](product_common::transaction::transaction_builder::TransactionBuilder) +//! that you can configure before signing and submitting: +//! +//! ```rust,no_run +//! # use audit_trails::AuditTrailClient; +//! # use audit_trails::core::types::Data; +//! # async fn example( +//! # client: &AuditTrailClient< +//! # impl secret_storage::Signer + iota_interaction::OptionalSync, +//! # >, +//! # ) -> Result<(), Box> { +//! let created = client +//! .create_trail() +//! .with_initial_record_parts(Data::text("Initial record"), None, None) +//! .finish()? +//! .with_gas_budget(1_000_000) +//! .build_and_execute(client) +//! .await?; +//! +//! let trail_id = created.output.trail_id; +//! +//! client +//! .trail(trail_id) +//! .records() +//! .add(Data::text("Follow-up record"), None, None) +//! .build_and_execute(client) +//! .await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Example Workflow +//! +//! ```rust,no_run +//! # use audit_trails::AuditTrailClient; +//! # use audit_trails::core::types::{Data, PermissionSet, RoleTags}; +//! # async fn example( +//! # client: &AuditTrailClient< +//! # impl secret_storage::Signer + iota_interaction::OptionalSync, +//! # >, +//! # ) -> Result<(), Box> { +//! let created = client +//! .create_trail() +//! .with_initial_record_parts(Data::text("Initial record"), None, None) +//! .with_record_tags(["finance"]) +//! .finish()? +//! .build_and_execute(client) +//! .await?; +//! +//! let trail_id = created.output.trail_id; +//! +//! client +//! .trail(trail_id) +//! .access() +//! .for_role("TaggedWriter") +//! .create(PermissionSet::record_admin_permissions(), Some(RoleTags::new(["finance"]))) +//! .build_and_execute(client) +//! .await?; +//! +//! client +//! .trail(trail_id) +//! .records() +//! .add(Data::text("Budget approved"), None, Some("finance".to_string())) +//! .build_and_execute(client) +//! .await?; +//! # Ok(()) +//! # } +//! ``` + +use std::ops::Deref; + +use async_trait::async_trait; +#[cfg(not(target_arch = "wasm32"))] +use iota_interaction::IotaClient; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::crypto::PublicKey; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::{IotaKeySignature, OptionalSync}; +#[cfg(target_arch = "wasm32")] +use iota_interaction_ts::bindings::WasmIotaClient as IotaClient; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::network_name::NetworkName; +use secret_storage::Signer; +use serde::de::DeserializeOwned; + +use crate::client::read_only::{AuditTrailClientReadOnly, PackageOverrides}; +use crate::core::builder::AuditTrailBuilder; +use crate::core::trail::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly}; +use crate::error::Error; +use crate::iota_interaction_adapter::IotaClientAdapter; + +/// A marker type indicating the absence of a signer. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct NoSigner; + +/// The error that results from a failed attempt at creating an [`AuditTrailClient`] +/// from a given [IotaClient]. +#[derive(Debug, thiserror::Error)] +#[error("failed to create an 'AuditTrailClient' from the given 'IotaClient'")] +#[non_exhaustive] +pub struct FromIotaClientError { + /// Type of failure for this error. + #[source] + pub kind: FromIotaClientErrorKind, +} + +/// Categories of failure for [`FromIotaClientError`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum FromIotaClientErrorKind { + /// A package ID is required, but was not supplied. + #[error("an audit-trail package ID must be supplied when connecting to an unofficial IOTA network")] + MissingPackageId, + /// Network ID resolution through an RPC call failed. + #[error("failed to resolve the network the given client is connected to")] + NetworkResolution(#[source] Box), +} + +/// A client for creating and managing audit trails on the IOTA blockchain. +/// +/// This client combines read-only capabilities with transaction signing, +/// enabling full interaction with audit trails. +/// +/// ## Type Parameter +/// +/// - `S`: The signer type that implements [`Signer`] +#[derive(Clone)] +pub struct AuditTrailClient { + /// The underlying read-only client used for executing read-only operations. + pub(super) read_client: AuditTrailClientReadOnly, + /// The public key associated with the signer, if any. + pub(super) public_key: Option, + /// The signer used for signing transactions, or `NoSigner` if the client is read-only. + pub(super) signer: S, +} + +impl Deref for AuditTrailClient { + type Target = AuditTrailClientReadOnly; + fn deref(&self) -> &Self::Target { + &self.read_client + } +} + +impl AuditTrailClient { + /// Creates a new client with no signing capabilities from an IOTA client. + /// + /// # Warning + /// + /// Passing `package_overrides` is only needed when connecting to a custom IOTA network or + /// when testing against explicitly deployed package pairs. + /// + /// Relying on a custom audit-trail package while connected to an official IOTA network is + /// strongly discouraged and can lead to compatibility problems with other official IOTA Trust + /// Framework products. + /// + /// # Examples + /// ```rust,ignore + /// # use audit_trails::client::AuditTrailClient; + /// + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let iota_client = iota_sdk::IotaClientBuilder::default() + /// .build_testnet() + /// .await?; + /// // No package ID is required since we are connecting to an official IOTA network. + /// let audit_trail_client = AuditTrailClient::from_iota_client(iota_client, None).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn from_iota_client( + iota_client: IotaClient, + package_overrides: impl Into>, + ) -> Result { + let read_only_client = if let Some(package_overrides) = package_overrides.into() { + AuditTrailClientReadOnly::new_with_package_overrides(iota_client, package_overrides).await + } else { + AuditTrailClientReadOnly::new(iota_client).await + } + .map_err(|e| match e { + Error::InvalidConfig(_) => FromIotaClientErrorKind::MissingPackageId, + Error::RpcError(msg) => FromIotaClientErrorKind::NetworkResolution(msg.into()), + _ => unreachable!( + "'AuditTrailClientReadOnly::new' has been changed without updating error handling in 'AuditTrailClient::from_iota_client'" + ), + }) + .map_err(|kind| FromIotaClientError { kind })?; + + Ok(Self { + read_client: read_only_client, + public_key: None, + signer: NoSigner, + }) + } +} + +impl AuditTrailClient { + /// Creates a signing client from an existing read-only client and signer. + /// + /// # Errors + /// + /// Returns an error if the signer public key cannot be loaded. + pub async fn new(client: AuditTrailClientReadOnly, signer: S) -> Result + where + S: Signer, + { + let public_key = signer + .public_key() + .await + .map_err(|e| Error::InvalidKey(e.to_string()))?; + + Ok(AuditTrailClient { + read_client: client, + public_key: Some(public_key), + signer, + }) + } + + /// Replaces the signer used by this client. + /// + /// # Errors + /// + /// Returns an error if the replacement signer public key cannot be loaded. + pub async fn with_signer(self, signer: NewS) -> Result, secret_storage::Error> + where + NewS: Signer, + { + let public_key = signer.public_key().await?; + + Ok(AuditTrailClient { + read_client: self.read_client, + public_key: Some(public_key), + signer, + }) + } + /// Returns the underlying read-only client view. + pub fn read_only(&self) -> &AuditTrailClientReadOnly { + &self.read_client + } + + /// Returns a typed handle bound to a specific trail object ID. + pub fn trail<'a>(&'a self, trail_id: ObjectID) -> AuditTrailHandle<'a, Self> { + AuditTrailHandle::new(self, trail_id) + } + + /// Returns the TfComponents package ID used by this client. + pub fn tf_components_package_id(&self) -> ObjectID { + self.read_client.tf_components_package_id() + } + + /// Creates a builder for a new audit trail. + /// + /// When the client has a signer, the builder is pre-populated with that signer's address as + /// the initial admin. + pub fn create_trail(&self) -> AuditTrailBuilder { + AuditTrailBuilder { + admin: self.public_key.as_ref().map(IotaAddress::from), + ..AuditTrailBuilder::default() + } + } +} + +impl AuditTrailClient +where + S: Signer, +{ + /// Returns a reference to the [PublicKey] wrapped by this client. + pub fn public_key(&self) -> &PublicKey { + self.public_key.as_ref().expect("public_key is set") + } + + /// Returns the [IotaAddress] wrapped by this client. + #[inline(always)] + pub fn address(&self) -> IotaAddress { + IotaAddress::from(self.public_key()) + } +} + +#[cfg_attr(feature = "send-sync", async_trait)] +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +impl CoreClientReadOnly for AuditTrailClient { + fn package_id(&self) -> ObjectID { + self.read_client.package_id() + } + + fn tf_components_package_id(&self) -> Option { + Some(self.read_client.tf_components_package_id()) + } + + fn network_name(&self) -> &NetworkName { + self.read_client.network() + } + + fn client_adapter(&self) -> &IotaClientAdapter { + &self.read_client + } +} + +#[cfg_attr(feature = "send-sync", async_trait)] +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +impl CoreClient for AuditTrailClient +where + S: Signer + OptionalSync, +{ + fn signer(&self) -> &S { + &self.signer + } + + fn sender_address(&self) -> IotaAddress { + IotaAddress::from(self.public_key()) + } + + fn sender_public_key(&self) -> &PublicKey { + self.public_key() + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl AuditTrailReadOnly for AuditTrailClient +where + S: Signer + OptionalSync, +{ + /// Delegates read-only execution to the wrapped [`AuditTrailClientReadOnly`]. + async fn execute_read_only_transaction( + &self, + tx: ProgrammableTransaction, + ) -> Result { + self.read_client.execute_read_only_transaction(tx).await + } +} + +impl AuditTrailFull for AuditTrailClient where S: Signer + OptionalSync {} diff --git a/audit-trail-rs/src/client/mod.rs b/audit-trail-rs/src/client/mod.rs new file mode 100644 index 00000000..feef288f --- /dev/null +++ b/audit-trail-rs/src/client/mod.rs @@ -0,0 +1,32 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Client implementations for interacting with audit trails on the IOTA ledger. +//! +//! [`AuditTrailClientReadOnly`] is the entry point for read-only inspection and typed trail handles. +//! [`AuditTrailClient`] wraps a read-only client together with a signer so it can build write +//! transactions through the shared transaction infrastructure. + +use iota_interaction::IotaClientTrait; +use product_common::network_name::NetworkName; + +use crate::error::Error; +use crate::iota_interaction_adapter::IotaClientAdapter; + +/// A signing client that can create audit-trail transaction builders. +pub mod full_client; +/// A read-only client that resolves package IDs and executes inspected calls. +pub mod read_only; + +pub use full_client::*; +pub use read_only::*; + +/// Resolves the network name reported by the given IOTA client. +async fn network_id(iota_client: &IotaClientAdapter) -> Result { + let network_id = iota_client + .read_api() + .get_chain_identifier() + .await + .map_err(|e| Error::RpcError(e.to_string()))?; + Ok(network_id.try_into().expect("chain ID is a valid network name")) +} diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs new file mode 100644 index 00000000..4a087aff --- /dev/null +++ b/audit-trail-rs/src/client/read_only.rs @@ -0,0 +1,217 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Read-only client support for audit-trail interactions. +//! +//! [`AuditTrailClientReadOnly`] resolves the deployed package IDs for the connected network, exposes +//! typed trail handles, and provides the internal read-only execution primitive used by the handle +//! APIs. + +use std::ops::Deref; + +#[cfg(not(target_arch = "wasm32"))] +use iota_interaction::IotaClient; +use iota_interaction::IotaClientTrait; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::{ProgrammableTransaction, TransactionKind}; +#[cfg(target_arch = "wasm32")] +use iota_interaction_ts::bindings::WasmIotaClient; +use product_common::core_client::CoreClientReadOnly; +use product_common::network_name::NetworkName; +use serde::de::DeserializeOwned; + +use super::network_id; +use crate::core::trail::{AuditTrailHandle, AuditTrailReadOnly}; +use crate::error::Error; +use crate::iota_interaction_adapter::IotaClientAdapter; +use crate::package; + +/// Explicit package-ID overrides used when constructing an audit-trail client. +/// +/// Use this when talking to custom deployments, local test networks, or any environment where the +/// package registry does not yet know the relevant package IDs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PackageOverrides { + /// Override for the audit-trail package itself. + pub audit_trail: Option, + /// Override for the `tf_components` package used by time locks and capabilities. + pub tf_component: Option, +} + +/// A read-only client for interacting with audit-trail objects on a specific network. +/// +/// This is the main entry point for applications that only need package resolution and typed read +/// helpers. Once constructed, use [`Self::trail`] to create lightweight handles scoped to a single +/// trail object. +/// +/// For write flows, wrap this client in [`crate::AuditTrailClient`]. +#[derive(Clone)] +pub struct AuditTrailClientReadOnly { + /// The underlying IOTA client adapter used for communication. + iota_client: IotaClientAdapter, + /// The [`ObjectID`] of the deployed Audit Trail Package (smart contract). + audit_trail_pkg_id: ObjectID, + /// The [`ObjectID`] of the deployed TfComponents package used by Audit Trail. + pub(crate) tf_components_pkg_id: ObjectID, + /// The name of the network this client is connected to (e.g., "mainnet", "testnet"). + network: NetworkName, + /// Raw chain identifier returned by the IOTA node. + chain_id: String, +} + +impl Deref for AuditTrailClientReadOnly { + type Target = IotaClientAdapter; + fn deref(&self) -> &Self::Target { + &self.iota_client + } +} + +impl AuditTrailClientReadOnly { + /// Returns the name of the network the client is connected to. + pub const fn network(&self) -> &NetworkName { + &self.network + } + + /// Returns the raw chain identifier for the network this client is connected to. + pub fn chain_id(&self) -> &str { + &self.chain_id + } + + /// Returns the package ID used by this client. + /// + /// This is the deployed audit-trail Move package ID, not a trail object ID. + pub fn package_id(&self) -> ObjectID { + self.audit_trail_pkg_id + } + + /// Returns the TfComponents package ID used by this client. + pub fn tf_components_package_id(&self) -> ObjectID { + self.tf_components_pkg_id + } + + /// Returns a reference to the underlying IOTA client adapter. + pub const fn iota_client(&self) -> &IotaClientAdapter { + &self.iota_client + } + + /// Returns a typed handle bound to a specific trail object ID. + /// + /// Creating the handle is cheap. Reads only happen when you call methods on the returned + /// [`AuditTrailHandle`], such as [`AuditTrailHandle::get`]. + pub fn trail<'a>(&'a self, trail_id: ObjectID) -> AuditTrailHandle<'a, Self> { + AuditTrailHandle::new(self, trail_id) + } + + /// Creates a new read-only client from an IOTA client. + /// + /// The package IDs are resolved from the internal registry using the connected network name. + /// This is the recommended constructor when connecting to official deployments whose package + /// history is already tracked by the crate. + /// + /// # Errors + /// + /// Returns an error if the network cannot be resolved or if the package IDs for that network + /// cannot be determined. + pub async fn new( + #[cfg(target_arch = "wasm32")] iota_client: WasmIotaClient, + #[cfg(not(target_arch = "wasm32"))] iota_client: IotaClient, + ) -> Result { + let client = IotaClientAdapter::new(iota_client); + let network = network_id(&client).await?; + Self::new_internal(client, network, PackageOverrides::default()).await + } + + async fn new_internal( + iota_client: IotaClientAdapter, + network: NetworkName, + package_overrides: PackageOverrides, + ) -> Result { + let chain_id = network.as_ref().to_string(); + let (network, package_ids) = package::resolve_package_ids(&network, &package_overrides).await?; + + Ok(Self { + iota_client, + audit_trail_pkg_id: package_ids.audit_trail_package_id, + tf_components_pkg_id: package_ids.tf_components_package_id, + network, + chain_id, + }) + } + + /// Creates a new read-only client with explicit package-ID overrides. + /// + /// This bypasses the default package-registry lookup for any IDs provided in + /// [`PackageOverrides`]. + /// + /// Prefer this constructor when talking to custom deployments, local networks, or preview + /// environments whose package IDs are not yet part of the built-in registry. + /// + /// # Errors + /// + /// Returns an error if the network cannot be resolved or if the resulting package-ID + /// configuration is invalid. + pub async fn new_with_package_overrides( + #[cfg(target_arch = "wasm32")] iota_client: WasmIotaClient, + #[cfg(not(target_arch = "wasm32"))] iota_client: IotaClient, + package_overrides: PackageOverrides, + ) -> Result { + let client = IotaClientAdapter::new(iota_client); + let network = network_id(&client).await?; + Self::new_internal(client, network, package_overrides).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait::async_trait)] +impl CoreClientReadOnly for AuditTrailClientReadOnly { + fn package_id(&self) -> ObjectID { + self.audit_trail_pkg_id + } + + fn tf_components_package_id(&self) -> Option { + Some(self.tf_components_pkg_id) + } + + fn network_name(&self) -> &NetworkName { + &self.network + } + + fn client_adapter(&self) -> &IotaClientAdapter { + &self.iota_client + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait::async_trait)] +impl AuditTrailReadOnly for AuditTrailClientReadOnly { + /// Executes a programmable transaction through `dev_inspect` and decodes the first return + /// value as `T`. + /// + /// This is primarily used by the typed read-only handle APIs. + async fn execute_read_only_transaction( + &self, + tx: ProgrammableTransaction, + ) -> Result { + let inspection_result = self + .iota_client + .read_api() + .dev_inspect_transaction_block(IotaAddress::ZERO, TransactionKind::Programmable(tx), None, None, None) + .await + .map_err(|err| Error::UnexpectedApiResponse(format!("Failed to inspect transaction block: {err}")))?; + + let execution_results = inspection_result + .results + .ok_or_else(|| Error::UnexpectedApiResponse("DevInspectResults missing 'results' field".to_string()))?; + + let (return_value_bytes, _) = execution_results + .first() + .ok_or_else(|| Error::UnexpectedApiResponse("Execution results list is empty".to_string()))? + .return_values + .first() + .ok_or_else(|| Error::InvalidArgument("should have at least one return value".to_string()))?; + + let deserialized_output = bcs::from_bytes::(return_value_bytes)?; + + Ok(deserialized_output) + } +} diff --git a/audit-trail-rs/src/core/access/mod.rs b/audit-trail-rs/src/core/access/mod.rs new file mode 100644 index 00000000..a1296b0c --- /dev/null +++ b/audit-trail-rs/src/core/access/mod.rs @@ -0,0 +1,284 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Role and capability management APIs for Audit Trails. +//! +//! This module is the Rust-facing wrapper around the access-control state integrated into each audit trail. +//! Roles grant [`crate::core::types::PermissionSet`] values, while capability objects bind one role to one trail and +//! may add optional address or time restrictions. +//! +//! Additional record-tag constraints are represented as [`crate::core::types::RoleTags`]. They narrow which tagged +//! records a role may operate on, but they do not replace the underlying permission checks enforced by the Move +//! package. + +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::CoreClient; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; + +use crate::core::trail::AuditTrailFull; +use crate::core::types::{CapabilityIssueOptions, PermissionSet, RoleTags}; + +mod operations; +mod transactions; + +pub use transactions::{ + CleanupRevokedCapabilities, CreateRole, DeleteRole, DestroyCapability, DestroyInitialAdminCapability, + IssueCapability, RevokeCapability, RevokeInitialAdminCapability, UpdateRole, +}; + +/// Access-control API scoped to a specific trail. +/// +/// This handle exposes role-management and capability-management operations for one trail. All authorization is +/// still enforced against the capability supplied during transaction construction. +#[derive(Debug, Clone)] +pub struct TrailAccess<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, +} + +impl<'a, C> TrailAccess<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self + } + + /// Returns a role-scoped handle for the given role name. + /// + /// The returned handle only identifies the role. Existence and authorization are checked when the + /// resulting transaction is built and executed. + pub fn for_role(&self, name: impl Into) -> RoleHandle<'a, C> { + RoleHandle::new(self.client, self.trail_id, name.into(), self.selected_capability_id) + } + + /// Revokes an issued capability. + /// + /// Revocation adds the capability ID to the trail's denylist. Pass the capability's `valid_until` value + /// when it is known so later cleanup keeps the same expiry semantics. + pub fn revoke_capability( + &self, + capability_id: ObjectID, + capability_valid_until: Option, + ) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(RevokeCapability::new( + self.trail_id, + owner, + capability_id, + capability_valid_until, + self.selected_capability_id, + )) + } + + /// Destroys a capability object. + /// + /// This consumes the owned capability object itself. It uses the generic capability-destruction path and + /// therefore must not be used for initial-admin capabilities. + pub fn destroy_capability(&self, capability_id: ObjectID) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(DestroyCapability::new( + self.trail_id, + owner, + capability_id, + self.selected_capability_id, + )) + } + + /// Destroys an initial-admin capability without presenting another authorization capability. + /// + /// Initial-admin capability IDs are tracked separately, so they cannot be removed through the generic + /// destroy path. + pub fn destroy_initial_admin_capability( + &self, + capability_id: ObjectID, + ) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + TransactionBuilder::new(DestroyInitialAdminCapability::new(self.trail_id, capability_id)) + } + + /// Revokes an initial-admin capability by ID. + /// + /// Like [`TrailAccess::revoke_capability`], this writes to the denylist. The dedicated entry point exists + /// because initial-admin capability IDs are protected separately. + pub fn revoke_initial_admin_capability( + &self, + capability_id: ObjectID, + capability_valid_until: Option, + ) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(RevokeInitialAdminCapability::new( + self.trail_id, + owner, + capability_id, + capability_valid_until, + self.selected_capability_id, + )) + } + + /// Removes expired entries from the revoked-capability denylist. + /// + /// Only entries whose stored expiry has passed are removed. Revocations without an expiry remain until + /// they are explicitly destroyed or the trail is deleted. + pub fn cleanup_revoked_capabilities(&self) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(CleanupRevokedCapabilities::new( + self.trail_id, + owner, + self.selected_capability_id, + )) + } +} + +/// Role-scoped access-control API. +/// +/// A `RoleHandle` identifies one role name inside the trail's access-control state and builds transactions that +/// act on that role. +#[derive(Debug, Clone)] +pub struct RoleHandle<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, + pub(crate) name: String, + pub(crate) selected_capability_id: Option, +} + +impl<'a, C> RoleHandle<'a, C> { + pub(crate) fn new( + client: &'a C, + trail_id: ObjectID, + name: String, + selected_capability_id: Option, + ) -> Self { + Self { + client, + trail_id, + name, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self + } + + /// Returns the role name represented by this handle. + pub fn name(&self) -> &str { + &self.name + } + + /// Creates this role with the provided permissions and optional role-tag + /// access rules. + /// + /// Any supplied [`RoleTags`] must already exist in the trail-owned tag + /// registry. The tag list is stored as + /// role data on the Move side and is later used for tag-aware record authorization. + pub fn create(&self, permissions: PermissionSet, role_tags: Option) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(CreateRole::new( + self.trail_id, + owner, + self.name.clone(), + permissions, + role_tags, + self.selected_capability_id, + )) + } + + /// Issues a capability for this role using optional restrictions. + /// + /// The resulting capability always targets this trail and grants exactly + /// this role. `issued_to`, + /// `valid_from_ms`, and `valid_until_ms` only configure restrictions on + /// the issued object; enforcement + /// happens on-chain when the capability is later used. + pub fn issue_capability(&self, options: CapabilityIssueOptions) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(IssueCapability::new( + self.trail_id, + owner, + self.name.clone(), + options, + self.selected_capability_id, + )) + } + + /// Updates permissions and role-tag access rules for this role. + /// + /// As with [`RoleHandle::create`], any supplied [`RoleTags`] must already + /// exist in the trail tag registry. + pub fn update_permissions( + &self, + permissions: PermissionSet, + role_tags: Option, + ) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(UpdateRole::new( + self.trail_id, + owner, + self.name.clone(), + permissions, + role_tags, + self.selected_capability_id, + )) + } + + /// Deletes this role. + /// + /// The reserved initial-admin role cannot be deleted. + pub fn delete(&self) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(DeleteRole::new( + self.trail_id, + owner, + self.name.clone(), + self.selected_capability_id, + )) + } +} diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs new file mode 100644 index 00000000..17ac77da --- /dev/null +++ b/audit-trail-rs/src/core/access/operations.rs @@ -0,0 +1,381 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Internal access-control helpers that build role and capability transactions. +//! +//! These helpers encode Rust-side access inputs into the exact Move call shapes expected by the audit-trail +//! package and apply the lightweight preflight checks that are cheaper to surface before submission. + +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::{CallArg, ProgrammableTransaction}; +use iota_interaction::{OptionalSync, ident_str}; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::internal::{trail as trail_reader, tx}; +use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet, RoleTags}; +use crate::error::Error; + +/// Internal namespace for role and capability transaction construction. +/// +/// Each helper selects the required authorization permission, prepares +/// Move-compatible arguments, and then +/// delegates to the shared trail transaction builders in [`crate::core::internal::tx`]. +pub(super) struct AccessOps; + +impl AccessOps { + /// Builds the `create_role` call. + /// + /// `role_tags`, when present, are validated against the trail tag registry + /// before PTB construction so the + /// Rust side fails early with `Error::InvalidArgument` instead of relying on a later Move abort. + pub(super) async fn create_role( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + role_tags: Option, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + assert_role_tags_defined(client, trail_id, &role_tags).await?; + + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddRoles, + selected_capability_id, + "create_role", + |ptb, _| { + let role = tx::ptb_pure(ptb, "role", name)?; + let perms_vec = permissions.to_move_vec(client.package_id(), ptb)?; + let perms = ptb.programmable_move_call( + client.package_id(), + ident_str!("permission").as_str().into(), + ident_str!("from_vec").as_str().into(), + vec![], + vec![perms_vec], + ); + let role_tags_arg = match role_tags { + Some(role_tags) => { + let role_tags_arg = role_tags.to_ptb(ptb, client.package_id())?; + + tx::option_to_move(Some(role_tags_arg), RoleTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))? + } + None => tx::option_to_move(None, RoleTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))?, + }; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![role, perms, role_tags_arg, clock]) + }, + ) + .await + } + + /// Builds the `update_role_permissions` call. + /// + /// The same tag-registry precondition as [`AccessOps::create_role`] applies because role-tag data is stored + /// on-chain as part of the role definition. + pub(super) async fn update_role( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + role_tags: Option, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + assert_role_tags_defined(client, trail_id, &role_tags).await?; + + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateRoles, + selected_capability_id, + "update_role_permissions", + |ptb, _| { + let role = tx::ptb_pure(ptb, "role", name)?; + let perms_vec = permissions.to_move_vec(client.package_id(), ptb)?; + + let perms = ptb.programmable_move_call( + client.package_id(), + ident_str!("permission").as_str().into(), + ident_str!("from_vec").as_str().into(), + vec![], + vec![perms_vec], + ); + let role_tags_arg = match role_tags { + Some(role_tags) => { + let role_tags_arg = role_tags.to_ptb(ptb, client.package_id())?; + tx::option_to_move(Some(role_tags_arg), RoleTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))? + } + None => tx::option_to_move(None, RoleTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))?, + }; + + let clock = tx::get_clock_ref(ptb); + + Ok(vec![role, perms, role_tags_arg, clock]) + }, + ) + .await + } + + /// Builds the `delete_role` call. + /// + /// The PTB only carries the role name and clock reference. Protection of the initial-admin role remains an + /// access-control invariant enforced by the Move package. + pub(super) async fn delete_role( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + name: String, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteRoles, + selected_capability_id, + "delete_role", + |ptb, _| { + let role = tx::ptb_pure(ptb, "role", name)?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![role, clock]) + }, + ) + .await + } + + /// Builds the `new_capability` call for a role. + /// + /// Optional restrictions are serialized exactly as provided. Validation of `issued_to`, `valid_from`, and + /// `valid_until` semantics remains on-chain. + pub(super) async fn issue_capability( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + role_name: String, + options: CapabilityIssueOptions, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddCapabilities, + selected_capability_id, + "new_capability", + |ptb, _| { + let role = tx::ptb_pure(ptb, "role", role_name)?; + let issued_to = tx::ptb_pure(ptb, "issued_to", options.issued_to)?; + let valid_from = tx::ptb_pure(ptb, "valid_from", options.valid_from_ms)?; + let valid_until = tx::ptb_pure(ptb, "valid_until", options.valid_until_ms)?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![role, issued_to, valid_from, valid_until, clock]) + }, + ) + .await + } + + /// Builds the generic `revoke_capability` call. + /// + /// `capability_valid_until` is forwarded to the Move layer so the denylist can later be cleaned up without + /// losing the capability's original expiry boundary. + pub(super) async fn revoke_capability( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + capability_valid_until: Option, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::RevokeCapabilities, + selected_capability_id, + "revoke_capability", + |ptb, _| { + let cap = tx::ptb_pure(ptb, "capability_id", capability_id)?; + let valid_until = tx::ptb_pure(ptb, "capability_valid_until", capability_valid_until)?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![cap, valid_until, clock]) + }, + ) + .await + } + + /// Builds the generic `destroy_capability` call. + /// + /// This resolves the capability object reference up front because the Move entry point consumes the owned + /// capability object rather than only its ID. + pub(super) async fn destroy_capability( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let capability_ref = tx::get_object_ref_by_id(client, &capability_id).await?; + + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::RevokeCapabilities, + selected_capability_id, + "destroy_capability", + |ptb, _| { + let cap_to_destroy = ptb + .obj(CallArg::ImmutableOrOwned(capability_ref)) + .map_err(|e| Error::InvalidArgument(format!("Failed to create capability argument: {e}")))?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![cap_to_destroy, clock]) + }, + ) + .await + } + + /// Builds the dedicated `destroy_initial_admin_capability` call. + /// + /// Initial-admin capability IDs are tracked separately, so they cannot be destroyed through the generic + /// capability path. + pub(super) async fn destroy_initial_admin_capability( + client: &C, + trail_id: ObjectID, + capability_id: ObjectID, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let cap_ref = tx::get_object_ref_by_id(client, &capability_id).await?; + tx::build_trail_transaction_with_cap_ref( + client, + trail_id, + cap_ref, + "destroy_initial_admin_capability", + |_, _| Ok(vec![]), + ) + .await + } + + /// Builds the dedicated `revoke_initial_admin_capability` call. + /// + /// This keeps the same denylist-expiry behavior as [`AccessOps::revoke_capability`] while using the + /// separate Move entry point reserved for tracked initial-admin IDs. + pub(super) async fn revoke_initial_admin_capability( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + capability_valid_until: Option, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::RevokeCapabilities, + selected_capability_id, + "revoke_initial_admin_capability", + |ptb, _| { + let cap = tx::ptb_pure(ptb, "capability_id", capability_id)?; + let valid_until = tx::ptb_pure(ptb, "capability_valid_until", capability_valid_until)?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![cap, valid_until, clock]) + }, + ) + .await + } + + /// Builds the `cleanup_revoked_capabilities` call. + /// + /// Cleanup only prunes denylist entries whose stored expiry has elapsed. It does not change capability + /// objects and does not revoke any additional IDs. + pub(super) async fn cleanup_revoked_capabilities( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::RevokeCapabilities, + selected_capability_id, + "cleanup_revoked_capabilities", + |ptb, _| { + let clock = tx::get_clock_ref(ptb); + Ok(vec![clock]) + }, + ) + .await + } +} + +/// Verifies that every requested role tag already exists in the trail tag registry. +/// +/// Roles may only reference tags that are defined on the trail itself so later record-tag checks +/// stay consistent with the registry stored on-chain. +async fn assert_role_tags_defined(client: &C, trail_id: ObjectID, role_tags: &Option) -> Result<(), Error> +where + C: CoreClientReadOnly + OptionalSync, +{ + let Some(role_tags) = role_tags else { + return Ok(()); + }; + + let trail = trail_reader::get_audit_trail(trail_id, client).await?; + let undefined_tags = role_tags + .tags + .iter() + .filter(|tag| !trail.tags.contains_key(tag)) + .cloned() + .collect::>(); + + if undefined_tags.is_empty() { + Ok(()) + } else { + Err(Error::InvalidArgument(format!( + "role tags {:?} are not defined for trail {trail_id}", + undefined_tags + ))) + } +} diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs new file mode 100644 index 00000000..02011d32 --- /dev/null +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -0,0 +1,822 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Transaction payloads for audit-trail role and capability administration. +//! +//! These types cache the generated programmable transaction, delegate PTB construction to +//! [`super::operations::AccessOps`], and decode the matching Move events into typed Rust outputs. + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use super::operations::AccessOps; +use crate::core::types::{ + CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, + RawRoleCreated, RawRoleDeleted, RawRoleUpdated, RevokedCapabilitiesCleanedUp, RoleCreated, RoleDeleted, RoleTags, + RoleUpdated, +}; +use crate::error::Error; + +// ===== CreateRole ===== + +/// Transaction that creates a role on a trail. +/// +/// Requires an authorization capability with `AddRoles`. Any [`RoleTags`] supplied as role data must +/// already be present in the trail's tag registry; otherwise the Move package aborts with +/// `ERecordTagNotDefined`. Each tag referenced by the new role bumps that tag's usage counter, which +/// then prevents the tag from being removed from the registry. +/// +/// On success a `RoleCreated` event is emitted. +#[derive(Debug, Clone)] +pub struct CreateRole { + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + role_tags: Option, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl CreateRole { + /// Creates a `CreateRole` transaction builder payload. + /// + /// `role_tags`, when present, are serialized as Move `record_tags::RoleTags` role data. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + role_tags: Option, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + name, + permissions, + role_tags, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::create_role( + client, + self.trail_id, + self.owner, + self.name.clone(), + self.permissions.clone(), + self.role_tags.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for CreateRole { + type Error = Error; + type Output = RoleCreated; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| bcs::from_bytes::(data.bcs.bytes()).ok().map(Into::into)) + .ok_or_else(|| Error::UnexpectedApiResponse("RoleCreated event not found".to_string()))?; + + Ok(event) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!("RoleCreated output requires transaction events") + } +} + +/// Transaction that updates an existing role. +/// +/// Requires the `UpdateRoles` permission. Updates both the permission set and the optional role-tag +/// data stored for the role. Any newly supplied [`RoleTags`] must already be in the trail's tag +/// registry, otherwise the Move package aborts with `ERecordTagNotDefined`. Tag usage counters are +/// adjusted to reflect the difference between the old and new role-tag sets. +/// +/// On success a `RoleUpdated` event is emitted. +#[derive(Debug, Clone)] +pub struct UpdateRole { + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + role_tags: Option, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl UpdateRole { + /// Creates an `UpdateRole` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + role_tags: Option, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + name, + permissions, + role_tags, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::update_role( + client, + self.trail_id, + self.owner, + self.name.clone(), + self.permissions.clone(), + self.role_tags.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateRole { + type Error = Error; + type Output = RoleUpdated; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| bcs::from_bytes::(data.bcs.bytes()).ok().map(Into::into)) + .ok_or_else(|| Error::UnexpectedApiResponse("RoleUpdated event not found".to_string()))?; + + Ok(event) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +/// Transaction that deletes a role. +/// +/// Requires the `DeleteRoles` permission. The reserved initial-admin role (`"Admin"`) cannot be +/// deleted, even by a holder of `DeleteRoles`. Removing a role decrements the usage counters of all +/// tags it referenced through its [`RoleTags`]. +/// +/// On success a `RoleDeleted` event is emitted. +#[derive(Debug, Clone)] +pub struct DeleteRole { + trail_id: ObjectID, + owner: IotaAddress, + name: String, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl DeleteRole { + /// Creates a `DeleteRole` transaction builder payload. + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, selected_capability_id: Option) -> Self { + Self { + trail_id, + owner, + name, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::delete_role( + client, + self.trail_id, + self.owner, + self.name.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteRole { + type Error = Error; + type Output = RoleDeleted; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| bcs::from_bytes::(data.bcs.bytes()).ok().map(Into::into)) + .ok_or_else(|| Error::UnexpectedApiResponse("RoleDeleted event not found".to_string()))?; + + Ok(event) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +/// Transaction that issues a capability for a role. +/// +/// Requires the `AddCapabilities` permission. Mints a new capability object for `role` against +/// `trail_id` and transfers it to the address in [`CapabilityIssueOptions::issued_to`] (or the caller +/// if absent). Optional `valid_from_ms` / `valid_until_ms` restrictions are copied into the capability +/// object and later enforced on-chain when the capability is used. A `CapabilityIssued` event is +/// emitted on success. +#[derive(Debug, Clone)] +pub struct IssueCapability { + trail_id: ObjectID, + owner: IotaAddress, + role: String, + options: CapabilityIssueOptions, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl IssueCapability { + /// Creates an `IssueCapability` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + role: String, + options: CapabilityIssueOptions, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + role, + options, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::issue_capability( + client, + self.trail_id, + self.owner, + self.role.clone(), + self.options.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for IssueCapability { + type Error = Error; + type Output = CapabilityIssued; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityIssued event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +/// Transaction that revokes a capability. +/// +/// Requires the `RevokeCapabilities` permission. Revocation writes the capability ID into the trail's +/// revoked-capability denylist. Supplying `capability_valid_until` preserves the capability's original +/// expiry boundary so [`CleanupRevokedCapabilities`] can later prune the entry once that timestamp has +/// elapsed; pass `None` (which becomes `0` on chain) to keep the entry permanently. A +/// `CapabilityRevoked` event is emitted on success. +#[derive(Debug, Clone)] +pub struct RevokeCapability { + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + capability_valid_until: Option, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl RevokeCapability { + /// Creates a `RevokeCapability` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + capability_valid_until: Option, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + capability_id, + capability_valid_until, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::revoke_capability( + client, + self.trail_id, + self.owner, + self.capability_id, + self.capability_valid_until, + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for RevokeCapability { + type Error = Error; + type Output = CapabilityRevoked; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityRevoked event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +/// Transaction that destroys a capability object. +/// +/// Requires the `RevokeCapabilities` permission and consumes the owned capability object. This path is +/// for ordinary capabilities only — initial-admin capabilities must use [`DestroyInitialAdminCapability`] +/// instead, since their IDs are tracked separately. A `CapabilityDestroyed` event is emitted on +/// success. +#[derive(Debug, Clone)] +pub struct DestroyCapability { + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl DestroyCapability { + /// Creates a `DestroyCapability` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + capability_id, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::destroy_capability( + client, + self.trail_id, + self.owner, + self.capability_id, + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DestroyCapability { + type Error = Error; + type Output = CapabilityDestroyed; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityDestroyed event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +// ===== DestroyInitialAdminCapability ===== + +/// Transaction that destroys an initial-admin capability without an auth capability. +/// +/// Self-service: the holder passes their own initial-admin capability and consumes it; no additional +/// authorization is required because the capability itself proves ownership. Initial-admin capability +/// IDs are tracked separately and cannot be removed through the generic destroy path. +/// +/// **Warning:** if every initial-admin capability is destroyed (and none was issued separately), the +/// trail is permanently sealed with no admin access possible. +/// +/// On success a `CapabilityDestroyed` event is emitted. +#[derive(Debug, Clone)] +pub struct DestroyInitialAdminCapability { + trail_id: ObjectID, + capability_id: ObjectID, + cached_ptb: OnceCell, +} + +impl DestroyInitialAdminCapability { + /// Creates a `DestroyInitialAdminCapability` transaction builder payload. + pub fn new(trail_id: ObjectID, capability_id: ObjectID) -> Self { + Self { + trail_id, + capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::destroy_initial_admin_capability(client, self.trail_id, self.capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DestroyInitialAdminCapability { + type Error = Error; + type Output = CapabilityDestroyed; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityDestroyed event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +// ===== RevokeInitialAdminCapability ===== + +/// Transaction that revokes an initial-admin capability. +/// +/// Requires the `RevokeCapabilities` permission. This is the dedicated revoke path for capability IDs +/// recognized as active initial-admin capabilities; ordinary capabilities must use [`RevokeCapability`] +/// instead. The same denylist semantics apply: pass the capability's `valid_until` to allow later +/// cleanup once the original expiry elapses, or `None` to keep the denylist entry permanently. +/// +/// **Warning:** revoking every initial-admin capability permanently seals the trail with no admin +/// access possible. +/// +/// On success a `CapabilityRevoked` event is emitted. +#[derive(Debug, Clone)] +pub struct RevokeInitialAdminCapability { + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + capability_valid_until: Option, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl RevokeInitialAdminCapability { + /// Creates a `RevokeInitialAdminCapability` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + capability_valid_until: Option, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + capability_id, + capability_valid_until, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::revoke_initial_admin_capability( + client, + self.trail_id, + self.owner, + self.capability_id, + self.capability_valid_until, + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for RevokeInitialAdminCapability { + type Error = Error; + type Output = CapabilityRevoked; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityRevoked event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +/// Transaction that cleans up expired revoked-capability entries. +/// +/// Requires the `RevokeCapabilities` permission. Only prunes denylist entries whose stored +/// `valid_until` is *non-zero* and *strictly less than* the current clock time; entries with +/// `valid_until == 0` (capabilities revoked without a known expiry) remain on the denylist +/// indefinitely. This does not revoke additional capabilities and does not destroy any objects. +/// +/// On success a `RevokedCapabilitiesCleanedUp` event is emitted. +/// +/// Returns the typed cleanup receipt with the trail ID, the number of entries removed, the address that +/// triggered the cleanup, and the millisecond timestamp. +#[derive(Debug, Clone)] +pub struct CleanupRevokedCapabilities { + trail_id: ObjectID, + owner: IotaAddress, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl CleanupRevokedCapabilities { + /// Creates a `CleanupRevokedCapabilities` transaction builder payload. + pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self { + Self { + trail_id, + owner, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::cleanup_revoked_capabilities(client, self.trail_id, self.owner, self.selected_capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for CleanupRevokedCapabilities { + type Error = Error; + type Output = RevokedCapabilitiesCleanedUp; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| { + serde_json::from_value::>(data.parsed_json.clone()).ok() + }) + .ok_or_else(|| Error::UnexpectedApiResponse("RevokedCapabilitiesCleanedUp event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!("RevokedCapabilitiesCleanedUp output requires transaction events") + } +} diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs new file mode 100644 index 00000000..dd5f815a --- /dev/null +++ b/audit-trail-rs/src/core/builder.rs @@ -0,0 +1,133 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Builder for trail-creation transactions. + +use std::collections::HashSet; + +use iota_interaction::types::base_types::IotaAddress; +use product_common::transaction::transaction_builder::TransactionBuilder; + +use super::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; +use crate::core::create::CreateTrail; +use crate::error::Error; + +/// Builder for creating an audit trail. +/// +/// The builder collects the full create-time configuration before it is normalized into the Move `create` +/// call. Any tag list configured here becomes the trail-owned registry that later role-tag and record-tag +/// checks refer to. +/// +/// Creation has three additional on-chain effects worth noting: +/// +/// - The trail object is published as a *shared* object. +/// - A reserved `Admin` role is seeded with the permissions returned by +/// [`PermissionSet::admin_permissions`](super::types::PermissionSet::admin_permissions), and an *initial-admin* +/// capability is minted and transferred to the configured admin address. +/// - When [`Self::with_initial_record`] is set, that record is stored as sequence number `0`. Its tag (if any) must +/// already appear in the configured record tags; otherwise the on-chain create call aborts with +/// `ERecordTagNotDefined`. +/// - An `AuditTrailCreated` event is emitted. +#[derive(Debug, Clone, Default)] +pub struct AuditTrailBuilder { + /// Initial admin address that should receive the initial admin capability. + pub admin: Option, + /// Optional initial record created together with the trail. + pub initial_record: Option, + /// Locking rules to apply at creation time. + pub locking_config: LockingConfig, + /// Immutable metadata stored once at creation time. + pub trail_metadata: Option, + /// Mutable metadata stored on the trail object. + pub updatable_metadata: Option, + /// Canonical list of record tags owned by the trail. + pub record_tags: HashSet, +} + +impl AuditTrailBuilder { + /// Sets the full initial record input used during trail creation. + /// + /// When present, the initial record is created as sequence number `0`. + pub fn with_initial_record(mut self, initial_record: InitialRecord) -> Self { + self.initial_record = Some(initial_record); + self + } + + /// Convenience helper for constructing the initial record inline. + pub fn with_initial_record_parts( + mut self, + data: impl Into, + metadata: Option, + tag: Option, + ) -> Self { + self.initial_record = Some(InitialRecord::new(data, metadata, tag)); + self + } + + /// Sets the locking configuration for the trail. + /// + /// This replaces the entire create-time locking configuration. + pub fn with_locking_config(mut self, config: LockingConfig) -> Self { + self.locking_config = config; + self + } + + /// Sets immutable metadata for the trail. + /// + /// Immutable metadata is stored once during creation and cannot be updated later. + pub fn with_trail_metadata(mut self, metadata: ImmutableMetadata) -> Self { + self.trail_metadata = Some(metadata); + self + } + + /// Sets immutable metadata by parts. + pub fn with_trail_metadata_parts(mut self, name: impl Into, description: Option) -> Self { + self.trail_metadata = Some(ImmutableMetadata { + name: name.into(), + description, + }); + self + } + + /// Sets updatable metadata for the trail. + /// + /// This seeds the mutable metadata field that later `update_metadata` calls can replace or clear. + pub fn with_updatable_metadata(mut self, metadata: impl Into) -> Self { + self.updatable_metadata = Some(metadata.into()); + self + } + + /// Sets the canonical list of tags that may be used on records in this trail. + /// + /// The list is deduplicated into the trail-owned tag registry during creation. + pub fn with_record_tags(mut self, tags: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.record_tags = tags.into_iter().map(Into::into).collect(); + self + } + + /// Sets the admin address that receives the initial-admin capability. + pub fn with_admin(mut self, admin: IotaAddress) -> Self { + self.admin = Some(admin); + self + } + + /// Finalizes the builder and creates the trail-creation transaction builder. + /// + /// Validates the configured [`LockingConfig`] before returning the transaction. Currently this rejects: + /// - [`LockingWindow::CountBased`](super::types::LockingWindow::CountBased) with `count == 0` (mirrors the Move + /// `ECountWindowMustBePositive` abort). + /// - [`TimeLock::UntilDestroyed`](super::types::TimeLock::UntilDestroyed) used as `delete_trail_lock` (mirrors the + /// Move `EUntilDestroyedNotSupportedForDeleteTrail` abort). `write_lock` may still be `UntilDestroyed`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidArgument`] when the locking configuration is invalid. + pub fn finish(self) -> Result, Error> { + self.locking_config.validate()?; + Ok(TransactionBuilder::new(CreateTrail::new(self))) + } +} diff --git a/audit-trail-rs/src/core/create/mod.rs b/audit-trail-rs/src/core/create/mod.rs new file mode 100644 index 00000000..7dace9ff --- /dev/null +++ b/audit-trail-rs/src/core/create/mod.rs @@ -0,0 +1,9 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Trail-creation transaction types. + +mod operations; +mod transactions; + +pub use transactions::{CreateTrail, TrailCreated}; diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs new file mode 100644 index 00000000..0f9aded4 --- /dev/null +++ b/audit-trail-rs/src/core/create/operations.rs @@ -0,0 +1,116 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Internal helpers that turn validated builder state into the trail-creation Move call. + +use std::collections::HashSet; + +use iota_interaction::ident_str; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; +use iota_interaction::types::transaction::{Argument, ProgrammableTransaction}; + +use crate::core::internal::tx; +use crate::core::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; +use crate::error::Error; + +/// Internal namespace for trail-creation transaction construction. +pub(super) struct CreateOps; + +/// Normalized inputs required to build the `main::create` programmable transaction. +/// +/// This keeps the public builder layer separate from the low-level PTB encoding logic. +pub(super) struct CreateTrailArgs { + /// Audit-trail package used for generic type tags and Move calls. + pub audit_trail_package_id: ObjectID, + /// TfComponents package used by locking and capability-related values. + pub tf_components_package_id: ObjectID, + /// Address that should receive the initial admin capability. + pub admin: IotaAddress, + /// Optional first record inserted into the newly created trail. + pub initial_record: Option, + /// Initial locking rules for the trail. + pub locking_config: LockingConfig, + /// Immutable metadata stored at trail creation time. + pub trail_metadata: Option, + /// Mutable metadata slot initialized together with the trail. + pub updatable_metadata: Option, + /// Canonical set of record tags that may be used on the trail. + pub record_tags: HashSet, +} + +impl CreateOps { + /// Builds the programmable transaction that creates a new audit trail. + /// + /// Record tags are sorted before serialization so the resulting wire format is stable across + /// equivalent `HashSet` inputs. + pub(super) fn create_trail(args: CreateTrailArgs) -> Result { + let mut ptb = ProgrammableTransactionBuilder::new(); + let CreateTrailArgs { + audit_trail_package_id, + tf_components_package_id, + admin, + initial_record, + locking_config, + trail_metadata, + updatable_metadata, + record_tags, + } = args; + + let data_tag = Data::tag(audit_trail_package_id); + let initial_record_tag = InitialRecord::tag(audit_trail_package_id); + let initial_record = match initial_record { + Some(initial_record) => { + let initial_record_arg = initial_record.into_ptb(&mut ptb, audit_trail_package_id)?; + tx::option_to_move(Some(initial_record_arg), initial_record_tag, &mut ptb) + } + None => tx::option_to_move(None, initial_record_tag, &mut ptb), + } + .map_err(|e| Error::InvalidArgument(format!("failed to build initial_record option: {e}")))?; + let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; + + let immutable_metadata_tag = ImmutableMetadata::tag(audit_trail_package_id); + + let trail_metadata = match trail_metadata { + Some(metadata) => { + let metadata_arg = metadata.to_ptb(&mut ptb, audit_trail_package_id)?; + tx::option_to_move(Some(metadata_arg), immutable_metadata_tag, &mut ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build trail_metadata option: {e}")))? + } + None => tx::option_to_move(None, immutable_metadata_tag, &mut ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build trail_metadata option: {e}")))?, + }; + + let updatable_metadata = tx::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; + + let record_tags = { + let mut record_tags = record_tags.into_iter().collect::>(); + record_tags.sort(); + tx::ptb_pure(&mut ptb, "record_tags", record_tags)? + }; + let clock = tx::get_clock_ref(&mut ptb); + + let result = ptb.programmable_move_call( + audit_trail_package_id, + ident_str!("main").as_str().into(), + ident_str!("create").as_str().into(), + vec![data_tag], + vec![ + initial_record, + locking_config, + trail_metadata, + updatable_metadata, + record_tags, + clock, + ], + ); + + let cap = match result { + Argument::Result(idx) => Argument::NestedResult(idx, 0), + _ => unreachable!("programmable_move_call should always return Argument::Result"), + }; + ptb.transfer_arg(admin, cap); + + Ok(ptb.finish()) + } +} diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs new file mode 100644 index 00000000..6256eea7 --- /dev/null +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -0,0 +1,146 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use super::operations::{CreateOps, CreateTrailArgs}; +use crate::core::builder::AuditTrailBuilder; +use crate::core::internal::trail as trail_reader; +use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; +use crate::error::Error; + +/// Output of a successful trail-creation transaction. +#[derive(Debug, Clone)] +pub struct TrailCreated { + /// Newly created trail object ID. + pub trail_id: ObjectID, + /// Address that created the trail. + pub creator: IotaAddress, + /// Millisecond timestamp emitted by the creation event. + pub timestamp: u64, +} + +impl TrailCreated { + /// Loads the newly created trail object from the ledger. + /// + /// # Errors + /// + /// Returns an error if the trail cannot be fetched or deserialized. + pub async fn fetch_audit_trail(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + trail_reader::get_audit_trail(self.trail_id, client).await + } +} + +/// A transaction that creates a new audit trail. +/// +/// The builder state is normalized into the exact Move `create` call shape, including tag-registry setup, +/// optional initial-record creation, and initial-admin capability assignment. +/// +/// On execution the Move package: shares the trail object, seeds the reserved `Admin` role with the +/// permissions returned by `permission::admin_permissions`, transfers a freshly minted initial-admin +/// capability to the admin address, stores the optional initial record at sequence number `0`, and emits +/// an `AuditTrailCreated` event. If an initial record carries a tag, the tag must already be in the +/// configured record-tag registry or the call aborts with `ERecordTagNotDefined`. +#[derive(Debug, Clone)] +pub struct CreateTrail { + builder: AuditTrailBuilder, + cached_ptb: OnceCell, +} + +impl CreateTrail { + /// Creates a new [`CreateTrail`] instance. + pub fn new(builder: AuditTrailBuilder) -> Self { + Self { + builder, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let AuditTrailBuilder { + admin, + initial_record, + locking_config, + trail_metadata, + updatable_metadata, + record_tags, + } = self.builder.clone(); + + let admin = admin.ok_or_else(|| { + Error::InvalidArgument( + "admin address is required; use `client.create_trail()` with signer or call `with_admin(...)`" + .to_string(), + ) + })?; + let tf_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for Audit Trail clients"); + + CreateOps::create_trail(CreateTrailArgs { + audit_trail_package_id: client.package_id(), + tf_components_package_id: tf_package_id, + admin, + initial_record, + locking_config, + trail_metadata, + updatable_metadata, + record_tags, + }) + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for CreateTrail { + type Error = Error; + type Output = TrailCreated; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("AuditTrailCreated event not found".to_string()))?; + + Ok(TrailCreated { + trail_id: event.data.trail_id, + creator: event.data.creator, + timestamp: event.data.timestamp, + }) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs new file mode 100644 index 00000000..6e7d5d4e --- /dev/null +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -0,0 +1,378 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Capability discovery helpers used by internal transaction builders. +use std::collections::HashSet; + +use iota_interaction::rpc_types::{ + IotaObjectDataFilter, IotaObjectDataOptions, IotaObjectResponseQuery, IotaParsedData, +}; +use iota_interaction::types::MoveTypeTagTrait; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef, StructTag}; +use iota_interaction::types::dynamic_field::DynamicFieldName; +use iota_interaction::types::id::ID; +use iota_interaction::{IotaClientTrait, OptionalSync}; +use product_common::core_client::CoreClientReadOnly; + +use super::{linked_table, tx}; +use crate::core::types::{Capability, OnChainAuditTrail, Permission}; +use crate::error::Error; + +/// Finds an owned capability object that grants `permission` for `trail_id` and returns its object +/// reference. +/// +/// The lookup is restricted to roles on `trail` that include the requested permission. +pub(crate) async fn find_capable_cap( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + trail: &OnChainAuditTrail, + permission: Permission, +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let valid_roles: HashSet = trail + .roles + .roles + .iter() + .filter(|(_, role)| role.permissions.contains(&permission)) + .map(|(name, _)| name.clone()) + .collect(); + + let cap = find_owned_capability(client, owner, trail, |cap| { + cap.matches_target_and_role(trail_id, &valid_roles) + }) + .await? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no capability with {:?} permission found for owner {owner} and trail {trail_id}", + permission + )) + })?; + + let object_id = *cap.id.object_id(); + tx::get_object_ref_by_id(client, &object_id).await +} + +/// Searches the owner's capability objects and returns the first one matching `predicate`. +/// +/// Revoked capabilities are filtered out before the predicate is applied to the remaining +/// candidates. +pub(crate) async fn find_owned_capability( + client: &C, + owner: IotaAddress, + trail: &OnChainAuditTrail, + predicate: P, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + P: Fn(&Capability) -> bool + Send, +{ + let revoked_capability_ids = revoked_capability_ids(client, trail).await?; + let now_ms = now_ms(); + let tf_components_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for Audit Trail clients"); + let capability_struct_tag: StructTag = Capability::type_tag(tf_components_package_id) + .to_string() + .parse() + .expect("capability type tag is a valid struct tag"); + let query = IotaObjectResponseQuery::new( + Some(IotaObjectDataFilter::StructType(capability_struct_tag)), + Some(IotaObjectDataOptions::default().with_content()), + ); + + let mut cursor = None; + loop { + let mut page = client + .client_adapter() + .read_api() + .get_owned_objects(owner, Some(query.clone()), cursor, Some(25)) + .await + .map_err(|e| Error::RpcError(e.to_string()))?; + + let maybe_cap = std::mem::take(&mut page.data) + .into_iter() + .filter_map(|res| res.data) + .filter_map(|data| data.content) + .filter_map(|obj_data| { + let IotaParsedData::MoveObject(move_object) = obj_data else { + unreachable!() + }; + serde_json::from_value(move_object.fields.to_json_value()).ok() + }) + .find(|cap| capability_matches(cap, owner, now_ms, &revoked_capability_ids, &predicate)); + cursor = page.next_cursor; + + if maybe_cap.is_some() { + return Ok(maybe_cap); + } + if !page.has_next_page { + break; + } + } + + Ok(None) +} + +/// Traverses the revoked-capabilities linked table and collects every revoked capability ID. +/// +/// The traversal validates that the linked-table shape is acyclic and that the number of visited +/// entries matches the size recorded on-chain. +async fn revoked_capability_ids(client: &C, trail: &OnChainAuditTrail) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, +{ + let table = &trail.roles.revoked_capabilities; + let expected = table.size as usize; + let mut cursor = table.head; + let mut keys = HashSet::with_capacity(expected); + + while let Some(key) = cursor { + if !keys.insert(key) { + return Err(Error::UnexpectedApiResponse(format!( + "cycle detected while traversing linked-table {table_id}; repeated key {key}", + table_id = table.id + ))); + } + + let node = linked_table::fetch_node::<_, ObjectID, u64>( + client, + table.id, + DynamicFieldName { + type_: ID::get_type_tag(), + value: serde_json::Value::String(IotaAddress::from(key).to_string()), + }, + ) + .await?; + cursor = node.next; + } + + if keys.len() != expected { + return Err(Error::UnexpectedApiResponse(format!( + "linked-table traversal mismatch; expected {expected} entries, got {}", + keys.len() + ))); + } + + Ok(keys) +} + +/// Returns whether a capability is a usable match for the current owner and predicate. +/// +/// A capability only matches when it satisfies the caller-provided predicate, has not been +/// revoked, and is either unbound or explicitly issued to `owner`. +fn capability_matches

( + cap: &Capability, + owner: IotaAddress, + now_ms: u64, + revoked_capability_ids: &HashSet, + predicate: &P, +) -> bool +where + P: Fn(&Capability) -> bool, +{ + predicate(cap) + && !revoked_capability_ids.contains(cap.id.object_id()) + && cap.issued_to.map(|issued_to| issued_to == owner).unwrap_or(true) + && cap.valid_from.is_none_or(|valid_from| now_ms >= valid_from) + && cap.valid_until.is_none_or(|valid_until| now_ms <= valid_until) +} + +/// Finds an owned capability for adding a tagged record. +/// +/// Tagged writes have stricter lookup rules than ordinary permission-based +/// operations: the selected role must grant `AddRecord` and its configured +/// `RoleTags` must allow the requested record tag. +pub(crate) async fn find_capable_cap_for_tag( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + trail: &OnChainAuditTrail, + tag: &str, +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let valid_roles = trail + .roles + .roles + .iter() + .filter(|(_, role)| { + role.permissions.contains(&Permission::AddRecord) + && role.data.as_ref().is_some_and(|record_tags| record_tags.allows(tag)) + }) + .map(|(name, _)| name.clone()) + .collect::>(); + + let cap = find_owned_capability(client, owner, trail, |cap| { + cap.target_key == trail_id && valid_roles.contains(&cap.role) + }) + .await? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no capability with {:?} permission and record tag '{tag}' found for owner {owner} and trail {trail_id}", + Permission::AddRecord + )) + })?; + + let object_id = *cap.id.object_id(); + tx::get_object_ref_by_id(client, &object_id).await +} + +/// Returns the current wall-clock time as milliseconds since the Unix epoch. +/// +/// Uses `std::time::SystemTime` on native targets and `js_sys::Date::now()` on +/// `wasm32`, where `SystemTime` is not available. +pub(crate) fn now_ms() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } + #[cfg(target_arch = "wasm32")] + { + js_sys::Date::now() as u64 + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use iota_interaction::types::base_types::{IotaAddress, ObjectID, dbg_object_id}; + use iota_interaction::types::id::UID; + + use super::capability_matches; + use crate::core::types::Capability; + + #[test] + fn capability_matches_skips_revoked_caps() { + let owner = IotaAddress::random(); + let trail_id = dbg_object_id(1); + let revoked_cap_id = dbg_object_id(2); + let valid_cap_id = dbg_object_id(3); + let valid_roles = HashSet::from(["Writer".to_string()]); + let revoked_ids = HashSet::from([revoked_cap_id]); + + let revoked_cap = make_capability(revoked_cap_id, trail_id, "Writer", None, None, None); + let valid_cap = make_capability(valid_cap_id, trail_id, "Writer", None, None, None); + + assert!(!capability_matches(&revoked_cap, owner, 0, &revoked_ids, &|cap| cap + .matches_target_and_role(trail_id, &valid_roles))); + assert!(capability_matches(&valid_cap, owner, 0, &revoked_ids, &|cap| cap + .matches_target_and_role(trail_id, &valid_roles))); + } + + #[test] + fn capability_matches_skips_issued_to_mismatch() { + let owner = IotaAddress::random(); + let other_owner = IotaAddress::random(); + let trail_id = dbg_object_id(4); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability(dbg_object_id(5), trail_id, "Writer", Some(other_owner), None, None); + + assert!(!capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_skips_caps_before_valid_from() { + let owner = IotaAddress::random(); + let trail_id = dbg_object_id(6); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability(dbg_object_id(7), trail_id, "Writer", None, Some(2_000), None); + + assert!(!capability_matches(&cap, owner, 1_999, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + assert!(capability_matches(&cap, owner, 2_000, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_skips_caps_after_valid_until() { + let owner = IotaAddress::random(); + let trail_id = dbg_object_id(8); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability(dbg_object_id(9), trail_id, "Writer", None, None, Some(2_000)); + + assert!(capability_matches(&cap, owner, 2_000, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + assert!(!capability_matches(&cap, owner, 2_001, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_accepts_unbound_capability_for_matching_role() { + let owner = IotaAddress::random(); + let trail_id = dbg_object_id(6); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability(dbg_object_id(7), trail_id, "Writer", None, None, None); + + assert!(capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_rejects_non_matching_role() { + let owner = IotaAddress::random(); + let trail_id = dbg_object_id(8); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability(dbg_object_id(9), trail_id, "Reader", None, None, None); + + assert!(!capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_honors_time_constraints() { + let owner = IotaAddress::random(); + let trail_id = dbg_object_id(10); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability( + dbg_object_id(11), + trail_id, + "Writer", + Some(owner), + Some(1_700_000_000_000), + Some(1_700_000_005_000), + ); + + assert!(capability_matches( + &cap, + owner, + 1_700_000_000_000, + &HashSet::new(), + &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) } + )); + } + + fn make_capability( + id: ObjectID, + trail_id: ObjectID, + role: &str, + issued_to: Option, + valid_from: Option, + valid_until: Option, + ) -> Capability { + Capability { + id: UID::new(id), + target_key: trail_id, + role: role.to_string(), + issued_to, + valid_from, + valid_until, + } + } +} diff --git a/audit-trail-rs/src/core/internal/linked_table.rs b/audit-trail-rs/src/core/internal/linked_table.rs new file mode 100644 index 00000000..7f3f4c85 --- /dev/null +++ b/audit-trail-rs/src/core/internal/linked_table.rs @@ -0,0 +1,65 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Helpers for reading Move `LinkedTable` nodes through dynamic fields. + +use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::collection_types::LinkedTableNode; +use iota_interaction::types::dynamic_field::{DynamicFieldName, Field}; +use iota_interaction::{IotaClientTrait, OptionalSync}; +use product_common::core_client::CoreClientReadOnly; +use serde::de::DeserializeOwned; + +use crate::error::Error; + +/// Fetches and decodes a single linked-table node stored as a dynamic field under `table_id`. +/// +/// The caller provides the fully encoded Move field name so this helper can stay generic over the +/// linked-table key and value types. +pub(crate) async fn fetch_node( + client: &C, + table_id: ObjectID, + name: DynamicFieldName, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + K: DeserializeOwned, + V: DeserializeOwned, +{ + let name_display = name.to_string(); + let data = client + .client_adapter() + .read_api() + .get_dynamic_field_object_v2(table_id, name, Some(IotaObjectDataOptions::bcs_lossless())) + .await + .map_err(|err| Error::RpcError(err.to_string()))? + .data + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "dynamic-field object not found for linked-table id {table_id} and name {name_display}" + )) + })?; + + let field: Field> = data + .bcs + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "linked-table node {} missing bcs object content", + data.object_id + )) + })? + .try_into_move() + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "linked-table node {} bcs content is not a move object", + data.object_id + )) + })? + .deserialize() + .map_err(|err| { + Error::UnexpectedApiResponse(format!("failed to decode linked-table node {}; {err}", data.object_id)) + })?; + + Ok(field.value) +} diff --git a/audit-trail-rs/src/core/internal/mod.rs b/audit-trail-rs/src/core/internal/mod.rs new file mode 100644 index 00000000..c4409bcb --- /dev/null +++ b/audit-trail-rs/src/core/internal/mod.rs @@ -0,0 +1,16 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Internal helpers used to bridge public audit-trail APIs to low-level IOTA object access and +//! programmable transaction construction. + +/// Capability lookup helpers for trail-scoped permission checks. +pub(crate) mod capability; +/// Linked-table decoding helpers for traversing on-chain Move collections. +pub(crate) mod linked_table; +/// Serde adapters for Move collection types that are exposed as standard Rust collections. +pub(crate) mod move_collections; +/// Raw trail fetch and decode helpers. +pub(crate) mod trail; +/// Common programmable-transaction building helpers. +pub(crate) mod tx; diff --git a/audit-trail-rs/src/core/internal/move_collections.rs b/audit-trail-rs/src/core/internal/move_collections.rs new file mode 100644 index 00000000..ba31be21 --- /dev/null +++ b/audit-trail-rs/src/core/internal/move_collections.rs @@ -0,0 +1,39 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Serde adapters for decoding Move collection wrappers into standard Rust collections. + +use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; +use std::hash::Hash; + +use iota_interaction::types::collection_types::{VecMap, VecSet}; +use serde::{Deserialize, Deserializer}; + +/// Deserializes a Move `VecMap` into a Rust [`HashMap`]. +/// +/// This adapter is used on public domain types that expose map-like data as idiomatic Rust +/// collections while preserving the on-chain wire format. +pub(crate) fn deserialize_vec_map<'de, D, K, V>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + K: Deserialize<'de> + Eq + Hash + Debug, + V: Deserialize<'de> + Debug, +{ + let vec_map = VecMap::::deserialize(deserializer)?; + Ok(vec_map + .contents + .into_iter() + .map(|entry| (entry.key, entry.value)) + .collect()) +} + +/// Deserializes a Move `VecSet` into a Rust [`HashSet`]. +pub(crate) fn deserialize_vec_set<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de> + Eq + Hash, +{ + let vec_set = VecSet::::deserialize(deserializer)?; + Ok(vec_set.contents.into_iter().collect()) +} diff --git a/audit-trail-rs/src/core/internal/trail.rs b/audit-trail-rs/src/core/internal/trail.rs new file mode 100644 index 00000000..d90861b8 --- /dev/null +++ b/audit-trail-rs/src/core/internal/trail.rs @@ -0,0 +1,34 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Helpers for fetching and decoding the shared on-chain audit-trail object. + +use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::{IotaClientTrait, OptionalSync}; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::types::OnChainAuditTrail; +use crate::error::Error; + +/// Loads the shared audit-trail object and decodes it into [`OnChainAuditTrail`]. +pub(crate) async fn get_audit_trail(trail_id: ObjectID, client: &C) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let data = client + .client_adapter() + .read_api() + .get_object_with_options(trail_id, IotaObjectDataOptions::bcs_lossless()) + .await + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", trail_id)))? + .data + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", trail_id)))?; + + data.bcs + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", trail_id)))? + .try_into_move() + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", trail_id)))? + .deserialize() + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", trail_id))) +} diff --git a/audit-trail-rs/src/core/internal/tx.rs b/audit-trail-rs/src/core/internal/tx.rs new file mode 100644 index 00000000..f9fdcc6f --- /dev/null +++ b/audit-trail-rs/src/core/internal/tx.rs @@ -0,0 +1,265 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Shared transaction-building helpers used by the internal audit-trail operations. + +use std::str::FromStr; + +use iota_interaction::rpc_types::IotaObjectDataOptions; +use iota_interaction::types::base_types::{Identifier, IotaAddress, ObjectID, ObjectRef, TypeTag}; +use iota_interaction::types::object::Owner; +use iota_interaction::types::programmable_transaction_builder::{ + ProgrammableTransactionBuilder as Ptb, ProgrammableTransactionBuilder, +}; +use iota_interaction::types::transaction::{Argument, CallArg, ProgrammableTransaction, SharedObjectRef}; +use iota_interaction::types::{IOTA_CLOCK_OBJECT_ID, IOTA_CLOCK_OBJECT_SHARED_VERSION, MOVE_STDLIB_PACKAGE_ID}; +use iota_interaction::{IotaClientTrait, OptionalSync, ident_str}; +use product_common::core_client::CoreClientReadOnly; +use serde::Serialize; + +use super::{capability, trail as trail_reader}; +use crate::core::types::Permission; +use crate::error::Error; + +/// Returns the canonical immutable clock object argument. +pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument { + ptb.obj(CallArg::Shared(SharedObjectRef { + object_id: IOTA_CLOCK_OBJECT_ID, + initial_shared_version: IOTA_CLOCK_OBJECT_SHARED_VERSION, + mutable: false, + })) + .expect("network has a singleton clock instantiated") +} + +/// Serializes a pure programmable-transaction argument and annotates serialization failures with +/// the logical argument name. +pub(crate) fn ptb_pure(ptb: &mut Ptb, name: &str, value: T) -> Result +where + T: Serialize + core::fmt::Debug, +{ + ptb.pure(&value).map_err(|err| { + Error::InvalidArgument(format!( + r"could not serialize pure value {name} with value {value:?}; {err}" + )) + }) +} + +/// Wraps an optional argument into the corresponding Move `std::option::Option` value. +pub(crate) fn option_to_move( + option: Option, + tag: TypeTag, + ptb: &mut ProgrammableTransactionBuilder, +) -> Result { + let arg = if let Some(t) = option { + ptb.programmable_move_call( + MOVE_STDLIB_PACKAGE_ID, + ident_str!("option").as_str().into(), + ident_str!("some").as_str().into(), + vec![tag], + vec![t], + ) + } else { + ptb.programmable_move_call( + MOVE_STDLIB_PACKAGE_ID, + ident_str!("option").as_str().into(), + ident_str!("none").as_str().into(), + vec![tag], + vec![], + ) + }; + + Ok(arg) +} + +/// Builds a writable trail transaction after resolving both the trail object and a matching +/// capability for `owner`. +pub(crate) async fn build_trail_transaction( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + permission: Permission, + selected_capability_id: Option, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let cap_ref = if let Some(capability_id) = selected_capability_id { + get_object_ref_by_id(client, &capability_id).await? + } else { + let trail = trail_reader::get_audit_trail(trail_id, client).await?; + capability::find_capable_cap(client, owner, trail_id, &trail, permission).await? + }; + build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await +} + +/// Builds a writable trail transaction when the caller already has the capability object +/// reference. +pub(crate) async fn build_trail_transaction_with_cap_ref( + client: &C, + trail_id: ObjectID, + cap_ref: ObjectRef, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let mut ptb = ProgrammableTransactionBuilder::new(); + + let type_tag = get_type_tag(client, &trail_id).await?; + let tag = vec![type_tag.clone()]; + let trail_arg = get_shared_object_arg(client, &trail_id, true).await?; + + let mut args = vec![ + ptb.obj(trail_arg) + .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, + ptb.obj(CallArg::ImmutableOrOwned(cap_ref)) + .map_err(|e| Error::InvalidArgument(format!("Failed to create cap argument: {e}")))?, + ]; + + args.extend(additional_args(&mut ptb, &type_tag)?); + + let function = Identifier::from_str(method.as_ref()) + .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; + + ptb.programmable_move_call( + client.package_id(), + ident_str!("main").as_str().into(), + function, + tag, + args, + ); + + Ok(ptb.finish()) +} + +/// Builds a read-only trail transaction that borrows the shared trail object immutably. +pub(crate) async fn build_read_only_transaction( + client: &C, + trail_id: ObjectID, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let mut ptb = ProgrammableTransactionBuilder::new(); + + let tag = vec![get_type_tag(client, &trail_id).await?]; + let trail_arg = get_shared_object_arg(client, &trail_id, false).await?; + + let mut args = vec![ + ptb.obj(trail_arg) + .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, + ]; + + args.extend(additional_args(&mut ptb)?); + + let function = Identifier::from_str(method.as_ref()) + .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; + + ptb.programmable_move_call( + client.package_id(), + ident_str!("main").as_str().into(), + function, + tag, + args, + ); + + Ok(ptb.finish()) +} + +/// Extracts the generic record payload type from the on-chain trail object type. +/// +/// Audit-trail Move entry points are generic over the record payload type, so transaction builders +/// need this type tag to invoke the correct specialization. +pub(crate) async fn get_type_tag(client: &C, object_id: &ObjectID) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let object_response = client + .client_adapter() + .read_api() + .get_object_with_options(*object_id, IotaObjectDataOptions::new().with_type()) + .await + .map_err(|err| Error::FailedToParseTag(format!("Failed to get object: {err}")))?; + + let object_data = object_response + .data + .ok_or_else(|| Error::FailedToParseTag(format!("Object {object_id} not found")))?; + + let full_type_str = object_data + .object_type() + .map_err(|e| Error::FailedToParseTag(format!("Failed to get object type: {e}")))? + .to_string(); + + let type_param_str = parse_type(&full_type_str)?; + + TypeTag::from_str(&type_param_str) + .map_err(|e| Error::FailedToParseTag(format!("Failed to parse tag '{type_param_str}': {e}"))) +} + +/// Extracts the innermost generic type parameter from a full Move object type string. +fn parse_type(full_type: &str) -> Result { + if let (Some(start), Some(end)) = (full_type.find('<'), full_type.rfind('>')) { + Ok(full_type[start + 1..end].to_string()) + } else { + Err(Error::FailedToParseTag(format!( + "Could not parse type parameter from {full_type}" + ))) + } +} + +/// Fetches the current object reference for `object_id`. +pub(crate) async fn get_object_ref_by_id( + client: &impl CoreClientReadOnly, + object_id: &ObjectID, +) -> Result { + let res = client + .client_adapter() + .read_api() + .get_object_with_options(*object_id, IotaObjectDataOptions::new().with_content()) + .await + .map_err(|err| Error::GenericError(format!("Failed to get object: {err}")))?; + + let Some(data) = res.data else { + return Err(Error::InvalidArgument("no data found".to_string())); + }; + + Ok(data.object_ref()) +} + +/// Resolves a shared object argument for use in a programmable transaction. +/// +/// This validates that the fetched object is shared and returns the appropriate mutability flag for +/// the planned call. +pub(crate) async fn get_shared_object_arg( + client: &impl CoreClientReadOnly, + object_id: &ObjectID, + mutable: bool, +) -> Result { + let res = client + .client_adapter() + .read_api() + .get_object_with_options(*object_id, IotaObjectDataOptions::new().with_owner()) + .await + .map_err(|err| Error::GenericError(format!("Failed to get object: {err}")))?; + + let Some(data) = res.data else { + return Err(Error::InvalidArgument("no data found".to_string())); + }; + + match data.owner { + Some(Owner::Shared(initial_shared_version)) => Ok(CallArg::Shared(SharedObjectRef { + object_id: *object_id, + initial_shared_version, + mutable, + })), + _ => Err(Error::InvalidArgument("object is not shared".to_string())), + } +} diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs new file mode 100644 index 00000000..37149282 --- /dev/null +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -0,0 +1,160 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Locking configuration APIs for Audit Trails. + +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::CoreClient; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; + +use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; +use crate::core::types::{LockingConfig, LockingWindow, TimeLock}; +use crate::error::Error; + +mod operations; +mod transactions; + +pub use transactions::{UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLockingConfig, UpdateWriteLock}; + +use self::operations::LockingOps; + +/// Locking API scoped to a specific trail. +/// +/// This handle updates the trail's locking configuration and queries whether an individual record is currently +/// locked against deletion. +#[derive(Debug, Clone)] +pub struct TrailLocking<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, +} + +impl<'a, C> TrailLocking<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self + } + + /// Replaces the full locking configuration for the trail. + /// + /// This overwrites all three locking dimensions at once: record delete window, trail delete lock, and + /// write lock. The supplied [`LockingConfig`] is validated before the transaction is constructed. + /// + /// # Errors + /// + /// Returns [`Error::InvalidArgument`] when `config` contains: + /// * `delete_record_window` using [`LockingWindow::CountBased`] with `count == 0` (mirrors the Move + /// `ECountWindowMustBePositive` abort). + /// * `delete_trail_lock` using [`TimeLock::UntilDestroyed`] (mirrors the Move + /// `EUntilDestroyedNotSupportedForDeleteTrail` abort). + pub fn update(&self, config: LockingConfig) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + config.validate()?; + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(UpdateLockingConfig::new( + self.trail_id, + owner, + config, + self.selected_capability_id, + ))) + } + + /// Updates only the delete-record window. + /// + /// Count-based windows protect the last N records present in trail order at the start of each call that + /// consults the window. `count` must be positive; pass [`LockingWindow::None`] to remove the lock. + /// Large count values increase delete gas linearly because the on-chain check walks backward from the tail + /// to determine the protected window's lower bound. + /// + /// # Errors + /// + /// Returns [`Error::InvalidArgument`] when `window` is [`LockingWindow::CountBased`] with `count == 0` + /// (mirrors the Move `ECountWindowMustBePositive` abort). + pub fn update_delete_record_window( + &self, + window: LockingWindow, + ) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + window.validate()?; + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(UpdateDeleteRecordWindow::new( + self.trail_id, + owner, + window, + self.selected_capability_id, + ))) + } + + /// Updates only the delete-trail time lock. + /// + /// # Errors + /// + /// Returns [`Error::InvalidArgument`] when `lock` is [`TimeLock::UntilDestroyed`] + /// (mirrors the Move `EUntilDestroyedNotSupportedForDeleteTrail` abort). + pub fn update_delete_trail_lock( + &self, + lock: TimeLock, + ) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + lock.validate_as_delete_trail_lock()?; + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(UpdateDeleteTrailLock::new( + self.trail_id, + owner, + lock, + self.selected_capability_id, + ))) + } + + /// Updates only the write lock. + pub fn update_write_lock(&self, lock: TimeLock) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(UpdateWriteLock::new( + self.trail_id, + owner, + lock, + self.selected_capability_id, + )) + } + + /// Returns `true` when the given record is currently locked against deletion. + /// + /// For count-based windows, the check determines the protected window's lower bound by walking back + /// from the current tail at call time; time-based locks are evaluated against the clock timestamp at + /// call time. The result reflects the trail snapshot observed by this read-only call. + /// + /// # Errors + /// + /// Returns an error if the lock state cannot be computed from the current on-chain state. + pub async fn is_record_locked(&self, sequence_number: u64) -> Result + where + C: AuditTrailReadOnly, + { + let tx = LockingOps::is_record_locked(self.client, self.trail_id, sequence_number).await?; + self.client.execute_read_only_transaction(tx).await + } +} diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs new file mode 100644 index 00000000..10e9613b --- /dev/null +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -0,0 +1,161 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Internal helpers that build locking-related programmable transactions. +//! +//! These helpers serialize locking values into the Move shapes used by the trail package and select the +//! corresponding locking-update permissions. + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::internal::tx; +use crate::core::types::{LockingConfig, LockingWindow, Permission, TimeLock}; +use crate::error::Error; + +/// Internal namespace for locking transaction construction. +pub(super) struct LockingOps; + +impl LockingOps { + /// Builds the `update_locking_config` call. + pub(super) async fn update_locking_config( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + new_config: LockingConfig, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let tf_components_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for Audit Trail clients"); + + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateLockingConfig, + selected_capability_id, + "update_locking_config", + |ptb, _| { + let config = new_config.to_ptb(ptb, client.package_id(), tf_components_package_id)?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![config, clock]) + }, + ) + .await + } + + /// Builds the `update_delete_record_window` call. + pub(super) async fn update_delete_record_window( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + new_delete_record_window: LockingWindow, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateLockingConfigForDeleteRecord, + selected_capability_id, + "update_delete_record_window", + |ptb, _| { + let window = new_delete_record_window.to_ptb(ptb, client.package_id())?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![window, clock]) + }, + ) + .await + } + + /// Builds the `update_delete_trail_lock` call. + pub(super) async fn update_delete_trail_lock( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + new_delete_trail_lock: TimeLock, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let tf_components_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for Audit Trail clients"); + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateLockingConfigForDeleteTrail, + selected_capability_id, + "update_delete_trail_lock", + |ptb, _| { + let delete_trail_lock = new_delete_trail_lock.to_ptb(ptb, tf_components_package_id)?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![delete_trail_lock, clock]) + }, + ) + .await + } + + /// Builds the `update_write_lock` call. + pub(super) async fn update_write_lock( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + new_write_lock: TimeLock, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let tf_components_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for Audit Trail clients"); + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateLockingConfigForWrite, + selected_capability_id, + "update_write_lock", + |ptb, _| { + let write_lock = new_write_lock.to_ptb(ptb, tf_components_package_id)?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![write_lock, clock]) + }, + ) + .await + } + + /// Builds the read-only `is_record_locked` call. + pub(super) async fn is_record_locked( + client: &C, + trail_id: ObjectID, + sequence_number: u64, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_read_only_transaction(client, trail_id, "is_record_locked", |ptb| { + let sequence_number = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; + let clock = tx::get_clock_ref(ptb); + + Ok(vec![sequence_number, clock]) + }) + .await + } +} diff --git a/audit-trail-rs/src/core/locking/transactions.rs b/audit-trail-rs/src/core/locking/transactions.rs new file mode 100644 index 00000000..01dd7e12 --- /dev/null +++ b/audit-trail-rs/src/core/locking/transactions.rs @@ -0,0 +1,292 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Transaction payloads for locking updates. + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::IotaTransactionBlockEffects; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use super::operations::LockingOps; +use crate::core::types::{LockingConfig, LockingWindow, TimeLock}; +use crate::error::Error; + +/// Transaction that replaces the full locking configuration. +/// +/// Requires the `UpdateLockingConfig` permission. The new `delete_trail_lock` must not be +/// [`TimeLock::UntilDestroyed`]; the Move package aborts otherwise. This writes the full +/// `LockingConfig` object and therefore updates all locking dimensions in one call. +/// +/// On success a `LockingConfigUpdated` event is emitted. +#[derive(Debug, Clone)] +pub struct UpdateLockingConfig { + trail_id: ObjectID, + owner: IotaAddress, + config: LockingConfig, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl UpdateLockingConfig { + /// Creates an `UpdateLockingConfig` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + config: LockingConfig, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + config, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + LockingOps::update_locking_config( + client, + self.trail_id, + self.owner, + self.config.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateLockingConfig { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +/// Transaction that updates the delete-record window. +/// +/// Requires the `UpdateLockingConfigForDeleteRecord` permission. Updates only the rule that governs how +/// long after creation, or for how many trailing records, an individual record stays *locked against +/// deletion*. +/// +/// On success a `LockingConfigUpdated` event is emitted. +#[derive(Debug, Clone)] +pub struct UpdateDeleteRecordWindow { + trail_id: ObjectID, + owner: IotaAddress, + window: LockingWindow, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl UpdateDeleteRecordWindow { + /// Creates an `UpdateDeleteRecordWindow` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + window: LockingWindow, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + window, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + LockingOps::update_delete_record_window( + client, + self.trail_id, + self.owner, + self.window.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateDeleteRecordWindow { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +/// Transaction that updates the delete-trail lock. +/// +/// Requires the `UpdateLockingConfigForDeleteTrail` permission. The new lock must not be +/// [`TimeLock::UntilDestroyed`]; the Move package aborts otherwise. This updates only the time lock +/// guarding deletion of the entire trail object. +/// +/// On success a `LockingConfigUpdated` event is emitted. +#[derive(Debug, Clone)] +pub struct UpdateDeleteTrailLock { + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl UpdateDeleteTrailLock { + /// Creates an `UpdateDeleteTrailLock` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + lock, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + LockingOps::update_delete_trail_lock( + client, + self.trail_id, + self.owner, + self.lock.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateDeleteTrailLock { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +/// Transaction that updates the write lock. +/// +/// Requires the `UpdateLockingConfigForWrite` permission. Updates only the time lock guarding future +/// record writes; while the lock is active, `add_record` aborts with `ETrailWriteLocked`. +/// +/// On success a `LockingConfigUpdated` event is emitted. +#[derive(Debug, Clone)] +pub struct UpdateWriteLock { + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl UpdateWriteLock { + /// Creates an `UpdateWriteLock` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + lock, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + LockingOps::update_write_lock( + client, + self.trail_id, + self.owner, + self.lock.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateWriteLock { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs new file mode 100644 index 00000000..80eb219d --- /dev/null +++ b/audit-trail-rs/src/core/mod.rs @@ -0,0 +1,33 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Core handles, builders, transactions, and domain types for Audit Trails. +//! +//! This namespace contains the main trail-facing Rust API: +//! +//! - [`crate::core::access`] exposes role and capability management +//! - [`crate::core::builder`] configures trail creation +//! - [`crate::core::create`] contains the creation transaction types +//! - [`crate::core::locking`] manages trail locking rules +//! - [`crate::core::records`] reads and mutates trail records +//! - [`crate::core::tags`] manages the trail-owned record-tag registry +//! - [`crate::core::trail`] provides the high-level typed handle bound to a specific trail +//! - [`crate::core::types`] contains serializable value types shared across the crate + +/// Role and capability management APIs. +pub mod access; +/// Builder used to configure trail creation. +pub mod builder; +/// Trail-creation transaction types. +pub mod create; +pub(crate) mod internal; +/// Locking configuration APIs. +pub mod locking; +/// Record read and mutation APIs. +pub mod records; +/// Trail-scoped record-tag management APIs. +pub mod tags; +/// High-level trail handle types. +pub mod trail; +/// Shared domain and event types. +pub mod types; diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs new file mode 100644 index 00000000..938cfbec --- /dev/null +++ b/audit-trail-rs/src/core/records/mod.rs @@ -0,0 +1,314 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Record read and mutation APIs for Audit Trails. + +use std::collections::{BTreeMap, HashMap}; + +use iota_interaction::move_core_types::annotated_value::MoveValue; +use iota_interaction::rpc_types::IotaMoveValue; +use iota_interaction::types::base_types::{ObjectID, TypeTag}; +use iota_interaction::types::collection_types::LinkedTable; +use iota_interaction::types::dynamic_field::DynamicFieldName; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; +use serde::de::DeserializeOwned; + +use crate::core::internal::{linked_table, trail as trail_reader}; +use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; +use crate::core::types::{Data, PaginatedRecord, Record}; +use crate::error::Error; + +mod operations; +mod transactions; + +pub use transactions::{AddRecord, DeleteRecord, DeleteRecordsBatch}; + +use self::operations::RecordsOps; + +const MAX_LIST_PAGE_LIMIT: usize = 1_000; + +/// Record API scoped to a specific trail. +/// +/// This handle builds record-oriented transactions and loads record data from the trail's linked-table storage. +#[derive(Debug, Clone)] +pub struct TrailRecords<'a, C, D = Data> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, + pub(crate) _phantom: std::marker::PhantomData, +} + +impl<'a, C, D> TrailRecords<'a, C, D> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + _phantom: std::marker::PhantomData, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self + } + + /// Loads a single record by sequence number. + /// + /// # Errors + /// + /// Returns an error if the record cannot be loaded or deserialized. + pub async fn get(&self, sequence_number: u64) -> Result, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let tx = RecordsOps::get_record(self.client, self.trail_id, sequence_number).await?; + self.client.execute_read_only_transaction(tx).await + } + + /// Builds a transaction that appends a record to the trail. + /// + /// Tagged writes must reference a tag already defined on the trail. They also require a capability whose + /// role allows both `AddRecord` and the requested tag. + pub fn add(&self, data: D, metadata: Option, tag: Option) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + D: Into, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(AddRecord::new( + self.trail_id, + owner, + data.into(), + metadata, + tag, + self.selected_capability_id, + )) + } + + /// Builds a transaction that deletes a single record. + /// + /// Deletion remains subject to record locking rules and tag-based access restrictions enforced on-chain. + pub fn delete(&self, sequence_number: u64) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(DeleteRecord::new( + self.trail_id, + owner, + sequence_number, + self.selected_capability_id, + )) + } + + /// Builds a transaction that deletes up to `limit` records in one operation. + /// + /// Batch deletion requires `DeleteAllRecords`, walks the trail from the front in sequence order, and silently + /// skips records that are either locked or whose tag is not in the capability's allowed set. The returned + /// vector contains the sequence numbers actually deleted in deletion order; it may be shorter than `limit` + /// (or empty) when records are skipped or the trail runs out of records before `limit` is reached. + /// + /// # Locking semantics + /// + /// The set of locked records is fixed at the start of the transaction. For count-based windows, the protected + /// window is the last `count` records present when the call begins — records this same call deletes do not + /// change which other records are protected. Time-based locks are evaluated against the clock timestamp + /// captured at the start of the call. Running `delete_records_batch(limit)` therefore produces the same + /// final trail state as invoking `delete_record` once per deletable sequence number, as long as the locking + /// configuration is not mutated and no new records are added between calls. + /// + /// # Caveats + /// + /// - **Partial progress is not an error.** An empty returned vector means every front-to-back candidate was either + /// locked or tag-filtered out. + /// - **Tag filtering is silent.** A capability with a narrow tag scope can make the batch appear to stop early + /// while locked-and-disallowed records still exist further back. + /// - **Gas and object-size limits.** The call walks and mutates inline; prefer modest `limit` values and repeat the + /// call rather than passing a single large `limit`. + /// - **Order is fixed.** Use [`Self::delete`] to target specific sequence numbers. + pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(DeleteRecordsBatch::new( + self.trail_id, + owner, + limit, + self.selected_capability_id, + )) + } + + /// Placeholder for a future correction helper. + /// + /// # Errors + /// + /// Always returns [`Error::NotImplemented`]. + pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailRecords::correct")) + } + + /// Returns the number of records currently stored in the trail. + /// + /// # Errors + /// + /// Returns an error if the count cannot be computed from the current on-chain state. + pub async fn record_count(&self) -> Result + where + C: AuditTrailReadOnly, + { + let tx = RecordsOps::record_count(self.client, self.trail_id).await?; + self.client.execute_read_only_transaction(tx).await + } + + /// Lists all records into a [`HashMap`]. + /// + /// This traverses the full on-chain linked table and can be expensive for large trails. + /// For paginated access, use [`list_page`](Self::list_page). + pub async fn list(&self) -> Result>, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let records_table = self.load_records_table().await?; + list_linked_table::<_, Record>(self.client, &records_table, None).await + } + + /// Lists all records with a hard cap to protect against expensive traversals. + pub async fn list_with_limit(&self, max_entries: usize) -> Result>, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let records_table = self.load_records_table().await?; + list_linked_table::<_, Record>(self.client, &records_table, Some(max_entries)).await + } + + /// Lists one page of linked-table records starting from `cursor`. + /// + /// Pass `None` for the first page; use `next_cursor` for subsequent pages. + pub async fn list_page(&self, cursor: Option, limit: usize) -> Result, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + if limit > MAX_LIST_PAGE_LIMIT { + return Err(Error::InvalidArgument(format!( + "page limit {limit} exceeds max supported page size {MAX_LIST_PAGE_LIMIT}" + ))); + } + + let records_table = self.load_records_table().await?; + let (records, next_cursor) = + list_linked_table_page::<_, Record>(self.client, &records_table, cursor, limit).await?; + + Ok(PaginatedRecord { + has_next_page: next_cursor.is_some(), + next_cursor, + records, + }) + } + + async fn load_records_table(&self) -> Result, Error> + where + C: AuditTrailReadOnly, + { + trail_reader::get_audit_trail(self.trail_id, self.client) + .await + .map(|on_chain_trail| on_chain_trail.records) + } +} + +async fn list_linked_table_page( + client: &C, + table: &LinkedTable, + start_key: Option, + limit: usize, +) -> Result<(BTreeMap, Option), Error> +where + C: CoreClientReadOnly + OptionalSync, + V: DeserializeOwned, +{ + // Preserve linked-table order while exposing a page as a stable Rust map keyed by sequence number. + if limit == 0 { + return Ok((BTreeMap::new(), start_key.or(table.head))); + } + + let mut cursor = start_key.or(table.head); + let mut items = BTreeMap::new(); + + for _ in 0..limit { + let Some(key) = cursor else { break }; + + if items.contains_key(&key) { + return Err(Error::UnexpectedApiResponse(format!( + "cycle detected while traversing linked-table {table_id}; repeated key {key}", + table_id = table.id + ))); + } + + let node = linked_table::fetch_node::<_, u64, V>( + client, + table.id, + DynamicFieldName { + type_: TypeTag::U64, + value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), + }, + ) + .await?; + + cursor = node.next; + items.insert(key, node.value); + } + + Ok((items, cursor)) +} + +async fn list_linked_table( + client: &C, + table: &LinkedTable, + max_entries: Option, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + V: DeserializeOwned, +{ + // Full traversal is only allowed when the caller explicitly accepts the current linked-table size. + let expected = table.size as usize; + let cap = max_entries.unwrap_or(expected); + + if expected > cap { + return Err(Error::InvalidArgument(format!( + "linked-table size {expected} exceeds max_entries {cap}" + ))); + } + + let (entries, next_key) = list_linked_table_page(client, table, None, expected).await?; + + if entries.len() != expected { + return Err(Error::UnexpectedApiResponse(format!( + "linked-table traversal mismatch; expected {expected} entries, got {}", + entries.len() + ))); + } + + if next_key.is_some() { + return Err(Error::UnexpectedApiResponse(format!( + "linked-table traversal has extra entries beyond declared size {expected}" + ))); + } + + Ok(entries.into_iter().collect()) +} diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs new file mode 100644 index 00000000..af099ca2 --- /dev/null +++ b/audit-trail-rs/src/core/records/operations.rs @@ -0,0 +1,171 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Internal record-operation helpers that build trail-scoped programmable transactions. +//! +//! These helpers enforce the Rust-side preflight checks around record tags and then encode the exact Move call +//! arguments expected by the trail package. + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::internal::capability::find_capable_cap_for_tag; +use crate::core::internal::{trail as trail_reader, tx}; +use crate::core::types::{Data, Permission}; +use crate::error::Error; + +/// Internal namespace for record-related transaction construction. +pub(super) struct RecordsOps; + +impl RecordsOps { + /// Builds the `add_record` call. + /// + /// Tagged writes are prevalidated against the trail tag registry and require a capability whose role allows + /// both `AddRecord` and the requested tag. + pub(super) async fn add_record( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + data: Data, + record_metadata: Option, + record_tag: Option, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let package_id = client.package_id(); + if let Some(tag) = record_tag.clone() { + let trail = trail_reader::get_audit_trail(trail_id, client).await?; + if !trail.tags.contains_key(&tag) { + return Err(Error::InvalidArgument(format!( + "record tag '{tag}' is not defined for trail {trail_id}" + ))); + } + let cap_ref = if let Some(capability_id) = selected_capability_id { + tx::get_object_ref_by_id(client, &capability_id).await? + } else { + find_capable_cap_for_tag(client, owner, trail_id, &trail, &tag).await? + }; + + tx::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, "add_record", |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag, package_id)?; + + let data_arg = data.into_ptb(ptb, package_id)?; + let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; + let tag_arg = tx::ptb_pure(ptb, "record_tag", Some(tag))?; + let clock = tx::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, tag_arg, clock]) + }) + .await + } else { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddRecord, + selected_capability_id, + "add_record", + |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag, package_id)?; + + let data_arg = data.into_ptb(ptb, package_id)?; + let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; + let tag = tx::ptb_pure(ptb, "record_tag", Option::::None)?; + let clock = tx::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, tag, clock]) + }, + ) + .await + } + } + + /// Builds the `delete_record` call. + /// + /// Authorization and locking remain enforced by the Move entry point. + pub(super) async fn delete_record( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + sequence_number: u64, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteRecord, + selected_capability_id, + "delete_record", + |ptb, _| { + let seq = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; + let clock = tx::get_clock_ref(ptb); + Ok(vec![seq, clock]) + }, + ) + .await + } + + /// Builds the `delete_records_batch` call. + /// + /// Batch deletion requires `DeleteAllRecords`, skips locked records and records outside the capability's tag + /// access, and deletes up to `limit` eligible records in trail order. + /// + /// `limit` caps the number of records actually deleted, not the number of records inspected. Ineligible + /// records at the front of the trail are silently walked past without counting toward `limit`, so more + /// than `limit` records may be visited before `limit` deletions accumulate. + pub(super) async fn delete_records_batch( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + limit: u64, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteAllRecords, + selected_capability_id, + "delete_records_batch", + |ptb, _| { + let limit_arg = tx::ptb_pure(ptb, "limit", limit)?; + let clock = tx::get_clock_ref(ptb); + Ok(vec![limit_arg, clock]) + }, + ) + .await + } + + /// Builds the read-only `get_record` call. + pub(super) async fn get_record( + client: &C, + trail_id: ObjectID, + sequence_number: u64, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_read_only_transaction(client, trail_id, "get_record", |ptb| { + let seq = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; + Ok(vec![seq]) + }) + .await + } + + /// Builds the read-only `record_count` call. + pub(super) async fn record_count(client: &C, trail_id: ObjectID) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await + } +} diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs new file mode 100644 index 00000000..882141d9 --- /dev/null +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -0,0 +1,317 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Transaction payloads for record writes and deletions. +//! +//! These types cache the generated programmable transaction, delegate PTB construction to +//! [`super::operations::RecordsOps`], and decode record events into typed Rust outputs. + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use super::operations::RecordsOps; +use crate::core::types::{Data, Event, RecordAdded, RecordDeleted}; +use crate::error::Error; + +// ===== AddRecord ===== + +/// Transaction that appends a record to a trail. +/// +/// Requires the `AddRecord` permission. Tagged writes additionally require the tag to exist in the trail +/// registry and a capability whose role explicitly allows that tag; otherwise the Move package aborts with +/// `ERecordTagNotDefined` or `ERecordTagNotAllowed`. The package also aborts with `ETrailWriteLocked` while +/// the configured `write_lock` is active. On success the new record is stored at the trail's current +/// monotonic sequence number (which never decrements, even after deletions) and a `RecordAdded` event is +/// emitted. +#[derive(Debug, Clone)] +pub struct AddRecord { + /// Trail object ID that will receive the record. + pub trail_id: ObjectID, + /// Address authorizing the write. + pub owner: IotaAddress, + /// Record payload to append. + pub data: Data, + /// Optional application-defined metadata. + pub metadata: Option, + /// Optional trail-owned tag to attach to the record. + pub tag: Option, + /// Explicit capability to use instead of auto-selecting one from the owner's wallet. + pub selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl AddRecord { + /// Creates an `AddRecord` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + data: Data, + metadata: Option, + tag: Option, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + data, + metadata, + tag, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RecordsOps::add_record( + client, + self.trail_id, + self.owner, + self.data.clone(), + self.metadata.clone(), + self.tag.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for AddRecord { + type Error = Error; + type Output = RecordAdded; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RecordAdded event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +// ===== DeleteRecord ===== + +/// Transaction that deletes a single record. +/// +/// Requires the `DeleteRecord` permission. The Move package aborts with `ERecordNotFound` when no record +/// exists at `sequence_number` and with `ERecordLocked` while the configured delete-record window still +/// protects the record. Tag-aware authorization additionally applies: if the record carries a tag, the +/// supplied capability's role must allow that tag. +/// +/// On success a `RecordDeleted` event is emitted. +#[derive(Debug, Clone)] +pub struct DeleteRecord { + /// Trail object ID containing the record. + pub trail_id: ObjectID, + /// Address authorizing the deletion. + pub owner: IotaAddress, + /// Sequence number of the record to delete. + pub sequence_number: u64, + /// Explicit capability to use instead of auto-selecting one from the owner's wallet. + pub selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl DeleteRecord { + /// Creates a `DeleteRecord` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + sequence_number: u64, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + sequence_number, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RecordsOps::delete_record( + client, + self.trail_id, + self.owner, + self.sequence_number, + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteRecord { + type Error = Error; + type Output = RecordDeleted; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RecordDeleted event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +// ===== DeleteRecordsBatch ===== + +/// Transaction that deletes multiple records in a batch operation. +/// +/// Requires the `DeleteAllRecords` permission. The Move entry point walks the trail from the front, +/// silently skips records still inside the delete-record window or outside the capability's allowed tag set, +/// and deletes up to `limit` eligible records in trail order. +/// +/// On success a `RecordDeleted` event is emitted per deletion. +/// +/// `limit` caps the number of records actually deleted, not the number of records inspected. Ineligible +/// records at the front of the trail are silently walked past without counting toward `limit`, so more +/// than `limit` records may be visited before `limit` deletions accumulate. +/// +/// Lock state — both count-based +/// and time-based — is evaluated against the trail snapshot and clock timestamp captured at the start of the +/// call, so the deletable set is stable for the batch's duration. The Rust implementation mirrors the Move +/// output by collecting the matching `RecordDeleted` events in deletion order; the returned vector may be +/// shorter than `limit` (or empty) and that is not an error. +#[derive(Debug, Clone)] +pub struct DeleteRecordsBatch { + /// Trail object ID containing the records. + pub trail_id: ObjectID, + /// Address authorizing the deletion. + pub owner: IotaAddress, + /// Maximum number of records to delete in this batch. + pub limit: u64, + /// Explicit capability to use instead of auto-selecting one from the owner's wallet. + pub selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl DeleteRecordsBatch { + /// Creates a `DeleteRecordsBatch` transaction builder payload. + pub fn new(trail_id: ObjectID, owner: IotaAddress, limit: u64, selected_capability_id: Option) -> Self { + Self { + trail_id, + owner, + limit, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RecordsOps::delete_records_batch( + client, + self.trail_id, + self.owner, + self.limit, + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteRecordsBatch { + type Error = Error; + type Output = Vec; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let deleted = events + .data + .iter() + .filter_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .map(|event| event.data.sequence_number) + .collect(); + + Ok(deleted) + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} diff --git a/audit-trail-rs/src/core/tags/mod.rs b/audit-trail-rs/src/core/tags/mod.rs new file mode 100644 index 00000000..8935ba67 --- /dev/null +++ b/audit-trail-rs/src/core/tags/mod.rs @@ -0,0 +1,77 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Record-tag registry APIs for Audit Trails. + +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::CoreClient; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; + +use crate::core::trail::AuditTrailFull; + +mod operations; +mod transactions; + +pub use transactions::{AddRecordTag, RemoveRecordTag}; + +/// Tag-registry API scoped to a specific trail. +/// +/// The registry defines the canonical set of tags that records and role-tag restrictions may reference. +#[derive(Debug, Clone)] +pub struct TrailTags<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, +} + +impl<'a, C> TrailTags<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self + } + + /// Adds a tag to the trail-owned record-tag registry. + /// + /// Added tags become available to future tagged record writes and role-tag restrictions. + pub fn add(&self, tag: impl Into) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(AddRecordTag::new( + self.trail_id, + owner, + tag.into(), + self.selected_capability_id, + )) + } + + /// Removes a tag from the trail-owned record-tag registry. + /// + /// Removal fails on-chain while the tag is still referenced by existing records or role-tag policies. + pub fn remove(&self, tag: impl Into) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(RemoveRecordTag::new( + self.trail_id, + owner, + tag.into(), + self.selected_capability_id, + )) + } +} diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs new file mode 100644 index 00000000..ba86c650 --- /dev/null +++ b/audit-trail-rs/src/core/tags/operations.rs @@ -0,0 +1,75 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Internal helpers that build record-tag registry transactions. +//! +//! These helpers encode updates to the trail-owned tag registry and select the corresponding tag-management +//! permissions. + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::internal::tx; +use crate::core::types::Permission; +use crate::error::Error; + +/// Internal namespace for tag-registry transaction construction. +pub(super) struct TagsOps; + +impl TagsOps { + /// Builds the `add_record_tag` call. + pub(super) async fn add_record_tag( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddRecordTags, + selected_capability_id, + "add_record_tag", + |ptb, _| { + let tag_arg = tx::ptb_pure(ptb, "tag", tag)?; + let clock = tx::get_clock_ref(ptb); + Ok(vec![tag_arg, clock]) + }, + ) + .await + } + + /// Builds the `remove_record_tag` call. + pub(super) async fn remove_record_tag( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteRecordTags, + selected_capability_id, + "remove_record_tag", + |ptb, _| { + let tag_arg = tx::ptb_pure(ptb, "tag", tag)?; + let clock = tx::get_clock_ref(ptb); + Ok(vec![tag_arg, clock]) + }, + ) + .await + } +} diff --git a/audit-trail-rs/src/core/tags/transactions.rs b/audit-trail-rs/src/core/tags/transactions.rs new file mode 100644 index 00000000..a93577f5 --- /dev/null +++ b/audit-trail-rs/src/core/tags/transactions.rs @@ -0,0 +1,143 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Transaction payloads for tag-registry updates. + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::IotaTransactionBlockEffects; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use super::operations::TagsOps; +use crate::error::Error; + +/// Transaction that adds a record tag to the trail registry. +/// +/// Requires the `AddRecordTags` permission. The Move package aborts with `ERecordTagAlreadyDefined` if +/// the tag is already in the registry. The new tag is inserted with a usage count of zero. +/// +/// On success a `RecordTagAdded` event is emitted. +#[derive(Debug, Clone)] +pub struct AddRecordTag { + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl AddRecordTag { + /// Creates an `AddRecordTag` transaction builder payload. + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String, selected_capability_id: Option) -> Self { + Self { + trail_id, + owner, + tag, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TagsOps::add_record_tag( + client, + self.trail_id, + self.owner, + self.tag.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for AddRecordTag { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +/// Transaction that removes a record tag from the trail registry. +/// +/// Requires the `DeleteRecordTags` permission. The Move package aborts with `ERecordTagNotDefined` if +/// the tag is not present and with `ERecordTagInUse` while it is still referenced by any existing +/// record or role-tag restriction. +/// +/// On success a `RecordTagRemoved` event is emitted. +#[derive(Debug, Clone)] +pub struct RemoveRecordTag { + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl RemoveRecordTag { + /// Creates a `RemoveRecordTag` transaction builder payload. + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String, selected_capability_id: Option) -> Self { + Self { + trail_id, + owner, + tag, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TagsOps::remove_record_tag( + client, + self.trail_id, + self.owner, + self.tag.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for RemoveRecordTag { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs new file mode 100644 index 00000000..b1bede6f --- /dev/null +++ b/audit-trail-rs/src/core/trail.rs @@ -0,0 +1,145 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! High-level trail handles and trail-scoped transactions. + +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; +use serde::de::DeserializeOwned; + +use crate::core::access::TrailAccess; +use crate::core::internal::trail as trail_reader; +use crate::core::locking::TrailLocking; +use crate::core::records::TrailRecords; +use crate::core::tags::TrailTags; +use crate::core::types::{Data, OnChainAuditTrail}; +use crate::error::Error; + +mod operations; +mod transactions; + +pub use transactions::{DeleteAuditTrail, Migrate, UpdateMetadata}; + +/// Marker trait for read-only audit-trail clients. +#[doc(hidden)] +#[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait::async_trait)] +pub trait AuditTrailReadOnly: CoreClientReadOnly + OptionalSync { + /// Executes a read-only programmable transaction and decodes the first return value. + async fn execute_read_only_transaction(&self, tx: ProgrammableTransaction) + -> Result; +} + +/// Marker trait for full audit-trail clients. +#[doc(hidden)] +pub trait AuditTrailFull: AuditTrailReadOnly {} + +/// A typed handle bound to one trail ID and one client. +/// +/// This is the main trail-scoped entry point. It keeps the trail identity together with the client so record, +/// locking, access, tag, migration, and metadata operations all share one typed handle. +#[derive(Debug, Clone)] +pub struct AuditTrailHandle<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, +} + +impl<'a, C> AuditTrailHandle<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { + client, + trail_id, + selected_capability_id: None, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self + } + + /// Loads the full on-chain audit trail object. + /// + /// Each call fetches a fresh snapshot from chain state rather than reusing cached client-side data. + pub async fn get(&self) -> Result + where + C: AuditTrailReadOnly, + { + trail_reader::get_audit_trail(self.trail_id, self.client).await + } + + /// Updates the trail's mutable metadata field. + /// + /// Passing `None` clears the field on-chain. + pub fn update_metadata(&self, metadata: Option) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(UpdateMetadata::new( + self.trail_id, + owner, + metadata, + self.selected_capability_id, + )) + } + + /// Migrates the trail to the latest package version supported by this crate. + pub fn migrate(&self) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(Migrate::new(self.trail_id, owner, self.selected_capability_id)) + } + + /// Deletes the trail object. + /// + /// Requires the `DeleteAuditTrail` permission. Deletion additionally requires the trail to be + /// empty (`ETrailNotEmpty` otherwise) and the configured `delete_trail_lock` to have elapsed + /// (`ETrailDeleteLocked` otherwise). + pub fn delete_audit_trail(&self) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner, self.selected_capability_id)) + } + + /// Returns the record API scoped to this trail. + /// + /// Use this for record reads, appends, and deletions. + pub fn records(&self) -> TrailRecords<'a, C, Data> { + TrailRecords::new(self.client, self.trail_id, self.selected_capability_id) + } + + /// Returns the locking API scoped to this trail. + /// + /// Use this for inspecting lock state and updating locking rules. + pub fn locking(&self) -> TrailLocking<'a, C> { + TrailLocking::new(self.client, self.trail_id, self.selected_capability_id) + } + + /// Returns the access-control API scoped to this trail. + /// + /// Use this for roles, capabilities, and access-policy updates. + pub fn access(&self) -> TrailAccess<'a, C> { + TrailAccess::new(self.client, self.trail_id, self.selected_capability_id) + } + + /// Returns the tag-registry API scoped to this trail. + /// + /// Use this for managing the canonical tag registry that record writes and role tags must reference. + pub fn tags(&self) -> TrailTags<'a, C> { + TrailTags::new(self.client, self.trail_id, self.selected_capability_id) + } +} diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs new file mode 100644 index 00000000..e3ebfa0c --- /dev/null +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -0,0 +1,98 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Internal helpers that build trail-level programmable transactions. +//! +//! These helpers select the required trail-level permission and encode the corresponding metadata, migration, +//! and deletion calls. + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::internal::tx; +use crate::core::types::Permission; +use crate::error::Error; + +/// Internal namespace for trail-level transaction construction. +pub(super) struct TrailOps; + +impl TrailOps { + /// Builds the `migrate` call. + pub(super) async fn migrate( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::Migrate, + selected_capability_id, + "migrate", + |ptb, _| { + let clock = tx::get_clock_ref(ptb); + Ok(vec![clock]) + }, + ) + .await + } + + /// Builds the `update_metadata` call. + pub(super) async fn update_metadata( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + metadata: Option, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateMetadata, + selected_capability_id, + "update_metadata", + |ptb, _| { + let metadata_arg = tx::ptb_pure(ptb, "new_metadata", metadata)?; + let clock = tx::get_clock_ref(ptb); + Ok(vec![metadata_arg, clock]) + }, + ) + .await + } + + /// Builds the `delete_audit_trail` call. + pub(super) async fn delete_audit_trail( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + selected_capability_id: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteAuditTrail, + selected_capability_id, + "delete_audit_trail", + |ptb, _| { + let clock = tx::get_clock_ref(ptb); + Ok(vec![clock]) + }, + ) + .await + } +} diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs new file mode 100644 index 00000000..50c52e07 --- /dev/null +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -0,0 +1,213 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Transaction payloads for trail-level metadata, migration, and deletion operations. + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use super::operations::TrailOps; +use crate::core::types::{AuditTrailDeleted, Event}; +use crate::error::Error; + +/// Transaction that migrates a trail to the latest package version supported by this crate. +/// +/// This requires the `Migrate` permission on the supplied capability and succeeds only when the on-chain +/// package version is *strictly less* than the current supported version. Otherwise the Move package aborts +/// with `EPackageVersionMismatch`. +/// +/// On success an `AuditTrailMigrated` event is emitted. +#[derive(Debug, Clone)] +pub struct Migrate { + trail_id: ObjectID, + owner: IotaAddress, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl Migrate { + /// Creates a `Migrate` transaction builder payload. + pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self { + Self { + trail_id, + owner, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TrailOps::migrate(client, self.trail_id, self.owner, self.selected_capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for Migrate { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +/// Transaction that updates mutable trail metadata. +/// +/// Requires the `UpdateMetadata` permission on the supplied capability. Passing `None` clears the mutable +/// metadata field on-chain. +/// +/// On success a `MetadataUpdated` event is emitted. +#[derive(Debug, Clone)] +pub struct UpdateMetadata { + trail_id: ObjectID, + owner: IotaAddress, + metadata: Option, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl UpdateMetadata { + /// Creates an `UpdateMetadata` transaction builder payload. + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + metadata: Option, + selected_capability_id: Option, + ) -> Self { + Self { + trail_id, + owner, + metadata, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TrailOps::update_metadata( + client, + self.trail_id, + self.owner, + self.metadata.clone(), + self.selected_capability_id, + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateMetadata { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +/// Transaction that deletes an empty trail. +/// +/// Requires the `DeleteAuditTrail` permission. The Move package additionally aborts with +/// `ETrailNotEmpty` while any records remain in the trail and with `ETrailDeleteLocked` while the +/// configured `delete_trail_lock` is still active. +/// +/// On success an `AuditTrailDeleted` event is emitted. +#[derive(Debug, Clone)] +pub struct DeleteAuditTrail { + trail_id: ObjectID, + owner: IotaAddress, + selected_capability_id: Option, + cached_ptb: OnceCell, +} + +impl DeleteAuditTrail { + /// Creates a `DeleteAuditTrail` transaction builder payload. + pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self { + Self { + trail_id, + owner, + selected_capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TrailOps::delete_audit_trail(client, self.trail_id, self.owner, self.selected_capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteAuditTrail { + type Error = Error; + type Output = AuditTrailDeleted; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("Expected AuditTrailDeleted event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} diff --git a/audit-trail-rs/src/core/types/RoleMap-README.md b/audit-trail-rs/src/core/types/RoleMap-README.md new file mode 100644 index 00000000..d684f986 --- /dev/null +++ b/audit-trail-rs/src/core/types/RoleMap-README.md @@ -0,0 +1,532 @@ +# Role-Based Access Control for Audit Trails + +Audit trails provide an access control registry (a.k.a. `RoleMap`), defining who may perform which +operations by combining two primitives: + +- **Roles** — named permission sets stored on the trail. +- **Capabilities** — on-chain objects held by users, each linked to one role. + +Every operation on a trail (adding a record, deleting a role, revoking a +capability, …) requires the caller to present a `Capability`. The audit trail +validates the capability before allowing the operation. + +--- + +## Concepts + +### Roles + +A role is a named and configurable set of `Permission` values, for example: + +| Role name | Permissions | +| :------------- | :------------------------------------------------ | +| `Admin` | the set returned by `admin_permissions()` | +| `RecordAdmin` | the set returned by `record_admin_permissions()` | +| `LockingAdmin` | the set returned by `locking_admin_permissions()` | +| `Auditor` | _(read-only — no write permissions needed)_ | + +Roles are identified by a unique string name within the trail. Multiple +capabilities can be issued for the same role, to allow users or services to share +that access level. + +Roles may optionally carry a `RoleTags` allowlist (see [Record Tags](#record-tags-and-roletags)). + +### Capabilities + +A `Capability` is an on-chain object owned by a wallet address. It records: + +| Field | Meaning | +| :------------ | :----------------------------------------------------------------- | +| `target_key` | The `ObjectID` of the trail this capability is valid for. | +| `role` | The role name — determines which permissions the holder has. | +| `issued_to` | Optional address binding; only that address may present the cap. | +| `valid_from` | Optional Unix-ms timestamp before which the cap is not yet active. | +| `valid_until` | Optional Unix-ms timestamp after which the cap expires. | + +Possessing a capability does **not** automatically grant access. The audit trail +validates all fields above on every call before the operation is executed. + +### The Admin Role + +When a trail is created, the access control registry is initialized with exactly one role — +the **initial admin role** (named `"Admin"`). A corresponding capability +object is minted and transferred to the trail creator (or a custom address +supplied via `with_admin`). + +The Admin role is protected by two invariants: + +1. It can **never be deleted**. +2. Although its permission set can be updated, it must always retain the permissions required + to manage the trail's access control — those returned by `role_admin_permissions()` (role + management) together with those returned by `cap_admin_permissions()` (capability + management). Removing any of these permissions from the Admin role will fail. + +Initial admin capabilities are tracked in `initial_admin_cap_ids` and must be +managed through dedicated entry-points (`revoke_initial_admin_capability`, +`destroy_initial_admin_capability`). + +--- + +## Lifecycle Example + +### 1 — Trail is created + +``` +Trail creator ──create_trail()──► AuditTrail (shared object) + │ + └── RoleMap + ├── roles: { "Admin" → admin_permissions() } + ├── initial_admin_role_name: "Admin" + └── initial_admin_cap_ids: { cap_id } + ◄── Admin Capability (owned object, transferred to creator) +``` + +### 2 — Admin defines additional roles + +The trail creator (Admin capability holder) defines a `RecordAdmin` role: + +``` +Admin Capability + create_role("RecordAdmin", record_admin_permissions()) + ──► RoleMap.roles: { "Admin" → admin_permissions(), "RecordAdmin" → record_admin_permissions() } +``` + +### 3 — Admin issues capabilities to operators + +``` +Admin Capability + issue_capability("RecordAdmin", issued_to = operator_address) + ──► RecordAdmin Capability (owned object, transferred to operator) +``` + +### 4 — Operator uses their capability + +``` +RecordAdmin Capability + add_record(trail, data) + ──► RoleMap.assert_capability_valid(cap, AddRecord) // validated + ──► Record appended to trail +``` + +### 5 — Admin revokes a capability + +``` +Admin Capability + revoke_capability(cap_id, valid_until) + ──► RoleMap.revoked_capabilities: { cap_id → valid_until_ms } +``` + +Please note: Revoked capability objects still exist on-chain but will be rejected by +`assert_capability_valid`. The holder can no longer use it. + +--- + +## Rust API Quick Reference + +### Creating a trail and obtaining the Admin capability + +```rust +use audit_trails::core::types::{Data, InitialRecord, ImmutableMetadata}; + +let created = client + .create_trail() + .with_trail_metadata(ImmutableMetadata::new("My Trail".into(), None)) + .with_initial_record(InitialRecord::new(Data::text("first entry"), None, None)) + .finish() + .build_and_execute(&client) + .await? + .output; // TrailCreated { trail_id, creator, timestamp } + +// The Admin capability is now in the creator's wallet. +``` + +### Defining a new role + +```rust +use audit_trails::core::types::PermissionSet; + +client + .trail(created.trail_id) + .access() + .for_role("RecordAdmin") + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&client) + .await?; +``` + +### Issuing a capability + +```rust +use audit_trails::core::types::CapabilityIssueOptions; + +// Unrestricted — any holder may use this capability +let cap = client + .trail(created.trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await? + .output; // CapabilityIssued { capability_id, target_key, role, … } + +// Address-bound and time-limited +let cap = client + .trail(created.trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(operator_address), + valid_from_ms: None, + valid_until_ms: Some(1_800_000_000_000), // expires at this Unix-ms timestamp + }) + .build_and_execute(&client) + .await? + .output; +``` + +### Revoking a capability + +```rust +client + .trail(trail_id) + .access() + .revoke_capability(cap.capability_id, cap.valid_until) + .build_and_execute(&client) + .await?; +``` + +### Cleaning up the denylist + +```rust +// Removes all denylist entries whose valid_until has already passed. +client + .trail(trail_id) + .access() + .cleanup_revoked_capabilities() + .build_and_execute(&client) + .await?; +``` + +### Updating a role's permissions + +```rust +use audit_trails::core::types::{Permission, PermissionSet}; +use std::collections::HashSet; + +client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .update_permissions( + PermissionSet { + permissions: HashSet::from([Permission::AddRecord, Permission::CorrectRecord]), + }, + None, // no RoleTags change + ) + .build_and_execute(&client) + .await?; +``` + +### Deleting a role + +```rust +client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .delete() + .build_and_execute(&client) + .await?; +// Note: the initial admin role ("Admin") cannot be deleted. +``` + +--- + +## Record Tags and RoleTags + +Tags are string labels that can be attached to individual records. They are +managed through a **tag registry** on the trail: a tag must be registered +before it can be used on a record or referenced by a role. + +### Why use tags? + +Tags enable fine-grained access control beyond simple permission checks. For +example, a legal department may only be allowed to access records tagged +`"legal"`, while the finance team works with records tagged `"finance"`. + +### How tags interact with roles + +A role may carry an optional `RoleTags` allowlist. When a capability holder +adds a record with a tag, the audit trail checks that: + +1. The tag is registered in the trail's tag registry. +2. The role associated with the capability includes the requested tag in its + `RoleTags` allowlist. + +If either check fails the transaction is rejected. + +The same checks apply when a record having a tag is updated or deleted. + +Please note: + +- Tags only restrict the use of tagged records to roles that explicitly + grant access to those tags in the associated `RoleTags` allowlist. +- Tags do not grant access permission themselves. A role still needs the relevant + permissions (e.g. `AddRecord`) to perform operations on tagged records. +- A role without any `RoleTags` can operate on any record not having tags, as long + as it has the necessary permissions. + +### Example — tagged records + +```rust +// 1. Create trail with a tag registry +let created = client + .create_trail() + .with_record_tags(["finance", "legal"]) + .with_initial_record(InitialRecord::new(Data::text("opening entry"), None, None)) + .finish() + .build_and_execute(&client) + .await? + .output; + +// 2. Create a role that may only write "finance" tagged records +use audit_trails::core::types::RoleTags; + +client + .trail(created.trail_id) + .access() + .for_role("FinanceWriter") + .create( + PermissionSet { permissions: HashSet::from([Permission::AddRecord]) }, + Some(RoleTags::new(["finance"])), + ) + .build_and_execute(&client) + .await?; +``` + +A `FinanceWriter` capability holder can add records tagged `"finance"` but not +records tagged `"legal"`. + +--- + +## Capability Validation Rules + +Every operation on a trail calls `assert_capability_valid` before executing. +The checks run in the order listed below; the transaction aborts on the +**first** failing check. + +### 1 — `ECapabilityTargetKeyMismatch` + +The capability's `target_key` must match the `target_key` of the RoleMap +(which is typically the `ObjectID` of the audit trail). This prevents a +capability issued for one trail from being used on a different trail. + +### 2 — `ERoleDoesNotExist` + +The role name stored in the capability must still exist in the RoleMap. If +an admin deleted the role after the capability was issued, the capability +becomes unusable — even though it was never explicitly revoked. + +### 3 — `ECapabilityPermissionDenied` + +The role's current permission set must contain the permission required by the +operation being performed. For example, calling `add_record` requires the +`AddRecord` permission. If the role was updated after the capability was +issued and the required permission was removed, existing capabilities for +that role will start failing this check. + +### 4 — `ECapabilityHasBeenRevoked` + +The capability's ID must **not** appear in the `revoked_capabilities` +denylist. A capability that has been revoked via `revoke_capability` (or +`revoke_initial_admin_capability`) is permanently rejected, even if it is +still within its validity window. See +[Managing Revoked Capabilities](#managing-revoked-capabilities) for details. + +### 5 — `ECapabilityTimeConstraintsNotMet` + +This check only runs when the capability has a `valid_from` and/or +`valid_until` field set. The current on-chain clock time must satisfy: + +- `valid_from`: current time **>=** `valid_from` (the capability is not yet + active before this timestamp). +- `valid_until`: current time **<=** `valid_until` (the capability has + expired after this timestamp). + +If neither field is set, this check is skipped entirely and the capability is +considered valid at any point in time. + +### 6 — `ECapabilityIssuedToMismatch` + +This check only runs when the capability has a non-empty `issued_to` field. +The address of the transaction sender must match the `issued_to` address +stored in the capability. This binds the capability to a specific wallet, +preventing it from being used by anyone else even if the on-chain object is +transferred. + +If `issued_to` is not set, any holder of the capability object may use it. + +### 7 — `ERecordTagNotDefined` / `ERecordTagNotAllowed` + +This check is performed by the audit trail **after** all `RoleMap` checks +(1–6) have passed. It only applies to record operations (add, correct, +delete) that involve a tagged record. + +When a record carries a tag, two additional conditions must hold: + +1. The tag must be registered in the trail's **tag registry** + (`ERecordTagNotDefined`). +2. The role associated with the capability must include the tag in its + `RoleTags` allowlist (`ERecordTagNotAllowed`). A role without any + `RoleTags` is **not** permitted to operate on tagged records. + +If the record has no tag, this check is skipped. See +[Record Tags and RoleTags](#record-tags-and-roletags) for a full explanation +and examples. + +### Summary + +| # | Check | Error | Skippable | +| :- | :---------------------- | :---------------------------------------------- | :------------------------------------------------- | +| 1 | `target_key` mismatch | `ECapabilityTargetKeyMismatch` | No | +| 2 | Role does not exist | `ERoleDoesNotExist` | No | +| 3 | Permission not in role | `ECapabilityPermissionDenied` | No | +| 4 | ID in revoked denylist | `ECapabilityHasBeenRevoked` | No | +| 5 | Outside validity window | `ECapabilityTimeConstraintsNotMet` | Yes — only if `valid_from` or `valid_until` is set | +| 6 | `issued_to` mismatch | `ECapabilityIssuedToMismatch` | Yes — only if `issued_to` is set | +| 7 | Record tag not allowed | `ERecordTagNotDefined` / `ERecordTagNotAllowed` | Yes — only for record operations on tagged records | + +--- + +## Managing Revoked Capabilities + +### The `revoked_capabilities` Denylist + +When a capability is revoked it is **not deleted from the chain** — the +on-chain `Capability` object still exists in the holder's wallet. Instead, +the capability's ID is added to a **denylist** stored inside the audit trail. +During every call to an access restricted audit trail function, the internally +called `assert_capability_valid` function checks the denylist and rejects any capability whose +ID appears in it (error `ECapabilityHasBeenRevoked`). + +The denylist approach (as opposed to an allowlist of all issued capabilities) +was chosen deliberately: it keeps on-chain storage proportional to the number +of _currently revoked_ capabilities rather than the total number ever issued. +This is important for deployments that issue large numbers of capabilities over +time. + +Each denylist entry maps a revoked capability ID to a `valid_until` timestamp +(Unix milliseconds). If the revoked capability had no `valid_until` field, the +stored value is `0`, which signals "no expiry — keep in the denylist +indefinitely". + +### How Time-Restricted Capabilities Affect Management + +Capabilities can carry optional `valid_from` and `valid_until` timestamps. +These fields are enforced by the internally used `assert_capability_valid`: +a capability whose +time window has not yet started or has already passed is rejected with +`ECapabilityTimeConstraintsNotMet`, regardless of whether it appears in the +denylist. + +This has an important consequence for revocation: **once a capability's +`valid_until` timestamp has passed, the capability is naturally expired and +can no longer be used — even if it was never explicitly revoked.** Its +denylist entry therefore becomes redundant and can be safely removed. + +The `cleanup_revoked_capabilities` function exploits this property. It +iterates through the denylist and removes every entry whose stored +`valid_until` value is **non-zero** and **less than** the current clock time. +Entries with `valid_until == 0` (capabilities that were issued without an +expiry or where the revoker did not supply the `valid_until` value during the +`revoke_capability` call) are kept because the corresponding capabilities never +expire on their own. + +**Best practice:** always set a `valid_until` when issuing capabilities. +Even a generous validity window (e.g. one year) ensures that the +corresponding denylist entry can be automatically cleaned up after the +capability expires, rather than occupying storage indefinitely. + +### Off-Chain Tracking Requirements + +Because the audit trail uses a denylist and not an allowlist, it does **not** +maintain an on-chain registry of all issued capabilities. Tracking every +issued capability on-chain would increase storage costs and slow down +validity checks. + +This design shifts the bookkeeping responsibility to the user: + +1. **Maintain an off-chain registry of every issued capability**, storing at + least the capability `ID`, the `role` it was issued for, the `issued_to` + address (if any), and the `valid_from` / `valid_until` timestamps. +2. **When revoking**, supply the correct capability ID and its `valid_until` + value (via the `cap_to_revoke_valid_until` parameter). The + `revoke_capability` function does **not** verify that the supplied ID + actually refers to a real, previously-issued capability — if you pass a + random ID, it will be silently added to the denylist without error. + Accurate off-chain records are therefore essential. +3. **Track which capabilities have been revoked or destroyed** so you do not + attempt to revoke the same capability twice (which would abort with + `ECapabilityToRevokeHasAlreadyBeenRevoked`). + +The off-chain capability registry can also be used to manage capability renewal: +when a capability is about to expire, a new capability is automatically issued for the +holder with an updated validity window. The old capability can be revoked or destroyed +at the same time. This process can be fully automated by a background service that +monitors capability expirations and performs renewals as needed. + +For deployments that only issue a small number of capabilities, a simplified +approach is acceptable: track only the issued capability IDs and pass +`None` for `cap_to_revoke_valid_until` when revoking capabilities using the +`revoke_capability` function. The trade-off is that +those denylist entries will never be automatically cleaned up — they persist +until the capability object is explicitly destroyed. + +### Cleaning Up the Denylist + +Over time the denylist can accumulate entries for capabilities that have +already naturally expired. The `cleanup_revoked_capabilities` function +removes these stale entries: + +1. It walks through every entry in the `revoked_capabilities` linked table. +2. For each entry with a **non-zero** `valid_until` value that is **less than** + the current on-chain clock time, the entry is removed. +3. Entries with `valid_until == 0` are skipped — they represent capabilities + that have no natural expiry and must remain on the denylist until the + capability object itself is destroyed (via `destroy_capability`). + +The cleanup operation requires a capability with the `RevokeCapabilities` +permission. + +**Recommendations for keeping the denylist short:** + +- Always provide the `cap_to_revoke_valid_until` value that matches the `valid_until` of the + revoked capability when revoking a capability so that + the entry becomes eligible for automatic cleanup. +- Call `cleanup_revoked_capabilities` periodically (e.g. as a maintenance + transaction) to reclaim storage. +- When a revoked capability is no longer needed at all, have the holder call + `destroy_capability` to delete the on-chain object. Destroying a + capability also removes it from the denylist if it was listed there. + +--- + +## Permission Sets + +`PermissionSet` provides convenience constructors for common role profiles. The exact +permissions each one grants are documented on the function itself (the source of truth); the +table below only summarizes the role each profile targets: + +| Constructor | Intended role | +| :----------------------------- | :------------------------------------------ | +| `admin_permissions()` | `Admin` — full trail administration | +| `record_admin_permissions()` | record management (add, delete, correct) | +| `role_admin_permissions()` | role management (add, update, delete roles) | +| `locking_admin_permissions()` | locking-configuration management | +| `cap_admin_permissions()` | capability management (issue, revoke) | +| `tag_admin_permissions()` | record-tag registry management | +| `metadata_admin_permissions()` | updatable-metadata management | + +Please note: + +- These constructors are just for convenience and do not enforce any invariants. + For example, you could (not recommended) create a role named `NormalUser` with + `PermissionSet::admin_permissions()`. +- You can create custom permission sets by constructing a `PermissionSet` with + an arbitrary combination of permissions. diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs new file mode 100644 index 00000000..ddf631eb --- /dev/null +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -0,0 +1,128 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::str::FromStr; + +use iota_interaction::ident_str; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, TypeTag}; +use iota_interaction::types::collection_types::LinkedTable; +use iota_interaction::types::id::UID; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::Argument; +use serde::{Deserialize, Serialize}; + +use super::locking::LockingConfig; +use super::role_map::RoleMap; +use crate::core::internal::move_collections::deserialize_vec_map; +use crate::core::internal::tx; +use crate::error::Error; + +/// Registry of trail-owned record tags. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TagRegistry { + /// Mapping from tag name to usage count. + #[serde(deserialize_with = "deserialize_vec_map")] + pub tag_map: HashMap, +} + +impl TagRegistry { + /// Returns the number of registered tags. + pub fn len(&self) -> usize { + self.tag_map.len() + } + + /// Returns `true` when no tags are registered. + pub fn is_empty(&self) -> bool { + self.tag_map.is_empty() + } + + /// Returns `true` when the registry contains the given tag. + pub fn contains_key(&self, tag: &str) -> bool { + self.tag_map.contains_key(tag) + } + + /// Returns the usage count for a tag. + pub fn get(&self, tag: &str) -> Option<&u64> { + self.tag_map.get(tag) + } + + /// Iterates over tag names and usage counts. + pub fn iter(&self) -> impl Iterator { + self.tag_map.iter() + } +} + +/// An audit trail stored on-chain. +/// +/// The trail is a *shared*, tamper-evident object that maintains an ordered +/// sequence of records. Each record is assigned a unique, auto-incrementing +/// sequence number that is never reused (the counter does not decrement on +/// deletion). Access is governed by capability-based RBAC: every mutating +/// call must present a [`Capability`](super::role_map::Capability) bound to a +/// role whose permissions cover the operation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OnChainAuditTrail { + /// Unique object ID of the trail. + pub id: UID, + /// Address that created the trail. + pub creator: IotaAddress, + /// Millisecond timestamp at which the trail was created. + pub created_at: u64, + /// Current record sequence number cursor. + pub sequence_number: u64, + /// Linked table containing the trail records. + pub records: LinkedTable, + /// Registry of allowed record tags. + pub tags: TagRegistry, + /// Active locking rules for the trail. + pub locking_config: LockingConfig, + /// Role and capability configuration for the trail. + pub roles: RoleMap, + /// Metadata fixed at creation time. + pub immutable_metadata: Option, + /// Metadata that can be updated after creation. + pub updatable_metadata: Option, + /// On-chain package version of the trail object. + pub version: u64, +} + +/// Metadata set at trail creation and never updated. +/// +/// Stored once on the trail object and exposed read-only thereafter. Use +/// [`OnChainAuditTrail::updatable_metadata`] for the mutable counterpart. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImmutableMetadata { + /// Human-readable trail name. + pub name: String, + /// Optional human-readable description. + pub description: Option, +} + +impl ImmutableMetadata { + /// Creates immutable metadata for a trail. + pub fn new(name: String, description: Option) -> Self { + Self { name, description } + } + + pub(in crate::core) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::main::ImmutableMetadata")) + .expect("invalid TypeTag for ImmutableMetadata") + } + + /// Creates a new `Argument` from the `ImmutableMetadata`. + /// + /// To be used when creating a new `ImmutableMetadata` object on the ledger. + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let name = tx::ptb_pure(ptb, "name", &self.name)?; + let description = tx::ptb_pure(ptb, "description", &self.description)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("main").as_str().into(), + ident_str!("new_trail_metadata").as_str().into(), + vec![], + vec![name, description], + )) + } +} diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs new file mode 100644 index 00000000..aa107e7d --- /dev/null +++ b/audit-trail-rs/src/core/types/event.rs @@ -0,0 +1,413 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::collection_types::VecSet; +use serde::{Deserialize, Serialize}; +use serde_aux::field_attributes::{deserialize_number_from_string, deserialize_option_number_from_string}; + +use super::{Permission, PermissionSet, RoleTags}; + +/// Generic wrapper for Audit Trails events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Event { + /// Parsed event payload. + #[serde(flatten)] + pub data: D, +} + +/// Event emitted when a trail is created. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditTrailCreated { + /// Newly created trail object ID. + pub trail_id: ObjectID, + /// Address that created the trail. + pub creator: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when a trail is deleted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditTrailDeleted { + /// Deleted trail object ID. + pub trail_id: ObjectID, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when a trail is migrated to the current package version. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditTrailMigrated { + /// Migrated trail object ID. + pub trail_id: ObjectID, + /// Address that migrated the trail. + pub migrated_by: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when mutable trail metadata is updated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MetadataUpdated { + /// Trail object ID whose metadata changed. + pub trail_id: ObjectID, + /// Address that updated the metadata. + pub updated_by: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when the trail's locking configuration is updated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockingConfigUpdated { + /// Trail object ID whose locking configuration changed. + pub trail_id: ObjectID, + /// Address that updated the locking configuration. + pub updated_by: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when a record is added. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordAdded { + /// Trail object ID receiving the new record. + pub trail_id: ObjectID, + /// Sequence number assigned to the new record. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub sequence_number: u64, + /// Address that added the record. + pub added_by: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when a record is deleted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordDeleted { + /// Trail object ID from which the record was deleted. + pub trail_id: ObjectID, + /// Sequence number of the deleted record. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub sequence_number: u64, + /// Address that deleted the record. + pub deleted_by: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when a record tag is added to the trail registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordTagAdded { + /// Trail object ID whose registry changed. + pub trail_id: ObjectID, + /// Address that added the tag. + pub added_by: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when a record tag is removed from the trail registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordTagRemoved { + /// Trail object ID whose registry changed. + pub trail_id: ObjectID, + /// Address that removed the tag. + pub removed_by: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when a capability is issued. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityIssued { + /// Trail object ID protected by the capability. + pub target_key: ObjectID, + /// Newly created capability object ID. + pub capability_id: ObjectID, + /// Role granted by the capability. + pub role: String, + /// Address receiving the capability, if one is assigned. + pub issued_to: Option, + /// Millisecond timestamp at which the capability becomes valid. + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub valid_from: Option, + /// Millisecond timestamp at which the capability expires. + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub valid_until: Option, +} + +/// Event emitted when a capability object is destroyed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityDestroyed { + /// Trail object ID protected by the capability. + pub target_key: ObjectID, + /// Destroyed capability object ID. + pub capability_id: ObjectID, + /// Role granted by the capability. + pub role: String, + /// Address that held the capability, if any. + pub issued_to: Option, + /// Millisecond timestamp at which the capability became valid. + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub valid_from: Option, + /// Millisecond timestamp at which the capability expired. + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub valid_until: Option, +} + +/// Event emitted when a capability is revoked. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRevoked { + /// Trail object ID protected by the capability. + pub target_key: ObjectID, + /// Revoked capability object ID. + pub capability_id: ObjectID, + /// Millisecond timestamp retained for denylist cleanup. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub valid_until: u64, +} + +/// Event emitted when expired revoked-capability denylist entries are removed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RevokedCapabilitiesCleanedUp { + /// Trail object ID whose denylist was pruned. + pub trail_id: ObjectID, + /// Number of expired entries removed by this cleanup call. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub cleaned_count: u64, + /// Address that triggered the cleanup. + pub cleaned_by: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +/// Event emitted when a role is created. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RoleCreated { + /// Trail object ID that owns the role. + pub trail_id: ObjectID, + /// Role name. + pub role: String, + /// Permissions granted by the new role. + pub permissions: PermissionSet, + /// Optional record-tag restrictions stored as role data. + pub data: Option, + /// Address that created the role. + pub created_by: IotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +/// Event emitted when a role is updated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RoleUpdated { + /// Trail object ID that owns the role. + pub trail_id: ObjectID, + /// Role name. + pub role: String, + /// Updated permissions for the role. + pub permissions: PermissionSet, + /// Updated record-tag restrictions, if any. + pub data: Option, + /// Address that updated the role. + pub updated_by: IotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +/// Event emitted when a role is deleted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RoleDeleted { + /// Trail object ID that owned the role. + pub trail_id: ObjectID, + /// Role name. + pub role: String, + /// Address that deleted the role. + pub deleted_by: IotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct RawRoleCreated { + target_key: ObjectID, + role: String, + permissions: VecSet, + data: Option, + created_by: IotaAddress, + timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct RawRoleUpdated { + target_key: ObjectID, + role: String, + new_permissions: VecSet, + new_data: Option, + updated_by: IotaAddress, + timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct RawRoleDeleted { + target_key: ObjectID, + role: String, + deleted_by: IotaAddress, + timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct RawRoleTags { + tags: VecSet, +} + +impl From> for PermissionSet { + fn from(value: VecSet) -> Self { + Self { + permissions: value.contents.into_iter().collect::>(), + } + } +} + +impl From for RoleTags { + fn from(value: RawRoleTags) -> Self { + Self { + tags: value.tags.contents.into_iter().collect::>(), + } + } +} + +impl From for RoleCreated { + fn from(value: RawRoleCreated) -> Self { + Self { + trail_id: value.target_key, + role: value.role, + permissions: value.permissions.into(), + data: value.data.map(Into::into), + created_by: value.created_by, + timestamp: value.timestamp, + } + } +} + +impl From for RoleUpdated { + fn from(value: RawRoleUpdated) -> Self { + Self { + trail_id: value.target_key, + role: value.role, + permissions: value.new_permissions.into(), + data: value.new_data.map(Into::into), + updated_by: value.updated_by, + timestamp: value.timestamp, + } + } +} + +impl From for RoleDeleted { + fn from(value: RawRoleDeleted) -> Self { + Self { + trail_id: value.target_key, + role: value.role, + deleted_by: value.deleted_by, + timestamp: value.timestamp, + } + } +} + +#[cfg(test)] +mod tests { + use iota_interaction::types::base_types::{IotaAddress, ObjectID}; + use serde_json::json; + + use super::*; + #[test] + fn metadata_updated_event_deserializes_string_encoded_timestamp() { + let trail_id = ObjectID::random(); + let updated_by = IotaAddress::random(); + + let event: Event = serde_json::from_value(json!({ + "trail_id": trail_id, + "updated_by": updated_by, + "timestamp": "42", + })) + .expect("metadata event deserializes"); + + assert_eq!(event.data.trail_id, trail_id); + assert_eq!(event.data.updated_by, updated_by); + assert_eq!(event.data.timestamp, 42); + } + + #[test] + fn locking_config_updated_event_deserializes_string_encoded_timestamp() { + let trail_id = ObjectID::random(); + let updated_by = IotaAddress::random(); + + let event: Event = serde_json::from_value(json!({ + "trail_id": trail_id, + "updated_by": updated_by, + "timestamp": "77", + })) + .expect("locking config event deserializes"); + + assert_eq!(event.data.trail_id, trail_id); + assert_eq!(event.data.updated_by, updated_by); + assert_eq!(event.data.timestamp, 77); + } + + #[test] + fn record_tag_events_deserialize_string_encoded_timestamps() { + let trail_id = ObjectID::random(); + let actor = IotaAddress::random(); + + let added: Event = serde_json::from_value(json!({ + "trail_id": trail_id, + "added_by": actor, + "timestamp": "11", + })) + .expect("tag-added event deserializes"); + let removed: Event = serde_json::from_value(json!({ + "trail_id": trail_id, + "removed_by": actor, + "timestamp": "12", + })) + .expect("tag-removed event deserializes"); + + assert_eq!(added.data.trail_id, trail_id); + assert_eq!(added.data.added_by, actor); + assert_eq!(added.data.timestamp, 11); + assert_eq!(removed.data.trail_id, trail_id); + assert_eq!(removed.data.removed_by, actor); + assert_eq!(removed.data.timestamp, 12); + } + + #[test] + fn audit_trail_migrated_event_deserializes_string_encoded_timestamp() { + let trail_id = ObjectID::random(); + let migrated_by = IotaAddress::random(); + + let event: Event = serde_json::from_value(json!({ + "trail_id": trail_id, + "migrated_by": migrated_by, + "timestamp": "88", + })) + .expect("migrated event deserializes"); + + assert_eq!(event.data.trail_id, trail_id); + assert_eq!(event.data.migrated_by, migrated_by); + assert_eq!(event.data.timestamp, 88); + } +} diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs new file mode 100644 index 00000000..8f44d169 --- /dev/null +++ b/audit-trail-rs/src/core/types/locking.rs @@ -0,0 +1,248 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::ident_str; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::Argument; +use serde::{Deserialize, Serialize}; + +use crate::core::internal::tx; +use crate::error::Error; + +/// Locking configuration for the audit trail. +/// +/// Combines three independent rules: a per-record delete window, a trail-delete +/// time lock, and a write time lock. Two invariants apply: +/// +/// - `delete_trail_lock` must not be [`TimeLock::UntilDestroyed`]; that variant is reserved for `write_lock`. +/// - `delete_record_window`, when [`LockingWindow::CountBased`], must use `count > 0`; use [`LockingWindow::None`] to +/// express "no deletion lock". +/// +/// Public entry points that accept a `LockingConfig` call [`LockingConfig::validate`] +/// up front, so misconfiguration is reported client-side before any transaction +/// is built; the same invariants are enforced on-chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct LockingConfig { + /// Delete-window policy applied to individual records. Records that fall + /// inside the window are locked against deletion. A [`LockingWindow::CountBased`] + /// window must use `count > 0`. + pub delete_record_window: LockingWindow, + /// Time lock that gates deletion of the entire trail. Must not be + /// [`TimeLock::UntilDestroyed`]. + pub delete_trail_lock: TimeLock, + /// Time lock that gates record writes (`add_record`). + pub write_lock: TimeLock, +} + +impl LockingConfig { + /// Validates the locking configuration without contacting the chain. + /// + /// Currently this rejects: + /// - [`LockingWindow::CountBased`] with `count == 0` (mirrors the Move `ECountWindowMustBePositive` abort). + /// - [`TimeLock::UntilDestroyed`] used as `delete_trail_lock` (mirrors the Move + /// `EUntilDestroyedNotSupportedForDeleteTrail` abort). `write_lock` may still be `UntilDestroyed`. + /// + /// Public entry points that accept a `LockingConfig` call this so that misconfiguration is reported + /// before any transaction is built. + pub fn validate(&self) -> Result<(), Error> { + self.delete_record_window.validate()?; + self.delete_trail_lock.validate_as_delete_trail_lock()?; + Ok(()) + } + + /// Creates a new `Argument` from the `LockingConfig`. + /// + /// To be used when creating or updating locking config on the ledger. + pub(in crate::core) fn to_ptb( + &self, + ptb: &mut Ptb, + package_id: ObjectID, + tf_components_package_id: ObjectID, + ) -> Result { + let delete_record_window = self.delete_record_window.to_ptb(ptb, package_id)?; + let delete_trail_lock = self.delete_trail_lock.to_ptb(ptb, tf_components_package_id)?; + let write_lock = self.write_lock.to_ptb(ptb, tf_components_package_id)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("locking").as_str().into(), + ident_str!("new").as_str().into(), + vec![], + vec![delete_record_window, delete_trail_lock, write_lock], + )) + } +} + +/// Time-based lock for trail-level operations. +/// +/// `UntilDestroyed` is rejected by the audit-trail package when used for the +/// trail-delete lock; pass it only for the write lock. +/// +/// Must match `tf_components::timelock::TimeLock` variant order for BCS compatibility. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum TimeLock { + /// Unlocks at the given Unix timestamp in seconds. + UnlockAt(u32), + /// Unlocks at the given Unix timestamp in milliseconds. + UnlockAtMs(u64), + /// Remains locked until the protected object is explicitly destroyed. + /// Not supported as the trail-delete lock. + UntilDestroyed, + /// Represents an always-locked state. + Infinite, + /// Disables the time lock. + #[default] + None, +} + +impl TimeLock { + /// Validates this lock as a candidate for the trail-level delete lock. + /// + /// Rejects [`TimeLock::UntilDestroyed`] (mirrors the Move + /// `EUntilDestroyedNotSupportedForDeleteTrail` abort). All other variants are accepted; time-based + /// timestamp validity is enforced on-chain because it depends on the clock at execution time. + pub fn validate_as_delete_trail_lock(&self) -> Result<(), Error> { + if matches!(self, Self::UntilDestroyed) { + return Err(Error::InvalidArgument( + "TimeLock::UntilDestroyed is not supported as a delete-trail lock".to_string(), + )); + } + Ok(()) + } + + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + match self { + Self::None => Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").as_str().into(), + ident_str!("none").as_str().into(), + vec![], + vec![], + )), + Self::Infinite => Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").as_str().into(), + ident_str!("infinite").as_str().into(), + vec![], + vec![], + )), + Self::UntilDestroyed => Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").as_str().into(), + ident_str!("until_destroyed").as_str().into(), + vec![], + vec![], + )), + Self::UnlockAt(unix_time) => { + let unix_time = tx::ptb_pure(ptb, "unix_time", *unix_time)?; + let clock = tx::get_clock_ref(ptb); + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").as_str().into(), + ident_str!("unlock_at").as_str().into(), + vec![], + vec![unix_time, clock], + )) + } + Self::UnlockAtMs(unix_time_ms) => { + let unix_time_ms = tx::ptb_pure(ptb, "unix_time_ms", *unix_time_ms)?; + let clock = tx::get_clock_ref(ptb); + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").as_str().into(), + ident_str!("unlock_at_ms").as_str().into(), + vec![], + vec![unix_time_ms, clock], + )) + } + } + } +} + +/// Defines a delete-record locking window. +/// +/// A window describes the period during which a record is *locked against +/// deletion*. Records outside the window may be deleted (subject to the +/// remaining permission and tag checks). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum LockingWindow { + /// No delete window is enforced; records may be deleted at any time. + #[default] + None, + /// A record is locked against deletion while its age (in milliseconds) + /// is below the configured number of seconds since it was added. + TimeBased { + /// Window size in seconds. Records younger than this are locked. + seconds: u64, + }, + /// Locks the last `count` records currently present in trail order. + /// + /// The protected window is evaluated against the records present when the + /// transaction begins; concurrent additions are observed by subsequent + /// transactions only. `count` must be positive — use [`LockingWindow::None`] + /// to express "no deletion lock". Constructing this variant with `count == 0` + /// is rejected client-side with [`Error::InvalidArgument`] and would otherwise + /// abort on-chain with `ECountWindowMustBePositive`. + /// + /// The on-chain check walks backward from the current tail once per call, + /// so delete gas scales linearly with `count`. + CountBased { + /// Number of current tail records protected from deletion. Must be `> 0`. + count: u64, + }, +} + +impl LockingWindow { + /// Validates the window configuration without contacting the chain. + /// + /// Rejects [`LockingWindow::CountBased`] with `count == 0` (mirrors the Move + /// `ECountWindowMustBePositive` abort). All other variants are always valid. + pub fn validate(&self) -> Result<(), Error> { + if let Self::CountBased { count: 0 } = self { + return Err(Error::InvalidArgument( + "LockingWindow::CountBased requires count > 0; use LockingWindow::None for no deletion lock" + .to_string(), + )); + } + Ok(()) + } + + /// Creates a new `Argument` from the `LockingWindow`. + /// + /// To be used when creating or updating locking config on the ledger. + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + self.validate()?; + match self { + Self::None => Ok(ptb.programmable_move_call( + package_id, + ident_str!("locking").as_str().into(), + ident_str!("window_none").as_str().into(), + vec![], + vec![], + )), + Self::TimeBased { seconds } => { + let seconds = tx::ptb_pure(ptb, "seconds", *seconds)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("locking").as_str().into(), + ident_str!("window_time_based").as_str().into(), + vec![], + vec![seconds], + )) + } + Self::CountBased { count } => { + let count = tx::ptb_pure(ptb, "count", *count)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("locking").as_str().into(), + ident_str!("window_count_based").as_str().into(), + vec![], + vec![count], + )) + } + } + } +} diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs new file mode 100644 index 00000000..00ba88c3 --- /dev/null +++ b/audit-trail-rs/src/core/types/mod.rs @@ -0,0 +1,27 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Shared serializable domain types for Audit Trails. +//! +//! These types stay close to the on-chain data model so they can deserialize ledger state and events while also +//! serving as the typed inputs and outputs of the Rust client API. + +/// On-chain trail metadata types. +pub mod audit_trail; +/// Event payload types emitted by audit-trail transactions. +pub mod event; +/// Locking configuration types. +pub mod locking; +/// Permission and permission-set types. +pub mod permission; +/// Record payload and pagination types. +pub mod record; +/// Role, capability, and role-tag types. +pub mod role_map; + +pub use audit_trail::*; +pub use event::*; +pub use locking::*; +pub use permission::*; +pub use record::*; +pub use role_map::*; diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs new file mode 100644 index 00000000..b885d255 --- /dev/null +++ b/audit-trail-rs/src/core/types/permission.rs @@ -0,0 +1,237 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; +use std::str::FromStr; + +use iota_interaction::ident_str; +use iota_interaction::types::base_types::{Identifier, ObjectID, TypeTag}; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::{Argument, Command, MakeMoveVector}; +use serde::{Deserialize, Serialize}; + +use crate::error::Error; + +/// Audit-trail permission variants mirrored from the Move permission module. +/// +/// Variants are grouped by the proposed role that typically owns them — `Admin` +/// (whole-trail), `RecordAdmin`, `LockingAdmin`, `RoleAdmin`, `CapAdmin`, +/// `MetadataAdmin`, and `TagAdmin`. The [`PermissionSet`] convenience +/// constructors below build the recommended sets for those roles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub enum Permission { + /// Allows deleting the entire trail. Proposed role: `Admin`. + DeleteAuditTrail, + /// Allows deleting all records via the batch cleanup workflow. + /// Proposed role: `Admin`. + DeleteAllRecords, + /// Allows adding records. + AddRecord, + /// Allows deleting individual records. + DeleteRecord, + /// Allows creating correction records. + CorrectRecord, + /// Allows updating the full locking configuration. + UpdateLockingConfig, + /// Allows updating the delete-record window. + UpdateLockingConfigForDeleteRecord, + /// Allows updating the delete-trail time lock. + UpdateLockingConfigForDeleteTrail, + /// Allows updating the write lock. + UpdateLockingConfigForWrite, + /// Allows creating roles. + AddRoles, + /// Allows updating roles. + UpdateRoles, + /// Allows deleting roles. + DeleteRoles, + /// Allows issuing capabilities. + AddCapabilities, + /// Allows revoking capabilities. + RevokeCapabilities, + /// Allows updating mutable metadata. + UpdateMetadata, + /// Allows deleting mutable metadata. + DeleteMetadata, + /// Allows migrating the trail to a newer package version. + Migrate, + /// Allows adding trail-owned record tags. + AddRecordTags, + /// Allows deleting trail-owned record tags. + DeleteRecordTags, +} + +impl Permission { + /// Returns the Move constructor function name for this permission variant. + pub(crate) fn function_name(&self) -> &'static str { + match self { + Self::DeleteAuditTrail => "delete_audit_trail", + Self::DeleteAllRecords => "delete_all_records", + Self::AddRecord => "add_record", + Self::DeleteRecord => "delete_record", + Self::CorrectRecord => "correct_record", + Self::UpdateLockingConfig => "update_locking_config", + Self::UpdateLockingConfigForDeleteRecord => "update_locking_config_for_delete_record", + Self::UpdateLockingConfigForDeleteTrail => "update_locking_config_for_delete_trail", + Self::UpdateLockingConfigForWrite => "update_locking_config_for_write", + Self::AddRecordTags => "add_record_tags", + Self::DeleteRecordTags => "delete_record_tags", + Self::AddRoles => "add_roles", + Self::UpdateRoles => "update_roles", + Self::DeleteRoles => "delete_roles", + Self::AddCapabilities => "add_capabilities", + Self::RevokeCapabilities => "revoke_capabilities", + Self::UpdateMetadata => "update_metadata", + Self::DeleteMetadata => "delete_metadata", + Self::Migrate => "migrate_audit_trail", + } + } + + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::permission::Permission")).expect("invalid TypeTag for Permission") + } + + pub(in crate::core) fn to_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let function = Identifier::from_str(self.function_name()) + .map_err(|e| Error::InvalidArgument(format!("Failed to create identifier for function: {e}")))?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("permission").as_str().into(), + function, + vec![], + vec![], + )) + } +} + +/// Convenience wrapper around a set of [`Permission`] values. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PermissionSet { + /// Permissions granted by this set. + pub permissions: HashSet, +} + +impl PermissionSet { + pub(crate) fn to_move_vec(&self, package_id: ObjectID, ptb: &mut Ptb) -> Result { + let permission_type = Permission::tag(package_id); + let permission_args: Vec<_> = self + .permissions + .iter() + .map(|permission| (*permission).to_ptb(ptb, package_id)) + .collect::, _>>()?; + + Ok(ptb.command(Command::MakeMoveVector(MakeMoveVector { + type_: Some(permission_type), + elements: permission_args, + }))) + } + /// Returns the recommended permission set for the `Admin` role. + /// + /// Includes the following permissions: + /// - [`Permission::AddCapabilities`] + /// - [`Permission::RevokeCapabilities`] + /// - [`Permission::AddRecordTags`] + /// - [`Permission::DeleteRecordTags`] + /// - [`Permission::AddRoles`] + /// - [`Permission::UpdateRoles`] + /// - [`Permission::DeleteRoles`] + /// - [`Permission::Migrate`] + /// + /// Mirrors `audit_trails::permission::admin_permissions` in the Move + /// package. This is the same set the package seeds when a trail is + /// created and the initial-admin capability is minted. + pub fn admin_permissions() -> Self { + Self { + permissions: HashSet::from([ + Permission::AddCapabilities, + Permission::RevokeCapabilities, + Permission::AddRecordTags, + Permission::DeleteRecordTags, + Permission::AddRoles, + Permission::UpdateRoles, + Permission::DeleteRoles, + Permission::Migrate, + ]), + } + } + + /// Returns the permissions needed to administer records. + /// + /// Includes the following permissions: + /// - [`Permission::AddRecord`] + /// - [`Permission::DeleteRecord`] + /// - [`Permission::CorrectRecord`] + pub fn record_admin_permissions() -> Self { + Self { + permissions: HashSet::from([ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::CorrectRecord, + ]), + } + } + + /// Returns the permissions needed to administer locking rules. + /// + /// Includes the following permissions: + /// - [`Permission::UpdateLockingConfig`] + /// - [`Permission::UpdateLockingConfigForDeleteTrail`] + /// - [`Permission::UpdateLockingConfigForDeleteRecord`] + /// - [`Permission::UpdateLockingConfigForWrite`] + pub fn locking_admin_permissions() -> Self { + Self { + permissions: HashSet::from([ + Permission::UpdateLockingConfig, + Permission::UpdateLockingConfigForDeleteTrail, + Permission::UpdateLockingConfigForDeleteRecord, + Permission::UpdateLockingConfigForWrite, + ]), + } + } + + /// Returns the permissions needed to administer roles. + /// + /// Includes the following permissions: + /// - [`Permission::AddRoles`] + /// - [`Permission::UpdateRoles`] + /// - [`Permission::DeleteRoles`] + pub fn role_admin_permissions() -> Self { + Self { + permissions: HashSet::from([Permission::AddRoles, Permission::UpdateRoles, Permission::DeleteRoles]), + } + } + + /// Returns the permissions needed to administer record tags. + /// + /// Includes the following permissions: + /// - [`Permission::AddRecordTags`] + /// - [`Permission::DeleteRecordTags`] + pub fn tag_admin_permissions() -> Self { + Self { + permissions: HashSet::from([Permission::AddRecordTags, Permission::DeleteRecordTags]), + } + } + + /// Returns the permissions needed to issue and revoke capabilities. + /// + /// Includes the following permissions: + /// - [`Permission::AddCapabilities`] + /// - [`Permission::RevokeCapabilities`] + pub fn cap_admin_permissions() -> Self { + Self { + permissions: HashSet::from_iter(vec![Permission::AddCapabilities, Permission::RevokeCapabilities]), + } + } + + /// Returns the permissions needed to administer mutable metadata. + /// + /// Includes the following permissions: + /// - [`Permission::UpdateMetadata`] + /// - [`Permission::DeleteMetadata`] + pub fn metadata_admin_permissions() -> Self { + Self { + permissions: HashSet::from_iter(vec![Permission::UpdateMetadata, Permission::DeleteMetadata]), + } + } +} diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs new file mode 100644 index 00000000..e3ce1a49 --- /dev/null +++ b/audit-trail-rs/src/core/types/record.rs @@ -0,0 +1,302 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, HashSet}; +use std::str::FromStr; + +use iota_interaction::ident_str; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, TypeTag}; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::Argument; +use serde::{Deserialize, Serialize}; + +use crate::core::internal::tx; +use crate::error::Error; + +/// Page of records loaded through linked-table traversal. +#[derive(Debug, Clone)] +pub struct PaginatedRecord { + /// Records included in the current page, keyed by sequence number. + pub records: BTreeMap>, + /// Cursor to pass to the next [`TrailRecords::list_page`](crate::core::records::TrailRecords::list_page) call. + pub next_cursor: Option, + /// Indicates whether another page may be available. + pub has_next_page: bool, +} + +/// A single record in the audit trail. +/// +/// Records form a tamper-evident, sequential chain: each record receives a monotonically increasing +/// sequence number that is never reused, even after the record is deleted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + /// Record payload stored on-chain. + pub data: D, + /// Optional application-defined metadata. + pub metadata: Option, + /// Optional trail-owned tag attached to the record. + pub tag: Option, + /// Monotonic record sequence number inside the trail. + pub sequence_number: u64, + /// Address that added the record. + pub added_by: IotaAddress, + /// Millisecond timestamp at which the record was added. + pub added_at: u64, + /// Correction relationships for this record. + pub correction: RecordCorrection, +} + +/// Input used when creating a trail with an initial record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InitialRecord { + /// Initial payload to store in the trail. + pub data: D, + /// Optional application-defined metadata. + pub metadata: Option, + /// Optional initial tag from the trail-owned registry. + pub tag: Option, +} + +impl InitialRecord { + /// Creates a new initial record. + /// + /// # Examples + /// + /// ```rust + /// use audit_trails::core::types::{Data, InitialRecord}; + /// + /// let record = InitialRecord::new( + /// Data::text("hello"), + /// Some("seed".to_string()), + /// Some("inbox".to_string()), + /// ); + /// + /// assert_eq!(record.data, Data::text("hello")); + /// assert_eq!(record.metadata.as_deref(), Some("seed")); + /// assert_eq!(record.tag.as_deref(), Some("inbox")); + /// ``` + pub fn new(data: impl Into, metadata: Option, tag: Option) -> Self { + Self { + data: data.into(), + metadata, + tag, + } + } + + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!( + "{package_id}::record::InitialRecord<{}>", + Data::tag(package_id) + )) + .expect("invalid TypeTag for InitialRecord") + } + + pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let data_tag = Data::tag(package_id); + let data = self.data.into_ptb(ptb, package_id)?; + let metadata = tx::ptb_pure(ptb, "initial_record_metadata", self.metadata)?; + let tag = tx::ptb_pure(ptb, "initial_record_tag", self.tag)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record").as_str().into(), + ident_str!("new_initial_record").as_str().into(), + vec![data_tag], + vec![data, metadata, tag], + )) + } +} + +/// Bidirectional correction tracking for audit records. +/// +/// `replaces` is fixed at creation and lists the sequence numbers this record supersedes; +/// `is_replaced_by` is a back-pointer the trail sets later when *this* record itself is corrected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RecordCorrection { + /// Sequence numbers that this record supersedes. + pub replaces: HashSet, + /// Sequence number of the record that supersedes this one, if any. + pub is_replaced_by: Option, +} + +impl RecordCorrection { + /// Creates a correction value that replaces the given sequence numbers. + pub fn with_replaces(replaces: HashSet) -> Self { + Self { + replaces, + is_replaced_by: None, + } + } + + /// Returns `true` when this record supersedes at least one earlier record. + /// + /// # Examples + /// + /// ```rust + /// use std::collections::HashSet; + /// + /// use audit_trails::core::types::RecordCorrection; + /// + /// let correction = RecordCorrection::with_replaces(HashSet::from([1, 2])); + /// + /// assert!(correction.is_correction()); + /// ``` + pub fn is_correction(&self) -> bool { + !self.replaces.is_empty() + } + + /// Returns `true` when this record has itself been replaced by a later record. + pub fn is_replaced(&self) -> bool { + self.is_replaced_by.is_some() + } +} + +/// Supported record data types. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Data { + /// Arbitrary binary payload. + Bytes(Vec), + /// UTF-8 text payload. + Text(String), +} + +impl Data { + /// Returns the Move type tag for `record::Data`. + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::record::Data")).expect("should be valid type tag") + } + + /// Creates a PTB argument for `record::Data`. + pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + match self { + Data::Bytes(bytes) => { + let bytes = tx::ptb_pure(ptb, "data_bytes", bytes)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record").as_str().into(), + ident_str!("new_bytes").as_str().into(), + vec![], + vec![bytes], + )) + } + Data::Text(text) => { + let text = tx::ptb_pure(ptb, "data_text", text)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record").as_str().into(), + ident_str!("new_text").as_str().into(), + vec![], + vec![text], + )) + } + } + } + + /// Validates that the on-chain trail stores `record::Data`. + pub(in crate::core) fn ensure_matches_tag(&self, expected: &TypeTag, package_id: ObjectID) -> Result<(), Error> { + let actual = Self::tag(package_id); + + if &actual == expected { + Ok(()) + } else { + Err(Error::InvalidArgument(format!( + "record data type mismatch: trail expects {:?}, client writes {:?}", + expected, actual + ))) + } + } + + /// Creates a new `Data` from bytes. + /// + /// # Examples + /// + /// ```rust + /// use audit_trails::core::types::Data; + /// + /// assert_eq!(Data::bytes([1_u8, 2, 3]), Data::Bytes(vec![1, 2, 3])); + /// ``` + pub fn bytes(data: impl Into>) -> Self { + Self::Bytes(data.into()) + } + + /// Creates a new `Data` from text. + /// + /// # Examples + /// + /// ```rust + /// use audit_trails::core::types::Data; + /// + /// assert_eq!(Data::text("hello"), Data::Text("hello".to_string())); + /// ``` + pub fn text(data: impl Into) -> Self { + Self::Text(data.into()) + } + + /// Extracts the data as bytes. + /// + /// ## Errors + /// + /// Returns an error if the data is text rather than bytes. + pub fn as_bytes(self) -> Result, Error> { + match self { + Data::Bytes(data) => Ok(data), + Data::Text(_) => Err(Error::GenericError("Data is not bytes".to_string())), + } + } + + /// Extracts the data as text. + /// + /// ## Errors + /// + /// Returns an error if the data is bytes rather than text. + pub fn as_text(self) -> Result { + match self { + Data::Bytes(_) => Err(Error::GenericError("Data is not text".to_string())), + Data::Text(data) => Ok(data), + } + } +} + +impl From for Data { + fn from(value: String) -> Self { + Data::Text(value) + } +} + +impl From<&str> for Data { + fn from(value: &str) -> Self { + Data::Text(value.to_string()) + } +} + +impl From> for Data { + fn from(value: Vec) -> Self { + Data::Bytes(value) + } +} + +impl From<&[u8]> for Data { + fn from(value: &[u8]) -> Self { + Data::Bytes(value.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::Data; + + #[test] + fn data_bcs_roundtrip_preserves_text_variant() { + let encoded = bcs::to_bytes(&Data::Text("hello world".to_string())).expect("failed to encode Data"); + let data = bcs::from_bytes::(&encoded).expect("failed to decode Data"); + assert_eq!(data, Data::Text("hello world".to_string())); + } + + #[test] + fn data_bcs_roundtrip_preserves_bytes_variant() { + let encoded = + bcs::to_bytes(&Data::Bytes(vec![0x47, 0x49, 0x46, 0x38, 0x39, 0x61])).expect("failed to encode Data"); + let data = bcs::from_bytes::(&encoded).expect("failed to decode Data"); + assert_eq!(data, Data::Bytes(vec![0x47, 0x49, 0x46, 0x38, 0x39, 0x61])); + } +} diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs new file mode 100644 index 00000000..3a00ec5f --- /dev/null +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -0,0 +1,232 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use iota_interaction::types::base_types::{IotaAddress, ObjectID, TypeTag}; +use iota_interaction::types::collection_types::LinkedTable; +use iota_interaction::types::id::UID; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::Argument; +use iota_interaction::{MoveType, ident_str}; +use serde::{Deserialize, Serialize}; +use serde_aux::field_attributes::deserialize_option_number_from_string; + +use super::permission::Permission; +use crate::core::internal::move_collections::{deserialize_vec_map, deserialize_vec_set}; +use crate::core::internal::tx; +use crate::error::Error; + +/// Role and capability configuration stored on a trail. +/// +/// This mirrors the access-control state maintained by the Move package, including the reserved initial-admin +/// role, the revoked-capability denylist, and the role data used for tag-aware authorization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleMap { + /// Trail object ID that this role map protects. + pub target_key: ObjectID, + /// Role definitions keyed by role name. + #[serde(deserialize_with = "deserialize_vec_map")] + pub roles: HashMap, + /// Reserved role name used for initial-admin capabilities. Always equals `"Admin"` (matching the + /// Move `INITIAL_ADMIN_ROLE_NAME` constant). The role bearing this name cannot be deleted. + pub initial_admin_role_name: String, + /// Denylist of revoked capability IDs. + pub revoked_capabilities: LinkedTable, + /// Capability IDs currently recognized as initial-admin capabilities. + #[serde(deserialize_with = "deserialize_vec_set")] + pub initial_admin_cap_ids: HashSet, + /// Permissions required to administer roles. + pub role_admin_permissions: RoleAdminPermissions, + /// Permissions required to administer capabilities. + pub capability_admin_permissions: CapabilityAdminPermissions, +} + +/// Role definition stored in the trail role map. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Role { + /// Permissions granted by the role. + #[serde(deserialize_with = "deserialize_vec_set")] + pub permissions: HashSet, + /// Optional role-scoped record-tag restrictions. + pub data: Option, +} + +/// Permissions required to administer roles in the trail's access-control state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleAdminPermissions { + /// Permission required to create roles. + pub add: Permission, + /// Permission required to delete roles. + pub delete: Permission, + /// Permission required to update roles. + pub update: Permission, +} + +/// Permissions required to administer capabilities in the trail's access-control state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityAdminPermissions { + /// Permission required to issue capabilities. + pub add: Permission, + /// Permission required to revoke capabilities. + pub revoke: Permission, +} + +/// Capability issuance options used by the role-based API. +/// +/// These fields only configure restrictions on the issued capability object. Matching against the current +/// caller and timestamp happens when the capability is later used. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityIssueOptions { + /// Address that should own the capability, if any. + pub issued_to: Option, + /// Millisecond timestamp at which the capability becomes valid. + pub valid_from_ms: Option, + /// Millisecond timestamp at which the capability expires. + pub valid_until_ms: Option, +} + +/// Allowlisted record tags stored as role data. +/// +/// Every tag listed here must already exist in the trail's +/// [`TagRegistry`](super::audit_trail::TagRegistry) before the role is created or updated; +/// otherwise the on-chain call aborts with `ERecordTagNotDefined`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RoleTags { + /// Allowlisted record tags for the role. + #[serde(deserialize_with = "deserialize_vec_set")] + pub tags: HashSet, +} + +impl RoleTags { + /// Creates role-tag restrictions from an iterator of tag names. + /// + /// The set is deduplicated, and PTB encoding later sorts the tags for deterministic serialization. + /// Every tag listed here must already exist in the trail's [`TagRegistry`](super::audit_trail::TagRegistry) + /// before the role is created or updated; otherwise the on-chain call aborts with + /// `ERecordTagNotDefined`. + pub fn new(tags: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + tags: tags.into_iter().map(Into::into).collect(), + } + } + + /// Returns `true` when the given tag is allowed for the role. + pub fn allows(&self, tag: &str) -> bool { + self.tags.contains(tag) + } + + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::record_tags::RoleTags")).expect("invalid TypeTag for RoleTags") + } + + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let mut tags = self.tags.iter().cloned().collect::>(); + tags.sort(); + let tags_arg = tx::ptb_pure(ptb, "tags", tags)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record_tags").as_str().into(), + ident_str!("new_role_tags").as_str().into(), + vec![], + vec![tags_arg], + )) + } +} + +/// Capability data returned by the Move capability module. +/// +/// A capability grants exactly one role against exactly one trail and may additionally restrict who may use it +/// and during which time window it is valid. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Capability { + /// Capability object ID. + pub id: UID, + /// Trail object ID protected by the capability. + pub target_key: ObjectID, + /// Role granted by the capability. + pub role: String, + /// Address bound to the capability. When `None`, any holder may present the capability for + /// authorization. + pub issued_to: Option, + /// Earliest millisecond timestamp (since the Unix epoch, inclusive) at which the capability is + /// valid. When `None`, the capability is valid from its creation time. + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub valid_from: Option, + /// Latest millisecond timestamp (since the Unix epoch, inclusive) at which the capability is + /// still valid. When `None`, the capability does not expire. + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub valid_until: Option, +} + +impl Capability { + pub(crate) fn type_tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(format!("{package_id}::capability::Capability").as_str()).expect("failed to create type tag") + } + + pub(crate) fn matches_target_and_role(&self, trail_id: ObjectID, valid_roles: &HashSet) -> bool { + self.target_key == trail_id && valid_roles.contains(&self.role) + } +} + +impl MoveType for Capability { + fn move_type(package: ObjectID) -> TypeTag { + Self::type_tag(package) + } +} + +#[cfg(test)] +mod tests { + use iota_interaction::types::base_types::{IotaAddress, dbg_object_id}; + use iota_interaction::types::id::UID; + use serde_json::json; + + use super::Capability; + + #[test] + fn capability_deserializes_string_encoded_time_constraints() { + let issued_to = IotaAddress::random(); + let capability = Capability { + id: UID::new(dbg_object_id(1)), + target_key: dbg_object_id(2), + role: "Writer".to_string(), + issued_to: Some(issued_to), + valid_from: None, + valid_until: None, + }; + + let mut value = serde_json::to_value(capability).expect("capability serializes"); + value["valid_from"] = json!("1700000000000"); + value["valid_until"] = json!("1700000005000"); + + let decoded: Capability = serde_json::from_value(value).expect("capability deserializes"); + + assert_eq!(decoded.valid_from, Some(1_700_000_000_000)); + assert_eq!(decoded.valid_until, Some(1_700_000_005_000)); + assert_eq!(decoded.issued_to, Some(issued_to)); + } + + #[test] + fn capability_deserializes_absent_time_constraints() { + let capability = Capability { + id: UID::new(dbg_object_id(4)), + target_key: dbg_object_id(5), + role: "Writer".to_string(), + issued_to: None, + valid_from: None, + valid_until: None, + }; + + let value = serde_json::to_value(capability).expect("capability serializes"); + let decoded: Capability = serde_json::from_value(value).expect("capability deserializes"); + + assert_eq!(decoded.valid_from, None); + assert_eq!(decoded.valid_until, None); + } +} diff --git a/audit-trail-rs/src/error.rs b/audit-trail-rs/src/error.rs new file mode 100644 index 00000000..6aa33505 --- /dev/null +++ b/audit-trail-rs/src/error.rs @@ -0,0 +1,50 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Error types returned by the audit-trail public API. + +use crate::iota_interaction_adapter::AdapterError; + +/// Errors that can occur when reading or mutating an `AuditTrail` object. +#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] +#[non_exhaustive] +pub enum Error { + /// Returned when a signer key or public key cannot be derived or validated. + #[error("invalid key: {0}")] + InvalidKey(String), + /// Returned when client configuration or package-ID configuration is invalid. + #[error("invalid config: {0}")] + InvalidConfig(String), + /// Returned when an RPC request fails. + #[error("RPC error: {0}")] + RpcError(String), + /// Error returned by the underlying IOTA client adapter. + #[error("IOTA client error: {0}")] + IotaClient(#[from] AdapterError), + /// Generic catch-all error for crate-specific failures that do not fit a narrower variant. + #[error("{0}")] + GenericError(String), + /// Placeholder for unimplemented API surface. + #[error("not implemented: {0}")] + NotImplemented(&'static str), + /// Returned when a Move tag cannot be parsed. + #[error("Failed to parse tag: {0}")] + FailedToParseTag(String), + /// Returned when an argument is semantically invalid. + #[error("Invalid argument: {0}")] + InvalidArgument(String), + /// The response from the IOTA node API was not in the expected format. + #[error("unexpected API response: {0}")] + UnexpectedApiResponse(String), + /// Failed to deserialize data using BCS. + #[error("BCS deserialization error: {0}")] + DeserializationError(#[from] bcs::Error), + /// The transaction response from the IOTA node API was not in the expected format. + #[error("unexpected transaction response: {0}")] + TransactionUnexpectedResponse(String), +} + +#[cfg(target_arch = "wasm32")] +use product_common::impl_wasm_error_from; +#[cfg(target_arch = "wasm32")] +impl_wasm_error_from!(Error); diff --git a/audit-trail-rs/src/iota_interaction_adapter.rs b/audit-trail-rs/src/iota_interaction_adapter.rs new file mode 100644 index 00000000..c2db171b --- /dev/null +++ b/audit-trail-rs/src/iota_interaction_adapter.rs @@ -0,0 +1,12 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Platform-dependent adapter re-exports for the underlying IOTA interaction layer. +//! +//! This keeps the rest of the crate generic over native and wasm targets by exposing the same +//! adapter names from either `iota_interaction_rust` or `iota_interaction_ts`. + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use iota_interaction_rust::*; +#[cfg(target_arch = "wasm32")] +pub(crate) use iota_interaction_ts::*; diff --git a/audit-trail-rs/src/lib.rs b/audit-trail-rs/src/lib.rs new file mode 100644 index 00000000..82f6f73e --- /dev/null +++ b/audit-trail-rs/src/lib.rs @@ -0,0 +1,22 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![doc = include_str!("../README.md")] +#![warn(missing_docs, rustdoc::all)] + +/// Client wrappers for read-only and signing access to audit trails. +pub mod client; +/// Core handles, builders, transactions, and domain types. +pub mod core; +/// Error types returned by the public API. +pub mod error; +pub(crate) mod iota_interaction_adapter; +pub(crate) mod package; + +/// A signing audit-trail client that can build write transactions. +pub use client::full_client::AuditTrailClient; +/// Read-only client types and package override configuration. +pub use client::read_only::{AuditTrailClientReadOnly, PackageOverrides}; +/// HTTP utilities to implement the trait [HttpClient](product_common::http_client::HttpClient). +#[cfg(feature = "gas-station")] +pub use product_common::http_client; diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs new file mode 100644 index 00000000..19f05ece --- /dev/null +++ b/audit-trail-rs/src/package.rs @@ -0,0 +1,156 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Package management for Audit Trails smart contracts. +//! +//! This module handles package ID resolution and registry management +//! for the Audit Trails Move Package. + +#![allow(dead_code)] + +use std::sync::LazyLock; + +use iota_interaction::types::base_types::ObjectID; +use product_common::network_name::NetworkName; +use product_common::package_registry::{Env, PackageRegistry}; +use product_common::tf_components_registry; +use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard, TryLockError}; + +use crate::client::PackageOverrides; +use crate::error::Error; + +type PackageRegistryLock = RwLockReadGuard<'static, PackageRegistry>; +type PackageRegistryLockMut = RwLockWriteGuard<'static, PackageRegistry>; + +/// Global registry for Audit Trails Package information. +static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLock::new(|| { + let package_history_json = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../audit-trail-move/Move.history.json" + )); + RwLock::new( + PackageRegistry::from_package_history_json_str(package_history_json) + .expect("Move.history.json exists and it's valid"), + ) +}); + +/// Returns a read lock to the package registry. +pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { + AUDIT_TRAIL_PACKAGE_REGISTRY.read().await +} + +/// Attempts to acquire a read lock without blocking. +pub(crate) fn try_audit_trail_package_registry() -> Result { + AUDIT_TRAIL_PACKAGE_REGISTRY.try_read() +} + +/// Returns a blocking read lock to the package registry. +pub(crate) fn blocking_audit_trail_registry() -> PackageRegistryLock { + AUDIT_TRAIL_PACKAGE_REGISTRY.blocking_read() +} + +/// Returns a write lock to the package registry. +pub(crate) async fn audit_trail_package_registry_mut() -> PackageRegistryLockMut { + AUDIT_TRAIL_PACKAGE_REGISTRY.write().await +} + +/// Attempts to acquire a write lock without blocking. +pub(crate) fn try_audit_trail_package_registry_mut() -> Result { + AUDIT_TRAIL_PACKAGE_REGISTRY.try_write() +} + +/// Returns a blocking write lock to the package registry. +pub(crate) fn blocking_audit_trail_registry_mut() -> PackageRegistryLockMut { + AUDIT_TRAIL_PACKAGE_REGISTRY.blocking_write() +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ResolvedPackageIds { + pub audit_trail_package_id: ObjectID, + pub tf_components_package_id: ObjectID, +} + +pub(crate) async fn resolve_package_ids( + network: &NetworkName, + package_overrides: &PackageOverrides, +) -> Result<(NetworkName, ResolvedPackageIds), Error> { + let chain_id = network.as_ref().to_string(); + let package_registry = audit_trail_package_registry().await; + let audit_trail_package_id = package_overrides + .audit_trail + .or_else(|| package_registry.package_id(network)) + .ok_or_else(|| { + Error::InvalidConfig(format!( + "no information for a published `IotaAuditTrails` package on network {network}; try to use `AuditTrailClientReadOnly::new_with_package_overrides`" + )) + })?; + let resolved_network = match chain_id.as_str() { + product_common::package_registry::MAINNET_CHAIN_ID => { + NetworkName::try_from("iota").expect("valid network name") + } + _ => package_registry + .chain_alias(&chain_id) + .and_then(|alias| NetworkName::try_from(alias).ok()) + .unwrap_or_else(|| network.clone()), + }; + + drop(package_registry); + + if let Some(audit_trail_package_id) = package_overrides.audit_trail { + let env = Env::new_with_alias(chain_id.clone(), resolved_network.as_ref()); + audit_trail_package_registry_mut() + .await + .insert_env_history(env, vec![audit_trail_package_id]); + } + let tf_components_package_id = package_overrides + .tf_component + .or_else(|| tf_components_registry::tf_components_package_id(resolved_network.as_ref())) + .ok_or_else(|| { + Error::InvalidConfig(format!( + "no information for a published `TfComponents` package on network {network}; try to use `AuditTrailClientReadOnly::new_with_package_overrides`" + )) + })?; + + Ok(( + resolved_network, + ResolvedPackageIds { + audit_trail_package_id, + tf_components_package_id, + }, + )) +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[tokio::test] + async fn resolves_tf_components_package_id() { + let network = NetworkName::try_from("testnet").expect("valid network"); + let registry_package_id = tf_components_registry::tf_components_package_id("testnet") + .expect("testnet TfComponents package is in the registry"); + let override_package_id = ObjectID::random(); + + let (_, registry_resolved_package_ids) = resolve_package_ids(&network, &PackageOverrides::default()) + .await + .expect("registered package IDs are valid"); + + assert_eq!( + registry_resolved_package_ids.tf_components_package_id, + registry_package_id + ); + + let (_, resolved_package_ids) = resolve_package_ids( + &network, + &PackageOverrides { + audit_trail: Some(ObjectID::random()), + tf_component: Some(override_package_id), + }, + ) + .await + .expect("explicit package overrides are valid"); + + assert_eq!(resolved_package_ids.tf_components_package_id, override_package_id); + } +} diff --git a/audit-trail-rs/tests/e2e/access.rs b/audit-trail-rs/tests/e2e/access.rs new file mode 100644 index 00000000..00713565 --- /dev/null +++ b/audit-trail-rs/tests/e2e/access.rs @@ -0,0 +1,586 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RoleTags}; +use iota_interaction::types::base_types::IotaAddress; +use product_common::core_client::CoreClient; + +use crate::client::get_funded_test_client; + +#[tokio::test] +async fn create_role_then_issue_capability_default_options() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let role_name = "auditor"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let issued = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + + assert_eq!(issued.target_key, trail_id); + assert_eq!(issued.role, role_name.to_string()); + assert_eq!(issued.issued_to, None); + assert_eq!(issued.valid_from, None); + assert_eq!(issued.valid_until, None); + + Ok(()) +} + +#[tokio::test] +async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); + let role_name = "editor"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let updated = access + .for_role(role_name) + .update_permissions( + PermissionSet { + permissions: HashSet::from([Permission::AddRecord, Permission::DeleteRecord]), + }, + None, + ) + .build_and_execute(&client) + .await? + .output; + assert_eq!(updated.trail_id, trail_id); + assert_eq!(updated.role, role_name.to_string()); + assert_eq!( + updated.permissions.permissions, + HashSet::from([Permission::AddRecord, Permission::DeleteRecord]) + ); + assert_eq!(updated.data, None); + assert_eq!(updated.updated_by, client.sender_address()); + assert!(updated.timestamp > 0); + + let issued = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + assert_eq!(issued.target_key, trail_id); + assert_eq!(issued.role, role_name.to_string()); + + Ok(()) +} + +#[tokio::test] +async fn delegated_role_and_capability_admins_can_enable_record_writes() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let role_admin = get_funded_test_client().await?; + let cap_admin = get_funded_test_client().await?; + let record_admin = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("delegated-access-flow")).await?; + + admin + .create_role( + trail_id, + "RoleAdmin", + PermissionSet::role_admin_permissions().permissions, + None, + ) + .await?; + admin + .create_role( + trail_id, + "CapAdmin", + PermissionSet::cap_admin_permissions().permissions, + None, + ) + .await?; + admin + .issue_cap( + trail_id, + "RoleAdmin", + CapabilityIssueOptions { + issued_to: Some(role_admin.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + admin + .issue_cap( + trail_id, + "CapAdmin", + CapabilityIssueOptions { + issued_to: Some(cap_admin.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + role_admin + .create_role( + trail_id, + "RecordAdmin", + PermissionSet::record_admin_permissions().permissions, + None, + ) + .await?; + cap_admin + .issue_cap( + trail_id, + "RecordAdmin", + CapabilityIssueOptions { + issued_to: Some(record_admin.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let added = record_admin + .trail(trail_id) + .records() + .add(Data::text("delegated write"), None, None) + .build_and_execute(&record_admin) + .await? + .output; + + assert_eq!(added.trail_id, trail_id); + assert_eq!(added.sequence_number, 1); + + let record = admin.trail(trail_id).records().get(1).await?; + assert_eq!(record.sequence_number, 1); + assert_eq!(record.added_by, record_admin.sender_address()); + assert_eq!(record.data, Data::text("delegated write")); + + Ok(()) +} + +#[tokio::test] +async fn create_role_rejects_undefined_role_tags() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("roles-undefined-create"), ["legal"]) + .await?; + + let created = client + .create_role( + trail_id, + "tagged-writer", + vec![Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await; + + assert!( + created.is_err(), + "creating a role with tags outside the trail registry must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn update_role_permissions_rejects_undefined_role_tags() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("roles-undefined-update"), ["legal"]) + .await?; + let access = client.trail(trail_id).access(); + let role_name = "editor"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let updated = access + .for_role(role_name) + .update_permissions( + PermissionSet { + permissions: HashSet::from([Permission::AddRecord]), + }, + Some(RoleTags::new(["finance"])), + ) + .build_and_execute(&client) + .await; + + assert!( + updated.is_err(), + "updating a role with tags outside the trail registry must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn issue_capability_for_nonexistent_role_fails() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("missing-role-cap")).await?; + + let issued = client + .trail(trail_id) + .access() + .for_role("NonExistentRole") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await; + + assert!(issued.is_err(), "issuing a capability for a missing role must fail"); + + Ok(()) +} + +#[tokio::test] +async fn issue_capability_requires_add_capabilities_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let operator = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("missing-cap-permission")).await?; + + admin + .create_role(trail_id, "NoCapPerm", vec![Permission::AddRecord], None) + .await?; + admin + .create_role( + trail_id, + "RecordAdmin", + PermissionSet::record_admin_permissions().permissions, + None, + ) + .await?; + admin + .issue_cap( + trail_id, + "NoCapPerm", + CapabilityIssueOptions { + issued_to: Some(operator.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let issued = operator + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&operator) + .await; + + assert!( + issued.is_err(), + "issuing a capability without AddCapabilities permission must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn revoke_capability_requires_revoke_capabilities_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let no_revoke = get_funded_test_client().await?; + let target = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("missing-revoke-permission")).await?; + + admin + .create_role(trail_id, "NoRevokePerm", vec![Permission::AddRecord], None) + .await?; + admin + .create_role( + trail_id, + "RecordAdmin", + PermissionSet::record_admin_permissions().permissions, + None, + ) + .await?; + admin + .issue_cap( + trail_id, + "NoRevokePerm", + CapabilityIssueOptions { + issued_to: Some(no_revoke.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + let target_cap = admin + .issue_cap( + trail_id, + "RecordAdmin", + CapabilityIssueOptions { + issued_to: Some(target.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let revoked = no_revoke + .trail(trail_id) + .access() + .revoke_capability(target_cap.capability_id, target_cap.valid_until) + .build_and_execute(&no_revoke) + .await; + + assert!( + revoked.is_err(), + "revoking a capability without RevokeCapabilities permission must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); + let role_name = "to-delete"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + let deleted = access + .for_role(role_name) + .delete() + .build_and_execute(&client) + .await? + .output; + assert_eq!(deleted.trail_id, trail_id); + assert_eq!(deleted.role, role_name.to_string()); + assert_eq!(deleted.deleted_by, client.sender_address()); + assert!(deleted.timestamp > 0); + + let issue_tx = access + .for_role(role_name) + .issue_capability(CapabilityIssueOptions::default()); + let issue_after_delete = issue_tx.build_and_execute(&client).await; + assert!( + issue_after_delete.is_err(), + "issuing a capability for a deleted role must fail" + ); + Ok(()) +} + +#[tokio::test] +async fn issue_capability_with_constraints() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let role_name = "reviewer"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let issued_to = IotaAddress::random(); + let constrained = CapabilityIssueOptions { + issued_to: Some(issued_to), + valid_from_ms: Some(1_700_000_000_000), + valid_until_ms: Some(1_700_000_001_000), + }; + + let issued = client.issue_cap(trail_id, role_name, constrained.clone()).await?; + + assert_eq!(issued.target_key, trail_id); + assert_eq!(issued.role, role_name.to_string()); + assert_eq!(issued.issued_to, constrained.issued_to); + assert_eq!(issued.valid_from, constrained.valid_from_ms); + assert_eq!(issued.valid_until, constrained.valid_until_ms); + + Ok(()) +} + +#[tokio::test] +async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); + let role_name = "revoker"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let issued = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + + let revoked = access + .revoke_capability(issued.capability_id, issued.valid_until) + .build_and_execute(&client) + .await? + .output; + assert_eq!(revoked.target_key, trail_id); + assert_eq!(revoked.capability_id, issued.capability_id); + assert_eq!(revoked.valid_until, 0); + + Ok(()) +} + +#[tokio::test] +async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); + let role_name = "destroyer"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let issued = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + + let destroyed = access + .destroy_capability(issued.capability_id) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(destroyed.target_key, trail_id); + assert_eq!(destroyed.capability_id, issued.capability_id); + assert_eq!(destroyed.role, role_name.to_string()); + assert_eq!(destroyed.issued_to, None); + assert_eq!(destroyed.valid_from, None); + assert_eq!(destroyed.valid_until, None); + + Ok(()) +} + +#[tokio::test] +async fn destroy_initial_admin_capability_emits_expected_event() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); + + let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; + let admin_cap_id = admin_cap_ref.object_id; + + let destroyed = access + .destroy_initial_admin_capability(admin_cap_id) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(destroyed.target_key, trail_id); + assert_eq!(destroyed.capability_id, admin_cap_id); + assert_eq!(destroyed.role, "Admin".to_string()); + assert_eq!(destroyed.issued_to, None); + assert_eq!(destroyed.valid_from, None); + assert_eq!(destroyed.valid_until, None); + + Ok(()) +} + +#[tokio::test] +async fn revoke_initial_admin_capability_emits_expected_event() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + + // Issue a second admin capability so we can use the original to revoke it + let second_admin = client + .issue_cap(trail_id, "Admin", CapabilityIssueOptions::default()) + .await?; + + let access = client.trail(trail_id).access(); + let revoked = access + .revoke_initial_admin_capability(second_admin.capability_id, second_admin.valid_until) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(revoked.target_key, trail_id); + assert_eq!(revoked.capability_id, second_admin.capability_id); + assert_eq!(revoked.valid_until, 0); + + Ok(()) +} + +#[tokio::test] +async fn regular_destroy_rejects_initial_admin_capability() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); + + let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; + let admin_cap_id = admin_cap_ref.object_id; + + let result = access.destroy_capability(admin_cap_id).build_and_execute(&client).await; + + assert!( + result.is_err(), + "destroying an initial admin cap via regular destroy_capability must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn regular_revoke_rejects_initial_admin_capability() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); + + let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; + let admin_cap_id = admin_cap_ref.object_id; + + let result = access + .revoke_capability(admin_cap_id, None) + .build_and_execute(&client) + .await; + + assert!( + result.is_err(), + "revoking an initial admin cap via regular revoke_capability must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn cleanup_revoked_capabilities_removes_expired_entries() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); + let role_name = "cleanup-target"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let issued = client + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: None, + valid_from_ms: None, + valid_until_ms: Some(1), + }, + ) + .await?; + + access + .revoke_capability(issued.capability_id, issued.valid_until) + .build_and_execute(&client) + .await?; + + let trail = client.trail(trail_id); + let before_cleanup = trail.get().await?; + assert_eq!(before_cleanup.roles.revoked_capabilities.size, 1); + + let cleaned = access + .cleanup_revoked_capabilities() + .build_and_execute(&client) + .await? + .output; + + assert_eq!(cleaned.trail_id, trail_id); + assert_eq!(cleaned.cleaned_count, 1); + assert_eq!(cleaned.cleaned_by, client.sender_address()); + assert!(cleaned.timestamp > 0); + + let after_cleanup = trail.get().await?; + assert_eq!(after_cleanup.roles.revoked_capabilities.size, 0); + + Ok(()) +} diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs new file mode 100644 index 00000000..22ce8458 --- /dev/null +++ b/audit-trail-rs/tests/e2e/client.rs @@ -0,0 +1,302 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; +use std::ops::Deref; +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{Context, anyhow}; +use audit_trails::core::types::{ + Capability, CapabilityIssueOptions, CapabilityIssued, Data, InitialRecord, Permission, PermissionSet, RoleCreated, + RoleTags, +}; +use audit_trails::{AuditTrailClient, PackageOverrides}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; +use iota_interaction::types::crypto::PublicKey; +use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClient, IotaClientBuilder}; +use iota_interaction_rust::IotaClientAdapter; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::network_name::NetworkName; +use product_common::test_utils::{InMemSigner, request_funds}; +use tokio::fs; +use tokio::process::Command; +use tokio::sync::OnceCell; + +static PACKAGE_IDS: OnceCell = OnceCell::const_new(); + +/// Script file for publishing the package. +pub const PUBLISH_SCRIPT_FILE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../audit-trail-move/scripts/publish_package.sh" +); + +const CACHED_PKG_FILE: &str = "/tmp/audit_trail_pkg_ids.txt"; + +#[derive(Clone, Copy)] +struct PublishedPackageIds { + audit_trail_package_id: ObjectID, + tf_components_package_id: Option, +} + +pub async fn get_funded_test_client() -> anyhow::Result { + TestClient::new().await +} + +async fn load_cached_package_ids(chain_id: &str) -> anyhow::Result { + let cache = fs::read_to_string(CACHED_PKG_FILE).await?; + let mut parts = cache.trim().split(';'); + let audit_trail_package_id = parts + .next() + .ok_or_else(|| anyhow!("missing IotaAuditTrails package ID in cache"))?; + let tf_components_package_id = parts.next().unwrap_or_default(); + let cached_chain_id = parts.next().ok_or_else(|| anyhow!("missing chain ID in cache"))?; + + if cached_chain_id != chain_id { + anyhow::bail!("cached package IDs belong to a different chain"); + } + + Ok(PublishedPackageIds { + audit_trail_package_id: ObjectID::from_str(audit_trail_package_id) + .context("failed to parse cached IotaAuditTrails package ID")?, + tf_components_package_id: if tf_components_package_id.is_empty() { + None + } else { + Some( + ObjectID::from_str(tf_components_package_id) + .context("failed to parse cached TfComponents package ID")?, + ) + }, + }) +} + +async fn publish_package_ids(iota_client: &IotaClient) -> anyhow::Result { + let chain_id = iota_client + .read_api() + .get_chain_identifier() + .await + .map_err(|e| anyhow!(e.to_string()))?; + + if let Ok(ids) = load_cached_package_ids(&chain_id).await { + return Ok(ids); + } + + let output = Command::new("bash") + .arg(PUBLISH_SCRIPT_FILE) + .output() + .await + .context("failed to execute publish_package.sh")?; + + let stdout = std::str::from_utf8(&output.stdout).context("publish script stdout is not valid utf-8")?; + + if !output.status.success() { + let stderr = std::str::from_utf8(&output.stderr).context("publish script stderr is not valid utf-8")?; + anyhow::bail!("failed to publish move package: \n\n{stdout}\n\n{stderr}"); + } + + let mut audit_trail_package_id = None; + let mut tf_components_package_id = None; + + for line in stdout.lines() { + let Some(exported) = line.strip_prefix("export ") else { + continue; + }; + let Some((key, value)) = exported.split_once('=') else { + continue; + }; + + match key { + "IOTA_AUDIT_TRAIL_PKG_ID" => { + let package_id = + ObjectID::from_str(value).context("failed to parse published IotaAuditTrails package ID")?; + audit_trail_package_id = Some(package_id); + } + "IOTA_TF_COMPONENTS_PKG_ID" => { + let package_id = + ObjectID::from_str(value).context("failed to parse published TfComponents package ID")?; + tf_components_package_id = Some(package_id); + } + _ => {} + } + } + + let ids = PublishedPackageIds { + audit_trail_package_id: audit_trail_package_id + .ok_or_else(|| anyhow!("publish script did not expose IOTA_AUDIT_TRAIL_PKG_ID"))?, + tf_components_package_id, + }; + + fs::write( + CACHED_PKG_FILE, + format!( + "{};{};{}", + ids.audit_trail_package_id, + ids.tf_components_package_id + .map(|package_id| package_id.to_string()) + .unwrap_or_default(), + chain_id + ), + ) + .await + .context("failed to write cached package IDs")?; + + Ok(ids) +} + +#[derive(Clone)] +pub struct TestClient { + client: Arc>, +} + +impl Deref for TestClient { + type Target = AuditTrailClient; + fn deref(&self) -> &Self::Target { + &self.client + } +} + +impl TestClient { + pub async fn new() -> anyhow::Result { + let api_endpoint = std::env::var("API_ENDPOINT").unwrap_or_else(|_| IOTA_LOCAL_NETWORK_URL.to_string()); + let iota_client = IotaClientBuilder::default().build(&api_endpoint).await?; + let package_ids = PACKAGE_IDS + .get_or_try_init(|| publish_package_ids(&iota_client)) + .await + .copied()?; + + // Use a dedicated ephemeral signer per test to avoid object-lock contention. + let signer = InMemSigner::new(); + let signer_address = signer.get_address().await?; + request_funds(&signer_address).await?; + + let client = AuditTrailClient::from_iota_client( + iota_client.clone(), + Some(PackageOverrides { + audit_trail: Some(package_ids.audit_trail_package_id), + tf_component: package_ids.tf_components_package_id, + }), + ) + .await?; + let client = client.with_signer(signer).await?; + + Ok(TestClient { + client: Arc::new(client), + }) + } + + pub(crate) async fn get_cap(&self, owner: IotaAddress, trail_id: ObjectID) -> anyhow::Result { + let cap: Capability = self + .client + .find_object_for_address(owner, |cap: &Capability| cap.target_key == trail_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to find accredit cap for owner {owner} and trail {trail_id}: {e}"))? + .ok_or_else(|| anyhow::anyhow!("No accredit capability found for owner {owner} and trail {trail_id}"))?; + + let object_id = *cap.id.object_id(); + + Ok(self + .client + .get_object_ref_by_id(object_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to get object ref for accredit cap: {e}"))? + .map(|owned_ref| owned_ref.reference) + .unwrap()) + } + + /// Creates a trail with the given initial record data and returns its ObjectID. + pub(crate) async fn create_test_trail(&self, data: Data) -> anyhow::Result { + self.create_test_trail_with_tags(data, std::iter::empty::()) + .await + } + + /// Creates a trail with the given initial record data and available tags. + pub(crate) async fn create_test_trail_with_tags(&self, data: Data, tags: I) -> anyhow::Result + where + I: IntoIterator, + S: Into, + { + let created = self + .create_trail() + .with_initial_record(InitialRecord::new(data, None, None)) + .with_record_tags(tags) + .finish()? + .build_and_execute(self) + .await? + .output; + Ok(created.trail_id) + } + + /// Creates a role on the given trail with the specified permissions and optional role tags. + pub(crate) async fn create_role( + &self, + trail_id: ObjectID, + role_name: &str, + permissions: impl IntoIterator, + role_tags: Option, + ) -> anyhow::Result { + let created = self + .trail(trail_id) + .access() + .for_role(role_name) + .create( + PermissionSet { + permissions: permissions.into_iter().collect::>(), + }, + role_tags, + ) + .build_and_execute(self) + .await? + .output; + Ok(created) + } + + /// Issues a capability for the given role on the trail. + pub(crate) async fn issue_cap( + &self, + trail_id: ObjectID, + role_name: &str, + options: CapabilityIssueOptions, + ) -> anyhow::Result { + let issued = self + .trail(trail_id) + .access() + .for_role(role_name) + .issue_capability(options) + .build_and_execute(self) + .await? + .output; + Ok(issued) + } +} + +impl CoreClientReadOnly for TestClient { + fn package_id(&self) -> ObjectID { + self.client.package_id() + } + + fn tf_components_package_id(&self) -> Option { + Some(self.client.tf_components_package_id()) + } + + fn network_name(&self) -> &NetworkName { + self.client.network_name() + } + + fn client_adapter(&self) -> &IotaClientAdapter { + self.client.client_adapter() + } +} + +impl CoreClient for TestClient { + fn signer(&self) -> &InMemSigner { + self.client.signer() + } + + fn sender_address(&self) -> IotaAddress { + self.client.sender_address() + } + + fn sender_public_key(&self) -> &PublicKey { + self.client.sender_public_key() + } +} diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs new file mode 100644 index 00000000..7810cff0 --- /dev/null +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -0,0 +1,422 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, TimeLock, +}; +use iota_interaction::types::base_types::ObjectID; + +use crate::client::{TestClient, get_funded_test_client}; + +async fn grant_role_capability( + client: &TestClient, + trail_id: ObjectID, + role_name: &str, + permissions: impl IntoIterator, +) -> anyhow::Result<()> { + client.create_role(trail_id, role_name, permissions, None).await?; + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + Ok(()) +} + +fn config_with_window(delete_record_window: LockingWindow) -> LockingConfig { + LockingConfig { + delete_record_window, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + } +} + +#[tokio::test] +async fn update_locking_config_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("trail-update-locking-e2e")).await?; + let trail = client.trail(trail_id); + + grant_role_capability(&client, trail_id, "LockingAdmin", [Permission::UpdateLockingConfig]).await?; + + trail + .locking() + .update(config_with_window(LockingWindow::CountBased { count: 2 }))? + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + config_with_window(LockingWindow::CountBased { count: 2 }) + ); + + Ok(()) +} + +#[tokio::test] +async fn update_locking_config_switches_count_to_time_based() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("trail-switch-count-to-time-e2e"), + None, + None, + )) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) + .finish()? + .build_and_execute(&client) + .await? + .output + .trail_id; + let trail = client.trail(trail_id); + + grant_role_capability(&client, trail_id, "LockingAdmin", [Permission::UpdateLockingConfig]).await?; + + let before = trail.get().await?; + assert_eq!( + before.locking_config, + config_with_window(LockingWindow::CountBased { count: 3 }) + ); + + trail + .locking() + .update(config_with_window(LockingWindow::TimeBased { seconds: 300 }))? + .build_and_execute(&client) + .await?; + + let after = trail.get().await?; + assert_eq!( + after.locking_config, + config_with_window(LockingWindow::TimeBased { seconds: 300 }) + ); + + Ok(()) +} + +#[tokio::test] +async fn update_delete_record_window_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-update-delete-window-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "DeleteWindowAdmin", + [Permission::UpdateLockingConfigForDeleteRecord], + ) + .await?; + + trail + .locking() + .update_delete_record_window(LockingWindow::TimeBased { seconds: 120 })? + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + config_with_window(LockingWindow::TimeBased { seconds: 120 }) + ); + + Ok(()) +} + +#[tokio::test] +async fn update_delete_trail_lock_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-update-delete-trail-lock-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "DeleteTrailLockAdmin", + [Permission::UpdateLockingConfigForDeleteTrail], + ) + .await?; + + trail + .locking() + .update_delete_trail_lock(TimeLock::Infinite)? + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record_window: LockingWindow::None, + delete_trail_lock: TimeLock::Infinite, + write_lock: TimeLock::None, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn update_write_lock_roundtrip_and_blocks_add_record() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-update-write-lock-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "WriteLockAdmin", + [Permission::UpdateLockingConfigForWrite, Permission::AddRecord], + ) + .await?; + + trail + .locking() + .update_write_lock(TimeLock::Infinite) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record_window: LockingWindow::None, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::Infinite, + } + ); + + let add_locked = trail + .records() + .add(Data::text("should-fail-write-locked"), None, None) + .build_and_execute(&client) + .await; + assert!(add_locked.is_err(), "write lock should block adding new records"); + + Ok(()) +} + +#[tokio::test] +async fn update_locking_config_requires_permission() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-locking-permission-e2e")) + .await?; + + let result = client + .trail(trail_id) + .locking() + .update(config_with_window(LockingWindow::TimeBased { seconds: 60 }))? + .build_and_execute(&client) + .await; + + assert!( + result.is_err(), + "updating locking config without UpdateLockingConfig permission must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn update_write_lock_requires_permission() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-write-lock-permission-e2e")) + .await?; + + let update_result = client + .trail(trail_id) + .locking() + .update_write_lock(TimeLock::Infinite) + .build_and_execute(&client) + .await; + + assert!( + update_result.is_err(), + "updating write lock without UpdateLockingConfigForWrite permission must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn is_record_locked_supports_count_window_and_missing_sequence() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-locking-status-e2e"), None, None)) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 2 })) + .finish()? + .build_and_execute(&client) + .await? + .output + .trail_id; + let trail = client.trail(trail_id); + + grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; + + trail + .records() + .add(Data::text("record-1"), None, None) + .build_and_execute(&client) + .await?; + trail + .records() + .add(Data::text("record-2"), None, None) + .build_and_execute(&client) + .await?; + + assert!( + !trail.locking().is_record_locked(0).await?, + "oldest record should be unlocked with count window of 2 and total records of 3" + ); + assert!( + trail.locking().is_record_locked(2).await?, + "latest record should be locked with count window of 2" + ); + + let missing = trail.locking().is_record_locked(999).await; + assert!(missing.is_err(), "missing sequence should fail"); + + Ok(()) +} + +#[tokio::test] +async fn delete_window_variants_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-locking-window-variants-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "DeleteWindowAdmin", + [Permission::UpdateLockingConfigForDeleteRecord], + ) + .await?; + + trail + .locking() + .update_delete_record_window(LockingWindow::TimeBased { seconds: 3600 })? + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + config_with_window(LockingWindow::TimeBased { seconds: 3600 }) + ); + + trail + .locking() + .update_delete_record_window(LockingWindow::CountBased { count: 1 })? + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + config_with_window(LockingWindow::CountBased { count: 1 }) + ); + + trail + .locking() + .update_delete_record_window(LockingWindow::None)? + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!(on_chain.locking_config, config_with_window(LockingWindow::None)); + + Ok(()) +} + +#[tokio::test] +async fn updated_time_lock_blocks_record_deletion() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-locking-delete-time-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "LockAndDeleteAdmin", + [ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::UpdateLockingConfig, + ], + ) + .await?; + + trail + .records() + .add("deletable-before-lock".into(), None, None) + .build_and_execute(&client) + .await?; + + trail + .locking() + .update(config_with_window(LockingWindow::TimeBased { seconds: 3600 }))? + .build_and_execute(&client) + .await?; + + let delete_locked = trail.records().delete(1).build_and_execute(&client).await; + assert!( + delete_locked.is_err(), + "deleting a record should fail after enabling a time-based delete lock" + ); + assert_eq!(trail.records().record_count().await?, 2); + + Ok(()) +} + +#[tokio::test] +async fn updated_delete_window_can_block_and_then_allow_deletion() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-locking-delete-window-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "DeleteWindowAdmin", + [Permission::DeleteRecord, Permission::UpdateLockingConfigForDeleteRecord], + ) + .await?; + + trail + .locking() + .update_delete_record_window(LockingWindow::CountBased { count: 1 })? + .build_and_execute(&client) + .await?; + + let delete_locked = trail.records().delete(0).build_and_execute(&client).await; + assert!( + delete_locked.is_err(), + "count-based window should block deleting the latest record" + ); + + trail + .locking() + .update_delete_record_window(LockingWindow::None)? + .build_and_execute(&client) + .await?; + + trail.records().delete(0).build_and_execute(&client).await?; + assert_eq!(trail.records().record_count().await?, 0); + + Ok(()) +} diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs new file mode 100644 index 00000000..cab94322 --- /dev/null +++ b/audit-trail-rs/tests/e2e/main.rs @@ -0,0 +1,8 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod access; +mod client; +mod locking; +mod records; +mod trail; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs new file mode 100644 index 00000000..c6c34b8e --- /dev/null +++ b/audit-trail-rs/tests/e2e/records.rs @@ -0,0 +1,1406 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::time::{SystemTime, UNIX_EPOCH}; + +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RoleTags, TimeLock, +}; +use audit_trails::error::Error; +use iota_interaction::types::base_types::ObjectID; +use product_common::core_client::CoreClient; +use tokio::time::{Duration, sleep}; + +use crate::client::{TestClient, get_funded_test_client}; + +async fn grant_role_capability( + client: &TestClient, + trail_id: ObjectID, + role_name: &str, + permissions: impl IntoIterator, +) -> anyhow::Result<()> { + client.create_role(trail_id, role_name, permissions, None).await?; + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + Ok(()) +} + +fn assert_text_data(data: Data, expected: &str) { + match data { + Data::Text(actual) => assert_eq!(actual, expected), + other => panic!("expected text data, got {other:?}"), + } +} + +fn assert_bytes_data(data: Data, expected: &[u8]) { + match data { + Data::Bytes(actual) => assert_eq!(actual, expected), + other => panic!("expected bytes data, got {other:?}"), + } +} + +fn config_with_window(delete_record_window: LockingWindow) -> LockingConfig { + LockingConfig { + delete_record_window, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + } +} + +#[tokio::test] +async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("records-e2e")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; + + let added = records + .add(Data::text("second record"), Some("second metadata".to_string()), None) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.trail_id, trail_id); + assert_eq!(added.sequence_number, 1); + assert_eq!(added.added_by, client.sender_address()); + assert!(added.timestamp > 0); + + let record = records.get(1).await?; + assert_eq!(record.sequence_number, 1); + assert_eq!(record.metadata, Some("second metadata".to_string())); + assert_eq!(record.added_by, client.sender_address()); + assert!(record.added_at > 0); + assert_text_data(record.data, "second record"); + + assert_eq!(records.record_count().await?, 2); + + Ok(()) +} + +#[tokio::test] +async fn add_and_fetch_tagged_record_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("records-tagged"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + + let added = records + .add( + Data::text("finance record"), + Some("tagged metadata".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.trail_id, trail_id); + assert_eq!(added.sequence_number, 1); + + let record = records.get(1).await?; + assert_eq!(record.tag, Some("finance".to_string())); + assert_eq!(record.metadata, Some("tagged metadata".to_string())); + assert_text_data(record.data, "finance record"); + + Ok(()) +} + +#[tokio::test] +async fn add_tagged_record_requires_matching_role_tag_access() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("records-tagged-deny"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "PlainWriter", [Permission::AddRecord]).await?; + + let denied = records + .add(Data::text("should fail"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await; + + assert!(denied.is_err(), "tagged writes should require matching role tag access"); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn add_tagged_record_requires_trail_defined_tag() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("records-tagged-undefined"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + + let denied = records + .add(Data::text("should fail"), None, Some("legal".to_string())) + .build_and_execute(&client) + .await; + + assert!( + denied.is_err(), + "tagged writes should require the tag to be defined on the trail" + ); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn add_record_requires_add_record_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("records-add-permission")).await?; + let records = writer.trail(trail_id).records(); + + admin + .create_role(trail_id, "NoAddRecord", [Permission::DeleteRecord], None) + .await?; + admin + .issue_cap( + trail_id, + "NoAddRecord", + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let denied = records + .add(Data::text("should fail"), None, None) + .build_and_execute(&writer) + .await; + + assert!(denied.is_err(), "adding without AddRecord permission must fail"); + assert_eq!(admin.trail(trail_id).records().record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn add_record_selector_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + // Untagged record flow. + let trail_id = client.create_test_trail(Data::text("records-revoked-selector")).await?; + let records = client.trail(trail_id).records(); + let role_name = "RecordWriter"; + + client + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + + // Revoked capability. + let revoked_cap = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + client + .trail(trail_id) + .access() + .revoke_capability(revoked_cap.capability_id, revoked_cap.valid_until) + .build_and_execute(&client) + .await?; + + // Valid fallback capability. + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + + let added = records + .add(Data::text("writer record"), None, None) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(records.get(1).await?.data, "writer record"); + + Ok(()) +} + +#[tokio::test] +async fn revoked_capability_cannot_add_record_without_fallback() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("records-revoked-hard-fail")).await?; + let records = writer.trail(trail_id).records(); + let role_name = "RecordWriter"; + + admin + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + let issued = admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + admin + .trail(trail_id) + .access() + .revoke_capability(issued.capability_id, issued.valid_until) + .build_and_execute(&admin) + .await?; + + let denied = records + .add(Data::text("should fail"), None, None) + .build_and_execute(&writer) + .await; + + assert!(denied.is_err(), "revoked capabilities must not authorize writes"); + assert_eq!(admin.trail(trail_id).records().record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn add_tagged_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let tagged_trail_id = client + .create_test_trail_with_tags(Data::text("records-revoked-tagged"), ["finance"]) + .await?; + let tagged_records = client.trail(tagged_trail_id).records(); + let tagged_role_name = "TaggedWriter"; + + client + .create_role( + tagged_trail_id, + tagged_role_name, + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + + // Revoked capability. + let revoked_tagged_cap = client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + client + .trail(tagged_trail_id) + .access() + .revoke_capability(revoked_tagged_cap.capability_id, revoked_tagged_cap.valid_until) + .build_and_execute(&client) + .await?; + + // Valid fallback capability. + client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + + let tagged_added = tagged_records + .add( + Data::text("finance entry"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(tagged_added.sequence_number, 1); + assert_eq!(tagged_records.get(1).await?.tag, Some("finance".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn add_record_selector_skips_expired_capability_when_valid_one_exists() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Untagged record flow. + let trail_id = client.create_test_trail(Data::text("records-expired-selector")).await?; + let records = client.trail(trail_id).records(); + let role_name = "RecordWriter"; + + client + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + + // Expired capability. + client + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + valid_until_ms: Some(now_ms.saturating_sub(60_000)), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + // Valid fallback capability. + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + + let added = records + .add(Data::text("writer record"), None, None) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(records.get(1).await?.data, "writer record"); + + // Tagged record flow. + let tagged_trail_id = client + .create_test_trail_with_tags(Data::text("records-expired-tagged"), ["finance"]) + .await?; + let tagged_records = client.trail(tagged_trail_id).records(); + let tagged_role_name = "TaggedWriter"; + + client + .create_role( + tagged_trail_id, + tagged_role_name, + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + + // Expired capability. + client + .issue_cap( + tagged_trail_id, + tagged_role_name, + CapabilityIssueOptions { + valid_until_ms: Some(now_ms.saturating_sub(60_000)), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + // Valid fallback capability. + client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + + let tagged_added = tagged_records + .add( + Data::text("finance entry"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(tagged_added.sequence_number, 1); + assert_eq!(tagged_records.get(1).await?.tag, Some("finance".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn add_record_using_capability_uses_selected_capability_without_fallback() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Untagged record flow. + let trail_id = client + .create_test_trail(Data::text("records-explicit-cap-selector")) + .await?; + let role_name = "RecordWriter"; + + client + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + + let expired_cap = client + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + valid_until_ms: Some(now_ms.saturating_sub(60_000)), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + let valid_cap = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + + let denied = client + .trail(trail_id) + .records() + .using_capability(expired_cap.capability_id) + .add(Data::text("should fail"), None, None) + .build_and_execute(&client) + .await; + + assert!( + denied.is_err(), + "explicit capability selection should not fall back when the chosen capability is expired" + ); + + let added = client + .trail(trail_id) + .records() + .using_capability(valid_cap.capability_id) + .add(Data::text("writer record"), None, None) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(client.trail(trail_id).records().get(1).await?.data, "writer record"); + + // Tagged record flow. + let tagged_trail_id = client + .create_test_trail_with_tags(Data::text("records-explicit-cap-tagged"), ["finance"]) + .await?; + let tagged_role_name = "TaggedWriter"; + + client + .create_role( + tagged_trail_id, + tagged_role_name, + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + + let expired_tagged_cap = client + .issue_cap( + tagged_trail_id, + tagged_role_name, + CapabilityIssueOptions { + valid_until_ms: Some(now_ms.saturating_sub(60_000)), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + let valid_tagged_cap = client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + + let tagged_denied = client + .trail(tagged_trail_id) + .records() + .using_capability(expired_tagged_cap.capability_id) + .add( + Data::text("should fail"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await; + + assert!( + tagged_denied.is_err(), + "tagged writes should also use the explicitly selected capability without fallback" + ); + + let tagged_added = client + .trail(tagged_trail_id) + .records() + .using_capability(valid_tagged_cap.capability_id) + .add( + Data::text("finance entry"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(tagged_added.sequence_number, 1); + assert_eq!( + client.trail(tagged_trail_id).records().get(1).await?.tag, + Some("finance".to_string()) + ); + + Ok(()) +} + +#[tokio::test] +async fn add_record_respects_valid_from_constraint() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("records-valid-from")).await?; + let records = writer.trail(trail_id).records(); + let role_name = "RecordWriter"; + + admin + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + let valid_from_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64 + + 15_000; + admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + valid_from_ms: Some(valid_from_ms), + valid_until_ms: None, + }, + ) + .await?; + + let denied = records + .add(Data::text("too early"), None, None) + .build_and_execute(&writer) + .await; + assert!(denied.is_err(), "writes before valid_from must fail"); + + sleep(Duration::from_secs(16)).await; + + let added = records + .add(Data::text("on time"), None, None) + .build_and_execute(&writer) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(records.get(1).await?.data, "on time"); + + Ok(()) +} + +#[tokio::test] +async fn add_record_respects_valid_until_constraint() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("records-valid-until")).await?; + let records = writer.trail(trail_id).records(); + let role_name = "RecordWriter"; + + admin + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + let valid_until_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64 + + 15_000; + admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + valid_from_ms: None, + valid_until_ms: Some(valid_until_ms), + }, + ) + .await?; + + let added = records + .add(Data::text("before expiry"), None, None) + .build_and_execute(&writer) + .await? + .output; + assert_eq!(added.sequence_number, 1); + + sleep(Duration::from_secs(16)).await; + + let denied = records + .add(Data::text("after expiry"), None, None) + .build_and_execute(&writer) + .await; + assert!(denied.is_err(), "writes after valid_until must fail"); + + Ok(()) +} + +#[tokio::test] +async fn add_record_allows_mixed_data_variants() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("text-trail")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; + + let added = records + .add( + Data::bytes(vec![0xFF, 0x00, 0xAA]), + Some("binary payload".to_string()), + None, + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_eq!(records.record_count().await?, 2); + assert_bytes_data(records.get(1).await?.data, &[0xFF, 0x00, 0xAA]); + + Ok(()) +} + +#[tokio::test] +async fn add_and_fetch_bytes_record_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::bytes(vec![0x10, 0x20, 0x30])).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; + + let added = records + .add( + Data::bytes(vec![0xFF, 0x00, 0xAA]), + Some("binary payload".to_string()), + None, + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_eq!(records.record_count().await?, 2); + + let record = records.get(1).await?; + assert_eq!(record.metadata, Some("binary payload".to_string())); + assert_bytes_data(record.data, &[0xFF, 0x00, 0xAA]); + + Ok(()) +} + +#[tokio::test] +async fn get_missing_record_fails() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("missing-get")).await?; + let records = client.trail(trail_id).records(); + + let missing = records.get(999).await; + assert!(missing.is_err(), "reading a missing sequence must fail"); + + Ok(()) +} + +#[tokio::test] +async fn delete_record_removes_entry_and_keeps_sequence_monotonic() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("delete-roundtrip")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + let added = records + .add(Data::text("surviving record"), Some("keep me".to_string()), None) + .build_and_execute(&client) + .await? + .output; + assert_eq!(added.sequence_number, 1); + + let deleted = records.delete(0).build_and_execute(&client).await?.output; + assert_eq!(deleted.trail_id, trail_id); + assert_eq!(deleted.sequence_number, 0); + assert_eq!(deleted.deleted_by, client.sender_address()); + assert!(deleted.timestamp > 0); + + assert_eq!(records.record_count().await?, 1); + assert!(records.get(0).await.is_err(), "deleted record should be gone"); + + let remaining = records.get(1).await?; + assert_eq!(remaining.sequence_number, 1); + assert_text_data(remaining.data, "surviving record"); + + let on_chain_trail = client.trail(trail_id).get().await?; + assert_eq!( + on_chain_trail.sequence_number, 2, + "sequence_number should stay monotonic even after deletion" + ); + + Ok(()) +} + +#[tokio::test] +async fn delete_tagged_record_requires_matching_role_tag_access() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("delete-tagged-deny"), ["finance"]) + .await?; + + client + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + client + .create_role(trail_id, "DeleteOnly", [Permission::DeleteRecord], None) + .await?; + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + client + .issue_cap(trail_id, "DeleteOnly", CapabilityIssueOptions::default()) + .await?; + + client + .trail(trail_id) + .records() + .add(Data::text("tagged record"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + + let denied = client + .trail(trail_id) + .records() + .delete(1) + .build_and_execute(&client) + .await; + + assert!( + denied.is_err(), + "tagged deletes should require matching role tag access" + ); + assert_eq!(client.trail(trail_id).records().record_count().await?, 2); + assert_eq!( + client.trail(trail_id).records().get(1).await?.tag.as_deref(), + Some("finance") + ); + + Ok(()) +} + +#[tokio::test] +async fn delete_tagged_record_with_matching_role_tag_access_succeeds() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("delete-tagged-allow"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedRecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedRecordAdmin", CapabilityIssueOptions::default()) + .await?; + + records + .add(Data::text("tagged record"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + + let deleted = records.delete(1).build_and_execute(&client).await?.output; + assert_eq!(deleted.sequence_number, 1); + assert_eq!(records.record_count().await?, 1); + assert!(records.get(1).await.is_err()); + + Ok(()) +} + +#[tokio::test] +async fn delete_record_requires_delete_permission() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("delete-perm")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "AddOnly", [Permission::AddRecord]).await?; + + let delete_result = records.delete(0).build_and_execute(&client).await; + assert!( + delete_result.is_err(), + "deleting without DeleteRecord permission must fail" + ); + assert!(records.get(0).await.is_ok(), "record should still exist"); + + Ok(()) +} + +#[tokio::test] +async fn delete_record_not_found_fails() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("delete-not-found")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "DeleteOnly", [Permission::DeleteRecord]).await?; + + let delete_missing = records.delete(999).build_and_execute(&client).await; + assert!(delete_missing.is_err(), "deleting a non-existent sequence should fail"); + + Ok(()) +} + +#[tokio::test] +async fn delete_record_fails_while_time_locked() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("locked"), None, None)) + .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) + .finish()? + .build_and_execute(&client) + .await? + .output; + let trail_id = created.trail_id; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "DeleteOnly", [Permission::DeleteRecord]).await?; + + let delete_locked = records.delete(0).build_and_execute(&client).await; + assert!(delete_locked.is_err(), "time-locked record deletion must fail"); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn sequence_numbers_do_not_reuse_deleted_slots() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("sequence-stability")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + let first_added = records + .add(Data::text("first added"), None, None) + .build_and_execute(&client) + .await? + .output; + assert_eq!(first_added.sequence_number, 1); + + records.delete(1).build_and_execute(&client).await?; + + let second_added = records + .add(Data::text("second added"), None, None) + .build_and_execute(&client) + .await? + .output; + assert_eq!( + second_added.sequence_number, 2, + "new records must not reuse deleted sequence slots" + ); + + assert!(records.get(1).await.is_err(), "deleted sequence should remain absent"); + assert_eq!(records.record_count().await?, 2); + assert_text_data(records.get(2).await?.data, "second added"); + + Ok(()) +} + +#[tokio::test] +async fn delete_record_fails_while_count_locked() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("count-locked"), None, None)) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 5 })) + .finish()? + .build_and_execute(&client) + .await? + .output; + let trail_id = created.trail_id; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "DeleteOnly", [Permission::DeleteRecord]).await?; + + let delete_locked = records.delete(0).build_and_execute(&client).await; + assert!(delete_locked.is_err(), "count-locked record deletion must fail"); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("batch-initial"), None, None)) + .with_locking_config(config_with_window(LockingWindow::None)) + .finish()? + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "BatchRecordAdmin", + [Permission::AddRecord, Permission::DeleteAllRecords], + ) + .await?; + + records + .add(Data::text("batch-second"), None, None) + .build_and_execute(&client) + .await?; + records + .add(Data::text("batch-third"), None, None) + .build_and_execute(&client) + .await?; + + assert_eq!(records.record_count().await?, 3); + + let deleted_two = records.delete_records_batch(2).build_and_execute(&client).await?.output; + assert_eq!( + deleted_two, + vec![0, 1], + "batch delete should return the deleted sequence numbers" + ); + assert_eq!(records.record_count().await?, 1); + assert!(records.get(0).await.is_err(), "oldest record should be removed first"); + assert!( + records.get(1).await.is_err(), + "second oldest record should also be removed" + ); + assert_text_data(records.get(2).await?.data, "batch-third"); + + let deleted_last = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + assert_eq!(deleted_last, vec![2], "remaining record should be deleted"); + assert_eq!(records.record_count().await?, 0); + + let deleted_empty = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + assert!( + deleted_empty.is_empty(), + "deleting from an empty trail should return no sequence numbers" + ); + + Ok(()) +} + +#[tokio::test] +async fn delete_records_batch_skips_locked_records() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("batch-skip-locked-initial"), None, None)) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 1 })) + .finish()? + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "BatchRecordMaintenance", + [Permission::AddRecord, Permission::DeleteAllRecords], + ) + .await?; + + records + .add(Data::text("batch-skip-locked-second"), None, None) + .build_and_execute(&client) + .await?; + records + .add(Data::text("batch-skip-locked-third"), None, None) + .build_and_execute(&client) + .await?; + + let deleted = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + assert_eq!( + deleted, + vec![0, 1], + "batch delete should skip the count-locked tail record" + ); + assert_eq!(records.record_count().await?, 1); + assert!( + records.get(0).await.is_err(), + "oldest unlocked record should be deleted" + ); + assert!( + records.get(1).await.is_err(), + "second unlocked record should be deleted" + ); + assert_text_data(records.get(2).await?.data, "batch-skip-locked-third"); + + Ok(()) +} + +#[tokio::test] +async fn delete_records_batch_requires_delete_all_records_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let operator = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("batch-delete-permission")).await?; + let records = operator.trail(trail_id).records(); + + admin + .create_role(trail_id, "TrailDeleteOnly", [Permission::DeleteAuditTrail], None) + .await?; + admin + .issue_cap( + trail_id, + "TrailDeleteOnly", + CapabilityIssueOptions { + issued_to: Some(operator.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let denied = records.delete_records_batch(10).build_and_execute(&operator).await; + assert!( + denied.is_err(), + "batch deletion must require DeleteAllRecords permission" + ); + assert_eq!(admin.trail(trail_id).records().record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn delete_records_batch_skips_unauthorized_tagged_records() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("batch-delete-tagged-deny"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + client + .create_role( + trail_id, + "DeleteAllWithoutTags", + [Permission::DeleteRecord, Permission::DeleteAllRecords], + None, + ) + .await?; + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + client + .issue_cap(trail_id, "DeleteAllWithoutTags", CapabilityIssueOptions::default()) + .await?; + + records.delete(0).build_and_execute(&client).await?; + records + .add(Data::text("tagged record"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + + let deleted = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + + assert!(deleted.is_empty()); + assert_eq!(records.record_count().await?, 1); + assert_eq!(records.get(1).await?.tag.as_deref(), Some("finance")); + + Ok(()) +} + +#[tokio::test] +async fn delete_records_batch_deletes_authorized_differently_tagged_records() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("batch-delete-tagged-allow"), ["finance", "legal"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedDeleteAll", + [ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::DeleteAllRecords, + ], + Some(RoleTags::new(["finance", "legal"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedDeleteAll", CapabilityIssueOptions::default()) + .await?; + + records.delete(0).build_and_execute(&client).await?; + records + .add(Data::text("finance record"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + records + .add(Data::text("legal record"), None, Some("legal".to_string())) + .build_and_execute(&client) + .await?; + + let deleted = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + assert_eq!(deleted, vec![1, 2]); + assert_eq!(records.record_count().await?, 0); + assert!(records.get(1).await.is_err()); + assert!(records.get(2).await.is_err()); + + Ok(()) +} + +#[tokio::test] +async fn list_and_pagination_support_sparse_sequence_numbers() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("pagination")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + records + .add(Data::text("second"), Some("m2".to_string()), None) + .build_and_execute(&client) + .await?; + records + .add(Data::text("third"), Some("m3".to_string()), None) + .build_and_execute(&client) + .await?; + records.delete(1).build_and_execute(&client).await?; + + assert_eq!(records.record_count().await?, 2); + + let listed = records.list().await?; + assert_eq!(listed.len(), 2); + assert!(listed.contains_key(&0)); + assert!(listed.contains_key(&2)); + + let too_small = records.list_with_limit(1).await; + assert!(too_small.is_err(), "limit below table size should fail"); + + let page_1 = records.list_page(None, 1).await?; + assert_eq!(page_1.records.len(), 1); + assert!(page_1.records.contains_key(&0)); + assert!(page_1.has_next_page); + + let page_2 = records.list_page(page_1.next_cursor, 1).await?; + assert_eq!(page_2.records.len(), 1); + assert!(page_2.records.contains_key(&2)); + assert!(!page_2.has_next_page); + assert!(page_2.next_cursor.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn list_and_pagination_multi_page_through_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("pagination-multi")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + for (idx, label) in ["r1", "r2", "r3", "r4", "r5", "r6"].into_iter().enumerate() { + records + .add( + Data::text(format!("record-{label}")), + Some(format!("meta-{}", idx + 1)), + None, + ) + .build_and_execute(&client) + .await?; + } + + // Create sparse keys: 0,1,3,4,6 + records.delete(2).build_and_execute(&client).await?; + records.delete(5).build_and_execute(&client).await?; + + assert_eq!(records.record_count().await?, 5); + + let list = records.list().await?; + assert_eq!(list.len(), 5); + assert!(list.contains_key(&0)); + assert!(list.contains_key(&1)); + assert!(list.contains_key(&3)); + assert!(list.contains_key(&4)); + assert!(list.contains_key(&6)); + assert_text_data( + list.get(&4).expect("record with key 4 should exist").data.clone(), + "record-r4", + ); + + let limited = records.list_with_limit(5).await?; + assert_eq!(limited.len(), 5); + assert!(records.list_with_limit(4).await.is_err()); + + // limit=0 returns no records and keeps the traversal cursor at the starting position. + let empty_page = records.list_page(None, 0).await?; + assert!(empty_page.records.is_empty()); + assert!(empty_page.has_next_page); + assert!(empty_page.next_cursor.is_some()); + + let page_1 = records.list_page(None, 2).await?; + assert_eq!(page_1.records.len(), 2); + assert_eq!( + page_1.records.keys().copied().collect::>(), + vec![0, 1], + "page keys should be stable and ordered" + ); + assert!(page_1.records.contains_key(&0)); + assert!(page_1.records.contains_key(&1)); + assert!(page_1.has_next_page); + + let page_2 = records.list_page(page_1.next_cursor, 2).await?; + assert_eq!(page_2.records.len(), 2); + assert_eq!( + page_2.records.keys().copied().collect::>(), + vec![3, 4], + "page keys should be stable and ordered" + ); + assert!(page_2.records.contains_key(&3)); + assert!(page_2.records.contains_key(&4)); + assert!(page_2.has_next_page); + + let page_3 = records.list_page(page_2.next_cursor, 2).await?; + assert_eq!(page_3.records.len(), 1); + assert!(page_3.records.contains_key(&6)); + assert!(!page_3.has_next_page); + assert!(page_3.next_cursor.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn list_page_cursor_validation_and_mid_cursor_start() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("pagination-cursor")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + for label in ["r1", "r2", "r3", "r4"] { + records + .add(Data::text(format!("record-{label}")), None, None) + .build_and_execute(&client) + .await?; + } + + // Existing keys are now 0,1,2,3,4. + let middle_page = records.list_page(Some(2), 2).await?; + assert_eq!(middle_page.records.len(), 2); + assert_eq!( + middle_page.records.keys().copied().collect::>(), + vec![2, 3], + "page keys should be stable and ordered" + ); + assert!(middle_page.records.contains_key(&2)); + assert!(middle_page.records.contains_key(&3)); + assert!(middle_page.has_next_page); + + // Cursors that do not exist in the linked-table should fail. + let invalid_cursor = records.list_page(Some(999), 1).await; + assert!(invalid_cursor.is_err(), "an invalid cursor should produce an error"); + + Ok(()) +} + +#[tokio::test] +async fn list_page_rejects_limit_above_supported_max() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("pagination-cap")).await?; + let records = client.trail(trail_id).records(); + + let result = records.list_page(None, 1_001).await; + + match result { + Err(Error::InvalidArgument(message)) => { + assert!( + message.contains("exceeds max supported page size"), + "page-size cap error should be explicit: {message}" + ); + } + Err(other) => panic!("expected InvalidArgument for oversized limit, got {other}"), + Ok(page) => panic!("expected oversized limit error, got page: {page:?}"), + } + + Ok(()) +} diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs new file mode 100644 index 00000000..fe7f635e --- /dev/null +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -0,0 +1,593 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, Permission, RoleTags, + TimeLock, +}; +use iota_interaction::types::base_types::IotaAddress; +use product_common::core_client::CoreClient; + +use crate::client::get_funded_test_client; + +fn config_with_window(delete_record_window: LockingWindow) -> LockingConfig { + LockingConfig { + delete_record_window, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + } +} + +#[tokio::test] +async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("audit-trail-create-default"), None, None)) + .finish()? + .build_and_execute(&client) + .await? + .output; + + assert_eq!(created.creator, client.sender_address()); + + let on_chain = created.fetch_audit_trail(&client).await?; + assert_eq!(on_chain.id.object_id(), &created.trail_id); + assert_eq!(on_chain.creator, client.sender_address()); + assert_eq!(on_chain.sequence_number, 1); + assert_eq!(on_chain.locking_config, config_with_window(LockingWindow::None)); + assert!(on_chain.immutable_metadata.is_none()); + assert!(on_chain.updatable_metadata.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn create_empty_trail_with_default_builder_settings() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client.create_trail().finish()?.build_and_execute(&client).await?.output; + + assert_eq!(created.creator, client.sender_address()); + + let on_chain = created.fetch_audit_trail(&client).await?; + assert_eq!(on_chain.id.object_id(), &created.trail_id); + assert_eq!(on_chain.creator, client.sender_address()); + assert_eq!(on_chain.sequence_number, 0); + assert_eq!(on_chain.locking_config, config_with_window(LockingWindow::None)); + assert!(on_chain.immutable_metadata.is_none()); + assert!(on_chain.updatable_metadata.is_none()); + assert_eq!(client.trail(created.trail_id).records().record_count().await?, 0); + + Ok(()) +} + +#[tokio::test] +async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let immutable_metadata = + ImmutableMetadata::new("Trail Time Lock".to_string(), Some("immutable description".to_string())); + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("audit-trail-create-time-lock"), + Some("initial record metadata".to_string()), + None, + )) + .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 300 })) + .with_trail_metadata(immutable_metadata.clone()) + .with_updatable_metadata("updatable metadata") + .finish()? + .build_and_execute(&client) + .await? + .output; + + let on_chain = created.fetch_audit_trail(&client).await?; + assert_eq!( + on_chain.locking_config, + config_with_window(LockingWindow::TimeBased { seconds: 300 }) + ); + assert_eq!(on_chain.immutable_metadata, Some(immutable_metadata)); + assert_eq!(on_chain.updatable_metadata, Some("updatable metadata".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::bytes(vec![0xAA, 0xBB, 0xCC, 0xDD]), + Some("bytes metadata".to_string()), + None, + )) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) + .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) + .finish()? + .build_and_execute(&client) + .await? + .output; + + let on_chain = created.fetch_audit_trail(&client).await?; + assert_eq!( + on_chain.locking_config, + config_with_window(LockingWindow::CountBased { count: 3 }) + ); + assert_eq!(on_chain.sequence_number, 1); + + Ok(()) +} + +#[tokio::test] +async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let custom_admin = IotaAddress::random(); + + let created = client + .create_trail() + .with_admin(custom_admin) + .with_initial_record(InitialRecord::new(Data::text("audit-trail-custom-admin"), None, None)) + .finish()? + .build_and_execute(&client) + .await? + .output; + + let cap = client.get_cap(custom_admin, created.trail_id).await; + + match cap { + Ok(cap_ref) => println!("Found admin capability with ID: {}", cap_ref.object_id), + Err(e) => println!("Error finding admin capability for custom admin: {e}"), + } + + Ok(()) +} + +#[tokio::test] +async fn get_returns_on_chain_trail() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-get-e2e"), None, None)) + .with_trail_metadata_parts("Get Test", Some("description".into())) + .with_updatable_metadata("initial updatable") + .finish()? + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + let on_chain = trail.get().await?; + + assert_eq!(on_chain.id.object_id(), &created.trail_id); + assert_eq!(on_chain.creator, created.creator); + assert_eq!(on_chain.sequence_number, 1); + assert_eq!( + on_chain.immutable_metadata, + Some(ImmutableMetadata::new( + "Get Test".to_string(), + Some("description".to_string()) + )) + ); + assert_eq!(on_chain.updatable_metadata, Some("initial updatable".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn get_trail_without_metadata() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-no-meta-e2e"), None, None)) + .finish()? + .build_and_execute(&client) + .await? + .output; + + let on_chain = client.trail(created.trail_id).get().await?; + + assert!(on_chain.immutable_metadata.is_none()); + assert!(on_chain.updatable_metadata.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn migrate_is_available_on_trail_handle() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("trail-migrate-e2e")).await?; + + let handle_migrate = client.trail(trail_id).migrate().build_and_execute(&client).await; + + assert!( + handle_migrate.is_err(), + "new trails are already on latest package version, migrate should fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn update_metadata_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let trail_id = client.create_test_trail(Data::text("trail-update-meta-e2e")).await?; + // Set initial updatable metadata via update_metadata + client + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) + .await?; + client + .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) + .await?; + + let trail = client.trail(trail_id); + + trail + .update_metadata(Some("before".to_string())) + .build_and_execute(&client) + .await?; + + let before = trail.get().await?; + assert_eq!(before.updatable_metadata, Some("before".to_string())); + + // Update to a new value + trail + .update_metadata(Some("after".to_string())) + .build_and_execute(&client) + .await?; + + let after = trail.get().await?; + assert_eq!(after.updatable_metadata, Some("after".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn update_metadata_to_none_clears_value() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let trail_id = client.create_test_trail(Data::text("trail-clear-meta-e2e")).await?; + client + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) + .await?; + client + .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) + .await?; + + let trail = client.trail(trail_id); + + trail + .update_metadata(Some("to-be-cleared".to_string())) + .build_and_execute(&client) + .await?; + + trail.update_metadata(None).build_and_execute(&client).await?; + + let on_chain = trail.get().await?; + assert_eq!(on_chain.updatable_metadata, None); + + Ok(()) +} + +#[tokio::test] +async fn update_metadata_multiple_times() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let trail_id = client.create_test_trail(Data::text("trail-multi-meta-e2e")).await?; + client + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) + .await?; + client + .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) + .await?; + + let trail = client.trail(trail_id); + + // Set, then overwrite, then clear + trail + .update_metadata(Some("first".to_string())) + .build_and_execute(&client) + .await?; + + trail + .update_metadata(Some("second".to_string())) + .build_and_execute(&client) + .await?; + + trail.update_metadata(None).build_and_execute(&client).await?; + + let on_chain = trail.get().await?; + assert_eq!(on_chain.updatable_metadata, None); + + Ok(()) +} + +#[tokio::test] +async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let immutable = ImmutableMetadata::new("Immutable Name".to_string(), Some("frozen".to_string())); + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-immutable-check-e2e"), None, None)) + .with_trail_metadata(immutable.clone()) + .with_updatable_metadata("mutable") + .finish()? + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + client + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) + .await?; + client + .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) + .await?; + + let trail = client.trail(trail_id); + + trail + .update_metadata(Some("changed".to_string())) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!(on_chain.immutable_metadata, Some(immutable)); + assert_eq!(on_chain.updatable_metadata, Some("changed".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn update_metadata_requires_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let metadata_user = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("trail-update-meta-denied")).await?; + + admin + .create_role(trail_id, "NoMetadataPerm", vec![Permission::AddRecord], None) + .await?; + admin + .issue_cap( + trail_id, + "NoMetadataPerm", + CapabilityIssueOptions { + issued_to: Some(metadata_user.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let updated = metadata_user + .trail(trail_id) + .update_metadata(Some("should fail".to_string())) + .build_and_execute(&metadata_user) + .await; + + assert!( + updated.is_err(), + "updating metadata without UpdateMetadata permission must fail" + ); + assert_eq!(admin.trail(trail_id).get().await?.updatable_metadata, None); + + Ok(()) +} + +#[tokio::test] +async fn revoked_capability_cannot_update_metadata() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let metadata_user = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("trail-update-meta-revoked")).await?; + + admin + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) + .await?; + let issued = admin + .issue_cap( + trail_id, + "MetadataAdmin", + CapabilityIssueOptions { + issued_to: Some(metadata_user.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + admin + .trail(trail_id) + .access() + .revoke_capability(issued.capability_id, issued.valid_until) + .build_and_execute(&admin) + .await?; + + let updated = metadata_user + .trail(trail_id) + .update_metadata(Some("should fail".to_string())) + .build_and_execute(&metadata_user) + .await; + + assert!(updated.is_err(), "revoked capabilities must not update metadata"); + assert_eq!(admin.trail(trail_id).get().await?.updatable_metadata, None); + + Ok(()) +} + +#[tokio::test] +async fn delete_audit_trail_fails_when_records_exist() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-delete-not-empty-e2e")) + .await?; + client + .create_role(trail_id, "TrailDeleteOnly", vec![Permission::DeleteAuditTrail], None) + .await?; + client + .issue_cap(trail_id, "TrailDeleteOnly", CapabilityIssueOptions::default()) + .await?; + let trail = client.trail(trail_id); + + let delete_result = trail.delete_audit_trail().build_and_execute(&client).await; + assert!(delete_result.is_err(), "deleting a non-empty trail must fail"); + + let on_chain = trail.get().await?; + assert_eq!(on_chain.id.object_id(), &trail_id); + + Ok(()) +} + +#[tokio::test] +async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-batch-delete-e2e"), None, None)) + .with_locking_config(config_with_window(LockingWindow::None)) + .finish()? + .build_and_execute(&client) + .await? + .output; + client + .create_role( + created.trail_id, + "TrailDeleteMaintenance", + vec![Permission::DeleteAllRecords, Permission::DeleteAuditTrail], + None, + ) + .await?; + client + .issue_cap( + created.trail_id, + "TrailDeleteMaintenance", + CapabilityIssueOptions::default(), + ) + .await?; + + let trail = client.trail(created.trail_id); + + let deleted = trail + .records() + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + assert_eq!(deleted, vec![0], "initial record should be deleted in batch"); + assert_eq!(trail.records().record_count().await?, 0); + + let deleted_trail = trail.delete_audit_trail().build_and_execute(&client).await?.output; + assert_eq!(deleted_trail.trail_id, created.trail_id); + assert!(deleted_trail.timestamp > 0); + + let fetch_deleted = trail.get().await; + assert!( + fetch_deleted.is_err(), + "trail object should no longer be readable after delete" + ); + + Ok(()) +} + +#[tokio::test] +async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-tag-registry"), None, None)) + .with_record_tags(["finance"]) + .finish()? + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + let initial = trail.get().await?; + assert_eq!(initial.tags.len(), 1); + assert!(initial.tags.contains_key("finance")); + + trail.tags().add("legal").build_and_execute(&client).await?; + let after_add = trail.get().await?; + assert!(after_add.tags.contains_key("finance")); + assert!(after_add.tags.contains_key("legal")); + + trail.tags().remove("legal").build_and_execute(&client).await?; + + let after_remove = trail.get().await?; + assert_eq!(after_remove.tags.len(), 1); + assert!(after_remove.tags.contains_key("finance")); + + Ok(()) +} + +#[tokio::test] +async fn remove_record_tag_rejects_in_use_tag() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-tag-in-use"), None, None)) + .with_record_tags(["finance"]) + .finish()? + .build_and_execute(&client) + .await? + .output; + + client + .create_role( + created.trail_id, + "TaggedWriter", + vec![Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + client + .issue_cap(created.trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + + let trail = client.trail(created.trail_id); + trail + .records() + .add(Data::text("tagged"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + + let removed = trail.tags().remove("finance").build_and_execute(&client).await; + assert!(removed.is_err(), "used record tags must not be removable"); + + Ok(()) +} + +#[tokio::test] +async fn remove_record_tag_rejects_role_only_usage() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("trail-tag-role-usage"), None, None)) + .with_record_tags(["finance"]) + .finish()? + .build_and_execute(&client) + .await? + .output; + + client + .create_role( + created.trail_id, + "TaggedWriter", + vec![Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + + let trail = client.trail(created.trail_id); + let removed = trail.tags().remove("finance").build_and_execute(&client).await; + assert!(removed.is_err(), "role-backed tags must not be removable"); + + Ok(()) +} diff --git a/bindings/wasm/DOC-STYLEGUIDE.md b/bindings/wasm/DOC-STYLEGUIDE.md new file mode 100644 index 00000000..d0197078 --- /dev/null +++ b/bindings/wasm/DOC-STYLEGUIDE.md @@ -0,0 +1,283 @@ +# DOC-STYLEGUIDE.md — Documentation Style Guide for wasm-bindgen generated TSDoc/JSDoc + +This file is the authoritative style guide for the doc comments on the wasm-bindgen +bindings of IOTA Trust Framework products (TF-product), typically located +in `bindings/wasm/_wasm/src/` in the products git repository. +The Rust doc comments here are shipped to TypeScript consumers verbatim — they end up +in the generated `.d.ts` files and drive IDE IntelliSense and TypeDoc output. +They must therefore read as TSDoc/JSDoc, **not** as rustdoc. + +## Audience + +The reader is a TypeScript developer using the TF-product TS/JS package. They: + +- Do **not** know Rust, `wasm_bindgen`, or that the bindings are generated. +- See identifiers in their TypeScript form (camelCase methods, JS class names from + `js_name = …`). +- Browse via IDE hover, the generated `.d.ts`, and TypeDoc HTML. + +Write for that audience. Never mention `wasm_bindgen`, `wasm-pack`, "wasm +consumers", "JS/TS bindings", "exposed to JavaScript", or anything that betrays +the binding mechanism. Describe behavior from the TypeScript user's perspective. + +## What gets documented + +Every **exported binding surface** must have a description: + +- Each `#[wasm_bindgen]` struct or enum. +- Each `pub` method/function inside a `#[wasm_bindgen]` `impl` block. +- Each public field of a `#[wasm_bindgen(getter_with_clone)]` struct. +- Each variant of a `#[wasm_bindgen]` enum. +- Each `pub` parameter of every documented function (use `@param`). + +Local helpers (private `fn`s, `pub(crate)` items, `From`/`TryFrom` impls, panic +hooks, internal closures) do **not** need TSDoc. Module-level `//!` comments are +also internal and are not exported. + +## Linking + +Use TSDoc/JSDoc link syntax only. Never use Rust intra-doc links. + +Examples from `https://github.com/iotaledger/notarization/tree/feat/audit-trails-dev/bindings/wasm/audit_trail_wasm` + +| Use | Don't use | +| -------------------------------------------------------------- | ------------------------- | +| `{@link AuditTrailBuilder}` | ``[`AuditTrailBuilder`]`` | +| `{@link AuditTrailBuilder.withAdmin}` | ``[`Self::with_admin`]`` | +| `{@link RoleMap.initialAdminRoleName \| Admin}` (display text) | rust path-style links | +| `{@link Permission.AddRecord}` | enum path links | + +Always link by **TS-visible name** (the value of `js_name`, or the camelCase form +that wasm-bindgen produces), not by Rust identifier: + +Examples: + +- `WasmAuditTrailBuilder` → link as `AuditTrailBuilder`. +- `with_admin` → link as `withAdmin`. +- A field `valid_from_ms` → link as `validFromMs`. + +Do not produce broken links. If the symbol is not exported, refer to it in +backticks (`` `addRecord` ``) instead of via `{@link …}`. + +## Allowed tags + +Use only TSDoc tags (the ones standard TypeDoc understands). The set in use: + +- `@remarks` — extended description after the summary. +- `@param name - description.` — one per parameter, hyphen-prefixed. +- `@returns description.` — return value (see omission rule below). +- `@throws description.` — error conditions. +- `@inheritDoc {@link Other.member}` — when copy-inheriting docs. + +Do **not** use rustdoc-only tags (`# Errors`, `# Examples`, `# Safety`, +`# Panics`) — they render as literal headings in TS output. + +## Block ordering and spacing + +Every doc comment that has more than a summary line follows this order, with a +blank line (`///` line on its own) between each block: + +``` +/// Summary line — one short sentence. +/// +/// @remarks +/// Extended description, multiple sentences, possibly multiple paragraphs. +/// +/// Requires the {@link Permission.} permission. +/// +/// @param foo - Description of foo. +/// @param bar - Description of bar. +/// +/// @returns Description of the return value. +/// +/// @throws Description of error conditions. +/// +/// Emits a {@link } event on success. +``` + +Rules: + +- Summary first, on its own line(s), no tag. +- `@remarks` block comes next when present. +- The "Requires …" capability block comes after `@remarks` (or directly after + the summary when no `@remarks` exists). Always its own paragraph, separated + by blank lines on both sides. The "Requires …" capability block is only + needed if the TF-products Move smart contract is access controlled e.g. + by Capability objects or equivalent access control mechanism. See below + for more details. +- `@param` lines are grouped together with no blank lines between them, then a + blank line before the next block. +- `@returns` follows `@param`s. +- `@throws` follows `@returns`. +- Move-event lines come **last**, separated from the preceding block by a blank + line. They are written as plain prose ("Emits a {@link Foo} event on + success."), not as a tag. +- Multi-paragraph `@remarks` separate paragraphs with `///` blank lines inside + the same `@remarks` block. + +## Capability gating + +When a function/transaction is gated on one or more capabilities/permissions, +state it in a dedicated paragraph that begins with "Requires …" and link the +permission(s) (examples are using +[RoleMap based access control](https://github.com/iotaledger/product-core/blob/main/components_move/sources/role_map.move)): + +``` +/// Requires the {@link Permission.} permission. +``` + +For multiple permissions, list them naturally: + +``` +/// Requires the {@link Permission.AddFoo} and {@link Permission.AddBar} +/// permissions. +``` + +The block is separated from surrounding prose by blank lines on both sides. + +When the function is not access gated, omit the block entirely. + +## Move events + +Functions whose Move counterpart emits an event end their doc with one +event-emission paragraph per event, separated from the preceding content by a +blank line: + +``` +/// Emits a {@link } event on success. +``` + +For multiple emissions: + +``` +/// Emits one {@link FooBarDeleted} event per deletion. +``` + +When the function emits no event, omit the block entirely. Do not write "Emits +no event." + +## Parameter docs + +- Every parameter visible to TypeScript callers gets an `@param` line. +- Use the **TS-visible name**: `@param sequenceNumber` not `@param sequence_number`. +- Use `name - description.` form (TSDoc's hyphen form). End with a period. +- Optional parameters: describe both the `null`/`undefined` semantics and the + set semantics. Example: + `@param message - Optional message shown displayed to the user.` + +If a parameter is purely structural (e.g. takes a builder and returns one), +still document it — at minimum: "Configured `Foo`." + +## Return docs + +- Document non-trivial return values with `@returns`. +- **Omit** `@returns` when the underlying Rust function returns `Result<(), …>` + (i.e. `Ok(())`). The `Ok(())` exists only so the JS side can throw on error — + there is no TS-visible return value. +- For builder chain methods that return `Self`, write something like: + "@returns The same builder, with the X configured." +- For transaction-wrapper-producing methods, write: + "@returns A {@link TransactionBuilder} wrapping the {@link Foo} transaction." + +## Throws docs + +Use `@throws` when the function can fail with a TS-visible exception: + +- Object ID parsing failures. +- "Read-only client" guard failures. +- Network/serialization errors. +- Logical preconditions that the TF-product library rejects before submission. + +Briefly state the trigger condition. One `@throws` per distinct failure +category, or a single `@throws` summarizing them when they share a phrase. + +## Field docs + +Public fields of `getter_with_clone` annotated structs are visible to TS as plain +properties. Document each with at least a one-line description above the field. If the +semantics are non-trivial (e.g. nullable, normalized, sorted), state that in +the description: + +```rust +/// Sequence number of the first entry, if any. +pub head: Option, +``` + +In case of references to functions, important concepts, edge cases or other explanatory details +related to the field add longer field documentation, where the same blank-line block ordering applies. + +## Enum variant docs + +Each variant of an exported enum gets a one-line description: + +```rust +pub enum WasmPermission { + /// Authorizes deleting the foo-bar itself. + DeleteFooBar, + /// Authorizes the batched foo-bar-deletion entry point. + DeleteAllFooBar, + … +} +``` + +Don't repeat the enum's own summary on every variant; describe what the variant +does. + +## Phrasing conventions + +- Prefer present tense, active voice: "Returns …", "Builds …", "Replaces …". +- "On success" / "on-chain" / "aborts on-chain" are the standard phrasings for + describing Move-side outcomes. +- Time fields: be explicit about units and epoch — "milliseconds since the Unix + epoch", "seconds since the Unix epoch", "inclusive" / "exclusive" where it + matters. +- Never claim something is "internal" or "for advanced users only" without a + concrete reason. +- Do not document the Rust-side type — document the TypeScript-side behavior. + E.g. write "Returns a string view of the payload." not "Calls + `String::from_utf8_lossy`." + +## Forbidden content + +- Mentions of `wasm_bindgen`, `wasm-pack`, "wasm exports", "wasm consumers", + "JS/TS bindings", "JS-friendly wrapper", "WASM-friendly". +- Rust intra-doc link syntax (``[`Foo`]``, ``[`Foo`](path)``). +- Section headings (`# Errors`, `# Examples`) — they don't render in TSDoc. +- Markdown emoji unless the user explicitly asks for it. +- "Returns Ok(())" or "Returns unit" — instead, omit `@returns`. +- Implementation details that are not observable from TypeScript. + +## Validating changes + +After changing doc comments: + +1. `cargo check --target wasm32-unknown-unknown` — must succeed. +2. If the crate has `#![warn(rustdoc::all)]`: + `cargo doc --target wasm32-unknown-unknown --no-deps` — should produce no + `rustdoc::all` warnings (). +3. If feasible, regenerate the `.d.ts` (`npm run build`) and skim the output + for any reference that looks broken or contains a stray rustdoc artifact. + +## Quick template + +When adding a new exported function, start from this template and trim any +block that does not apply: + +```rust +/// +/// +/// @remarks +/// +/// +/// Requires the {@link Permission.} permission. +/// +/// @param - . +/// +/// @returns +/// +/// @throws +/// +/// Emits a {@link } event on success. +#[wasm_bindgen(js_name = )] +pub fn (...) -> Result<...> { ... } +``` diff --git a/bindings/wasm/README.md b/bindings/wasm/README.md index 433abf5b..fb1e438f 100644 --- a/bindings/wasm/README.md +++ b/bindings/wasm/README.md @@ -8,7 +8,9 @@ The `build` folder provides build scripts needed to build the artifacts. Here is an overview of the existing artifacts: - `notarization_wasm`
- Exports the NotarizationClient to TypeScript using wasm-bindgen generated wasm bindings + Public surface of notarization-rs exported to JS/TypeScript +- `audit_trail_wasm`
+ Public surface of audit-trail-rs exported to JS/TypeScript ## Building an Artifact @@ -93,3 +95,10 @@ It is used by the following run tasks for the following tsconfig files and distr | `bundle:nodejs` | `./lib/tsconfig.json` | `node` | | `bundle:web` | `./lib/tsconfig.web.json` | `web` | | `build:examples:web` | `./examples/tsconfig.web.json` | `./examples/dist` | + +## Documentation Style Guide for generated TSDoc/JSDoc + +The [DOC-STYLEGUIDE.md](./DOC-STYLEGUIDE.md) states rules to be followed for the documentation +of Rust types being compiled in TS/JS types using wasm-bindgen. + +These rules are obligatory for developers and AI agents. diff --git a/bindings/wasm/audit_trail_wasm/CLAUDE.md b/bindings/wasm/audit_trail_wasm/CLAUDE.md new file mode 100644 index 00000000..6610571a --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/CLAUDE.md @@ -0,0 +1,10 @@ +# CLAUDE.md — Guidelines for `audit_trail_wasm` + +## Documentation Style Guide + +Follow the guidelines in `../DOC-STYLEGUIDE.md` and make sure to +follow all rules stated there. + +Make sure to follow - despite all other rules described there - the rules +stated in the `Capability gating` section because +`audit_trail_wasm` uses [RoleMap based access control](https://github.com/iotaledger/product-core/blob/main/components_move/sources/role_map.move)). diff --git a/bindings/wasm/audit_trail_wasm/Cargo.toml b/bindings/wasm/audit_trail_wasm/Cargo.toml new file mode 100644 index 00000000..9c036b1f --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "audit_trail_wasm" +version = "0.1.0-alpha" +authors = ["IOTA Stiftung"] +edition = "2021" +homepage = "https://www.iota.org" +keywords = ["iota", "tangle", "audit-trail", "wasm"] +license = "Apache-2.0" +publish = false +readme = "README.md" +repository = "https://github.com/iotaledger/notarization.git" +resolver = "2" +description = "Web Assembly bindings for the audit_trails crate." + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +anyhow = "1.0.95" +audit_trails = { path = "../../../audit-trail-rs", default-features = false, features = ["gas-station", "default-http-client"] } +bcs = "0.1.6" +console_error_panic_hook = { version = "0.1" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.19", package = "iota_interaction", default-features = false } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.19", package = "iota_interaction_ts" } +js-sys = { version = "0.3.61" } +product_common = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.19", package = "product_common", features = ["core-client", "transaction", "bindings", "binding-utils", "gas-station", "default-http-client"] } +serde = { version = "1.0", features = ["derive"] } +serde-wasm-bindgen = "0.6.5" +tokio = { version = "1.49.0", default-features = false, features = ["sync"] } +wasm-bindgen = { version = "0.2.100", features = ["serde-serialize"] } +wasm-bindgen-futures = { version = "0.4", default-features = false } + +[target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dependencies] +getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } + +[profile.release] +opt-level = 's' +lto = true + +[lints.clippy] +empty_docs = "allow" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(wasm_bindgen_unstable_test_coverage)'] } diff --git a/bindings/wasm/audit_trail_wasm/README.md b/bindings/wasm/audit_trail_wasm/README.md new file mode 100644 index 00000000..ec7acb02 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/README.md @@ -0,0 +1,74 @@ +# `audit_trail_wasm` + +`audit_trail_wasm` exposes the `audit_trails` Rust crate to JavaScript and TypeScript consumers through `wasm-bindgen`. + +It is designed for browser and other `wasm32` environments that need: + +- read-only and signing audit-trail clients +- typed wrappers for trail handles, records, locking, access control, and tags +- serializable value and event types that map cleanly into JS/TS +- transaction wrappers that integrate with the shared `product_common` wasm transaction helpers + +## Main entry points + +- `AuditTrailClientReadOnly` for reads and inspected transactions +- `AuditTrailClient` for signed write flows +- `AuditTrailBuilder` for creating new trails +- `AuditTrailHandle` for trail-scoped APIs +- `TrailRecords`, `TrailLocking`, `TrailAccess`, and `TrailTags` for subsystem-specific operations + +## Choosing an entry point + +- Use `AuditTrailClientReadOnly` when you need reads, package resolution, or inspected transactions. +- Use `AuditTrailClient` when you also need typed write transaction builders. +- Use `AuditTrailHandle` after you already know the trail object ID and want to stay scoped to that trail. +- Use `AuditTrailBuilder` when you are preparing a create-trail transaction. + +## Data model wrappers + +The bindings expose JS-friendly wrappers for the most important Rust value types: + +- `Data` +- `Permission` and `PermissionSet` +- `RoleTags`, `RoleMap`, and `CapabilityIssueOptions` +- `TimeLock`, `LockingWindow`, and `LockingConfig` +- `Record`, `PaginatedRecord`, and `OnChainAuditTrail` +- event payloads such as `RecordAdded`, `RoleCreated`, and `CapabilityIssued` + +## Typical read flow + +1. Create an `AuditTrailClientReadOnly` or `AuditTrailClient`. +2. Resolve a trail handle with `.trail(trailId)`. +3. Read state with `.get()`, `.records().get(...)`, `.records().listPage(...)`, or `.locking().isRecordLocked(...)`. + +## Typical write flow + +1. Create an `AuditTrailClient` with a transaction signer. +2. Build a transaction from `client.createTrail()`, `client.trail(trailId)`, or one of the trail subsystem handles. +3. Convert that transaction wrapper into programmable transaction bytes. +4. Submit it through your surrounding JS transaction flow and feed the effects and events back into the typed `applyWithEvents(...)` helper. + +The package intentionally separates transaction construction from submission so browser apps, wallet integrations, and server-side signing flows can keep transport and execution policy outside the package. + +## Minimal TypeScript shape + +```ts +import { AuditTrailClientReadOnly } from "@iota/audit-trails"; + +const client = await AuditTrailClientReadOnly.create(iotaClient); +const trail = client.trail(trailId); +const state = await trail.get(); + +console.log(state.sequenceNumber); +``` + +## Build + +```bash +npm install +npm run build +``` + +## Examples + +See [examples/README.md](./examples/README.md) for runnable node and web example flows. diff --git a/bindings/wasm/audit_trail_wasm/cypress.config.ts b/bindings/wasm/audit_trail_wasm/cypress.config.ts new file mode 100644 index 00000000..481c7412 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + screenshotOnRunFailure: false, + video: false, + requestTimeout: 10000, + defaultCommandTimeout: 60000, + retries: { + runMode: 3, + }, + e2e: { + baseUrl: "http://localhost:5173", + supportFile: false, + setupNodeEvents(on, config) { + on("before:browser:launch", (browser, launchOptions) => { + if (browser.family === "firefox") { + // Fix to make subtle crypto work in cypress firefox + // https://github.com/cypress-io/cypress/issues/18217 + launchOptions.preferences[ + "network.proxy.testing_localhost_is_secure_when_hijacked" + ] = true; + // Temporary fix to allow cypress to control Firefox via CDP + // https://github.com/cypress-io/cypress/issues/29713 + // https://fxdx.dev/deprecating-cdp-support-in-firefox-embracing-the-future-with-webdriver-bidi/ + launchOptions.preferences[ + "remote.active-protocols" + ] = 3; + } + return launchOptions; + }); + }, + }, +}); diff --git a/bindings/wasm/audit_trail_wasm/cypress/Dockerfile b/bindings/wasm/audit_trail_wasm/cypress/Dockerfile new file mode 100644 index 00000000..c1b0cd24 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/Dockerfile @@ -0,0 +1,27 @@ +FROM cypress/browsers:latest + +ARG IOTA_AUDIT_TRAIL_PKG_ID + +ENV IOTA_AUDIT_TRAIL_PKG_ID=$IOTA_AUDIT_TRAIL_PKG_ID + +ARG IOTA_TF_COMPONENTS_PKG_ID + +ENV IOTA_TF_COMPONENTS_PKG_ID=$IOTA_TF_COMPONENTS_PKG_ID + +ARG NETWORK_NAME_FAUCET + +ENV NETWORK_NAME_FAUCET=$NETWORK_NAME_FAUCET + +ARG NETWORK_URL + +ENV NETWORK_URL=$NETWORK_URL + +COPY ./ /e2e + +WORKDIR /e2e/audit_trail_wasm + +RUN npm ci + +RUN npm run build:examples:web + +ENTRYPOINT [ "npm", "run" ] \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/index.html b/bindings/wasm/audit_trail_wasm/cypress/app/index.html new file mode 100644 index 00000000..5d4406c0 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/index.html @@ -0,0 +1,24 @@ + + + + + + + Audit Trail Example App + + +

+ + + \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/package-lock.json b/bindings/wasm/audit_trail_wasm/cypress/app/package-lock.json new file mode 100644 index 00000000..d117eb2a --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/package-lock.json @@ -0,0 +1,3286 @@ +{ + "name": "vite-project", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vite-project", + "version": "0.0.0", + "dependencies": { + "@iota/audit-trails": "file:../..", + "@iota/iota-sdk": "^1.0.0" + }, + "devDependencies": { + "typescript": "~5.7.2", + "vite": "^6.2.0", + "vite-plugin-node-polyfills": "^0.24.0" + } + }, + "../..": { + "name": "@iota/audit-trails", + "version": "0.1.0-alpha", + "license": "Apache-2.0", + "dependencies": { + "@iota/iota-interaction-ts": "^0.12.0" + }, + "devDependencies": { + "@types/mocha": "^9.1.0", + "@types/node": "^22.0.0", + "cypress": "^14.2.0", + "dprint": "^0.33.0", + "mocha": "^9.2.0", + "rimraf": "^6.0.1", + "start-server-and-test": "^2.0.11", + "ts-mocha": "^9.0.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.1.0", + "typedoc": "^0.28.5", + "typedoc-plugin-markdown": "^4.4.1", + "typescript": "^5.7.3", + "wasm-opt": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@iota/iota-sdk": "^1.11.0" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", + "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "node_modules/@0no-co/graphqlsp": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@0no-co/graphqlsp/-/graphqlsp-1.15.3.tgz", + "integrity": "sha512-rap58Wh1qbRnGpPGwB60P6rvKF6G+mgo1kPeDySWIAcqkGMjuyQdrZPcHS6w7mKOT8i/f1UQmjow6+7vfuEXKw==", + "license": "MIT", + "dependencies": { + "@gql.tada/internal": "^1.0.0", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@gql.tada/cli-utils": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@gql.tada/cli-utils/-/cli-utils-1.7.3.tgz", + "integrity": "sha512-3iQY5E/jvv3Lnh6D1Mh7zr+Bb9C/TGk1DHkm+lbIjQBnZAu2m+BcTcr1e3spUt6Aa6HG/xAN2XxpbWw9oZALEg==", + "license": "MIT", + "dependencies": { + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/internal": "1.0.9", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/svelte-support": "1.0.2", + "@gql.tada/vue-support": "1.0.2", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@gql.tada/svelte-support": { + "optional": true + }, + "@gql.tada/vue-support": { + "optional": true + } + } + }, + "node_modules/@gql.tada/internal": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@gql.tada/internal/-/internal-1.0.9.tgz", + "integrity": "sha512-Bp8yi+kLrzIJ3l5Dfxhz48H4OCH2LCX+pShaPcJgh+oiBt6clrjUKDYNDD3Z78aDQ3+Tyrxe4dd0MfLgpSLPPg==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.5" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@iota/audit-trails": { + "resolved": "../..", + "link": true + }, + "node_modules/@iota/bcs": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@iota/bcs/-/bcs-1.5.0.tgz", + "integrity": "sha512-/hv395YtUcRNLY00v7Cl2O+KvVUaUajg4OucZENgSE4Xu1ygUGsLD3dU5FixOUVOn7Abo+n7+KYr9PE/1dsvWg==", + "license": "Apache-2.0", + "dependencies": { + "@scure/base": "^1.2.4" + } + }, + "node_modules/@iota/iota-sdk": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@iota/iota-sdk/-/iota-sdk-1.11.0.tgz", + "integrity": "sha512-Fveg/4euheaBUzU1ybPyFGe7sSfLFUjLNHhPjNFUmSBOMR+l9q3LU1QdN2sLElcmgJZ+BLxAEmL8TZ0eX3Khpw==", + "license": "Apache-2.0", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "@iota/bcs": "1.5.0", + "@noble/curves": "^1.4.2", + "@noble/hashes": "^1.4.0", + "@scure/base": "^1.2.4", + "@scure/bip32": "^1.4.0", + "@scure/bip39": "^1.3.0", + "bignumber.js": "^9.1.1", + "gql.tada": "^1.8.2", + "graphql": "^16.9.0", + "valibot": "^1.2.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rollup/plugin-inject": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", + "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/browser-resolve": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz", + "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.17.0" + } + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "node_modules/browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", + "dev": true, + "license": "ISC", + "dependencies": { + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.6.1", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.9", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-sign/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-sign/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/console-browserify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true + }, + "node_modules/constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + } + }, + "node_modules/create-ecdh/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crypto-browserify": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/diffie-hellman/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/domain-browser": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-4.22.0.tgz", + "integrity": "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gql.tada": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/gql.tada/-/gql.tada-1.9.2.tgz", + "integrity": "sha512-QxRHVpxtrOVdYXz6oavq0lBM+Zdp0swapLGJcD4SLpXDcsD337BHDFrzqqjfkbepv0sSAiO0LGabu1kI5D5Gyg==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.5", + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/cli-utils": "1.7.3", + "@gql.tada/internal": "1.0.9" + }, + "bin": { + "gql-tada": "bin/cli.js", + "gql.tada": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", + "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isomorphic-timers-promises": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-timers-promises/-/isomorphic-timers-promises-1.0.1.tgz", + "integrity": "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/miller-rabin/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-stdlib-browser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-stdlib-browser/-/node-stdlib-browser-1.3.1.tgz", + "integrity": "sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert": "^2.0.0", + "browser-resolve": "^2.0.0", + "browserify-zlib": "^0.2.0", + "buffer": "^5.7.1", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "create-require": "^1.1.1", + "crypto-browserify": "^3.12.1", + "domain-browser": "4.22.0", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "isomorphic-timers-promises": "^1.0.1", + "os-browserify": "^0.3.0", + "path-browserify": "^1.0.1", + "pkg-dir": "^5.0.0", + "process": "^0.11.10", + "punycode": "^1.4.1", + "querystring-es3": "^0.2.1", + "readable-stream": "^3.6.0", + "stream-browserify": "^3.0.0", + "stream-http": "^3.2.0", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.1", + "url": "^0.11.4", + "util": "^0.12.4", + "vm-browserify": "^1.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-asn1": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "pbkdf2": "^3.1.5", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pbkdf2": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", + "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/public-encrypt/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/ripemd160/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ripemd160/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dev": true, + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/stream-http": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz", + "integrity": "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "xtend": "^4.0.2" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/timers-browserify": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", + "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "setimmediate": "^1.0.4" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/tty-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", + "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-node-polyfills": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.24.0.tgz", + "integrity": "sha512-GA9QKLH+vIM8NPaGA+o2t8PDfFUl32J8rUp1zQfMKVJQiNkOX4unE51tR6ppl6iKw5yOrDAdSH7r/UIFLCVhLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-inject": "^5.0.5", + "node-stdlib-browser": "^1.2.0" + }, + "funding": { + "url": "https://github.com/sponsors/davidmyersdev" + }, + "peerDependencies": { + "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/package.json b/bindings/wasm/audit_trail_wasm/cypress/app/package.json new file mode 100644 index 00000000..8d53977f --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/package.json @@ -0,0 +1,20 @@ +{ + "name": "vite-project", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.7.2", + "vite": "^6.2.0", + "vite-plugin-node-polyfills": "^0.24.0" + }, + "dependencies": { + "@iota/iota-sdk": "^1.0.0", + "@iota/audit-trails": "file:../.." + } +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts new file mode 100644 index 00000000..62a69656 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts @@ -0,0 +1,18 @@ +import url from "@iota/audit-trails/web/audit_trail_wasm_bg.wasm?url"; + +import { init } from "@iota/audit-trails/web"; +import { main } from "../../../examples/dist/web/web-main"; + +export const runTest = async (example: string) => { + try { + await main(example); + console.log("success"); + } catch (error) { + throw error; + } +}; + +init(url) + .then(() => { + console.log("init"); + }); diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts new file mode 100644 index 00000000..e8945451 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts @@ -0,0 +1,3 @@ +import { runTest } from "./audit_trail"; + +globalThis.runTest = runTest; diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts new file mode 100644 index 00000000..01691ca6 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// +declare const IOTA_AUDIT_TRAIL_PKG_ID: string; +declare const IOTA_TF_COMPONENTS_PKG_ID: string; +declare const NETWORK_NAME_FAUCET: string; +declare const ENV_NETWORK_URL: string; +declare const runTest: (example: string) => Promise; + +declare global { + var runTest: (example: string) => Promise; +} + +export {}; diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/tsconfig.json b/bindings/wasm/audit_trail_wasm/cypress/app/tsconfig.json new file mode 100644 index 00000000..9469f855 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js b/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js new file mode 100644 index 00000000..305644fb --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js @@ -0,0 +1,38 @@ +import { defineConfig } from "vite"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +export default defineConfig(({ command, mode }) => { + // variables will be set during build time + const EXPOSED_ENVS = [ + "IOTA_AUDIT_TRAIL_PKG_ID", + "IOTA_TF_COMPONENTS_PKG_ID", + "NETWORK_NAME_FAUCET", + "NETWORK_URL", + ]; + + return { + plugins: [ + nodePolyfills({ + include: ["assert"], + }), + ], + define: EXPOSED_ENVS.reduce((prev, env_var) => { + const var_value = globalThis?.process?.env?.[env_var]; + if (var_value) { + console.log("exposing", env_var, var_value); + prev[`process.env.${env_var}`] = JSON.stringify(var_value); + } + return prev; + }, {}), + server: { + // open on default port or fail to make CI consistent + strictPort: true, + }, + build: { + rollupOptions: { + output: { + interop: "auto", + }, + }, + }, + }; +}); diff --git a/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js b/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js new file mode 100644 index 00000000..960ab311 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js @@ -0,0 +1,35 @@ +const { _ } = Cypress; + +describe( + "Test Examples", + () => { + const examples = [ + "01_create_audit_trail", + "02_add_and_read_records", + "03_update_metadata", + "04_configure_locking", + "05_manage_access", + "06_delete_records", + "07_access_read_only_methods", + "08_delete_audit_trail", + "09_tagged_records", + "10_capability_constraints", + "11_manage_record_tags", + "01_customs_clearance", + "02_clinical_trial", + ]; + + _.each(examples, (example) => { + it(example, () => { + cy.visit("/", { + onBeforeLoad(win) { + cy.stub(win.console, "log").as("consoleLog"); + }, + }); + cy.get("@consoleLog").should("be.calledWith", "init"); + cy.window({ timeout: 180000 }).then({ timeout: 180000 }, (win) => win.runTest(example)); + cy.get("@consoleLog", { timeout: 180000 }).should("be.calledWith", "success"); + }); + }); + }, +); diff --git a/bindings/wasm/audit_trail_wasm/examples/README.md b/bindings/wasm/audit_trail_wasm/examples/README.md new file mode 100644 index 00000000..6d1b9f1a --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/README.md @@ -0,0 +1,72 @@ +# IOTA Audit Trails WASM Examples + +The examples in this folder demonstrate how to use the `@iota/audit-trails` package. + +## Environment + +Set the following environment variables before running the node examples: + +| Name | Required | Description | +| --------------------------- | ------------------- | --------------------------------------------------------- | +| `IOTA_AUDIT_TRAIL_PKG_ID` | yes | Package ID of the deployed `IotaAuditTrails` Move package | +| `IOTA_TF_COMPONENTS_PKG_ID` | local/custom setups | Package ID of the deployed `TfComponents` package | +| `NETWORK_URL` | yes | RPC URL of the IOTA node | +| `NETWORK_NAME_FAUCET` | local/test networks | Faucet alias used by `@iota/iota-sdk` | + +## Run + +Install dependencies and build the package: + +```bash +npm install +npm run build +``` + +Run an example: + +```bash +IOTA_AUDIT_TRAIL_PKG_ID= \ +IOTA_TF_COMPONENTS_PKG_ID= \ +NETWORK_URL=http://127.0.0.1:9000 \ +npm run example:node -- 01_create_audit_trail +``` + +### Localnet + +On localnet the publish script emits the required `export` statements directly. Use `eval` to set both variables in one step (run from the `audit_trail_wasm/` directory): + +```bash +eval $(../../../audit-trail-move/scripts/publish_package.sh) +npm run example:node -- 01_create_audit_trail +``` + +Available examples: + +### Core + +| Name | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `01_create_audit_trail` | Creates an audit trail, defines a RecordAdmin role, and issues a capability for it | +| `02_add_and_read_records` | Adds follow-up records, reads them individually and through paginated reads | +| `03_update_metadata` | Updates and clears mutable metadata while preserving immutable metadata via a MetadataAdmin role | +| `04_configure_locking` | Configures write and delete locks, demonstrates that locks block record creation | +| `05_manage_access` | Creates and updates a role, then demonstrates constrained capability issuance, revoke and destroy flows, denylist cleanup, and final role removal | +| `06_delete_records` | Deletes individual records and batch-deletes remaining records | +| `07_access_read_only_methods` | Reads trail metadata, record counts, pagination, and lock status | +| `08_delete_audit_trail` | Shows that non-empty trails cannot be deleted, batch-deletes records, then deletes the trail | + +### Advanced + +| Name | Description | +| --------------------------- | -------------------------------------------------------------------------------------- | +| `09_tagged_records` | Uses role tags and address-bound capabilities to restrict who may add tagged records | +| `10_capability_constraints` | Shows address-bound capability use and how revocation immediately blocks future writes | +| `11_manage_record_tags` | Delegates tag management, adds/removes tags, shows that in-use tags cannot be removed | + +### Real-World + +| Name | Description | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `01_customs_clearance` | Models customs clearance with role-tag restrictions, delegated capabilities, denied inspection writes, and a final write lock | +| `02_clinical_trial` | Models a clinical trial with time-constrained capabilities, mid-study tag addition, deletion windows, time-locks, and regulator verification | +| `03_digital_product_passport` | Models a Digital Product Passport for an e-bike battery with lifecycle-scoped actors, technician access approval, an annual maintenance event, and documented Lifecycle Credit reward evidence | diff --git a/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts new file mode 100644 index 00000000..4e6e8bb6 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts @@ -0,0 +1,59 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail and holds the built-in Admin capability that is + * automatically minted on creation. + * - **Record admin client**: Receives a RecordAdmin capability bound to their address. Writes + * records in subsequent examples. + * + * Demonstrates how to: + * 1. Create an audit trail with immutable metadata, updatable metadata, and a seed record. + * 2. Inspect the built-in Admin role. + * 3. Define a RecordAdmin role and issue a capability for it. + */ + +import { CapabilityIssueOptions, PermissionSet } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function createAuditTrail(): Promise { + console.log("Creating an audit trail"); + + // `adminClient` creates the trail and holds the Admin capability. + // `recordAdminClient` receives the delegated RecordAdmin capability. + const adminClient = await getFundedClient(); + const recordAdminClient = await getFundedClient(); + + console.log("Admin client address: ", adminClient.senderAddress()); + console.log("Record admin client address: ", recordAdminClient.senderAddress()); + + const { output: createdTrail, response } = await createTrailWithSeedRecord(adminClient); + + console.log(`Created trail ${createdTrail.id} with transaction ${response.digest}`); + console.log("Immutable metadata:", createdTrail.immutableMetadata); + console.log("Updatable metadata:", createdTrail.updatableMetadata); + console.log("Locking config:", createdTrail.lockingConfig); + + assert.equal(createdTrail.sequenceNumber, 1n); + assert.ok(createdTrail.immutableMetadata); + assert.equal(createdTrail.immutableMetadata?.name, "Example Audit Trail"); + + // Admin capability authorization is implicit: adminClient owns the built-in Admin capability. + const recordAdminRole = adminClient.trail(createdTrail.id).access().forRole("RecordAdmin"); + await recordAdminRole + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await recordAdminRole + .issueCapability(new CapabilityIssueOptions(recordAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const onChainTrail = await adminClient.trail(createdTrail.id).get(); + const roleNames = onChainTrail.roles.roles.map((r) => r.name); + console.log("Roles:", roleNames); + assert.ok(roleNames.includes("RecordAdmin")); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts new file mode 100644 index 00000000..da9d8946 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts @@ -0,0 +1,75 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability. + * - **Record admin client**: Holds the capability and writes records. Reads use the same client + * to keep the example focused after delegation. + * + * Demonstrates how to: + * 1. Add follow-up records to a trail. + * 2. Read them back individually by sequence number. + * 3. Paginate through records. + */ + +import { CapabilityIssueOptions, Data, PermissionSet } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function addAndReadRecords(): Promise { + console.log("Adding records and reading them back with pagination"); + + // `adminClient` creates the trail and delegates record writes. + // `recordAdminClient` holds the capability and writes/reads records. + const adminClient = await getFundedClient(); + const recordAdminClient = await getFundedClient(); + + const { output: createdTrail } = await createTrailWithSeedRecord(adminClient); + const trailId = createdTrail.id; + + const recordAdminRole = adminClient.trail(trailId).access().forRole("RecordAdmin"); + await recordAdminRole + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await recordAdminRole + .issueCapability(new CapabilityIssueOptions(recordAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + // Capability selection is automatic from recordAdminClient's wallet. + const recordAdminRecords = recordAdminClient.trail(trailId).records(); + + const addedSecondRecord = await recordAdminRecords + .add(Data.fromString("record 2"), "second") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(recordAdminClient); + const addedThirdRecord = await recordAdminRecords + .add(Data.fromString("record 3"), "third") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(recordAdminClient); + + console.log("Added records:", addedSecondRecord.output, addedThirdRecord.output); + + const seedRecord = await recordAdminRecords.get(0n); + const secondRecord = await recordAdminRecords.get(addedSecondRecord.output.sequenceNumber); + assert.equal(seedRecord.data.toString(), "seed record"); + assert.equal(secondRecord.data.toString(), "record 2"); + + const count = await recordAdminRecords.recordCount(); + console.log(`Current record count: ${count}`); + assert.equal(count, 3n, `expected 3 records, got ${count}`); + + // Pagination uses the previous page cursor to continue from the next record. + const firstPage = await recordAdminRecords.listPage(undefined, 2); + const secondPage = await recordAdminRecords.listPage(firstPage.nextCursor, 2); + + console.log("First page:", firstPage); + console.log("Second page:", secondPage); + + assert.equal(firstPage.records.length, 2); + assert.equal(firstPage.hasNextPage, true); + assert.equal(secondPage.records.length, 1); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts new file mode 100644 index 00000000..e72daa39 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts @@ -0,0 +1,86 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail and sets up the MetadataAdmin role. + * - **Metadata admin client**: Holds the MetadataAdmin capability and updates the trail's mutable + * status field. Has no record-write permissions. + * + * Demonstrates how to: + * 1. Create a trail with immutable and updatable metadata. + * 2. Delegate metadata updates through a dedicated MetadataAdmin role. + * 3. Change and clear the trail's updatable metadata. + * 4. Verify that immutable metadata never changes. + */ + +import { CapabilityIssueOptions, PermissionSet } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function updateMetadata(): Promise { + console.log("=== Audit Trail: Update Metadata ===\n"); + + // `adminClient` creates the trail and delegates metadata updates. + // `metadataAdminClient` holds the MetadataAdmin capability and updates the status. + const adminClient = await getFundedClient(); + const metadataAdminClient = await getFundedClient(); + + const { output: createdTrail } = await adminClient + .createTrail() + .withTrailMetadata("Shipment Processing", "Tracks the lifecycle of a warehouse shipment") + .withUpdatableMetadata("Status: Draft") + .withInitialRecordString("Shipment created", "event:created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailId = createdTrail.id; + + // Delegate metadata updates to a MetadataAdmin role. + const metadataAdminRole = adminClient.trail(trailId).access().forRole("MetadataAdmin"); + await metadataAdminRole + .create(PermissionSet.metadataAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await metadataAdminRole + .issueCapability(new CapabilityIssueOptions(metadataAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailBeforeUpdate = await adminClient.trail(trailId).get(); + console.log("Before update:"); + console.log(" immutable =", trailBeforeUpdate.immutableMetadata); + console.log(" updatable =", trailBeforeUpdate.updatableMetadata, "\n"); + + // MetadataAdmin updates the mutable metadata. + await metadataAdminClient + .trail(trailId) + .updateMetadata("Status: In Review") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(metadataAdminClient); + + const trailAfterUpdate = await adminClient.trail(trailId).get(); + console.log("After update:"); + console.log(" immutable =", trailAfterUpdate.immutableMetadata); + console.log(" updatable =", trailAfterUpdate.updatableMetadata, "\n"); + + assert.equal(trailAfterUpdate.immutableMetadata?.name, "Shipment Processing"); + assert.equal(trailAfterUpdate.updatableMetadata, "Status: In Review"); + + // MetadataAdmin clears the mutable metadata. + await metadataAdminClient + .trail(trailId) + .updateMetadata(undefined) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(metadataAdminClient); + + const trailAfterClear = await adminClient.trail(trailId).get(); + console.log("After clear:"); + console.log(" immutable =", trailAfterClear.immutableMetadata); + console.log(" updatable =", trailAfterClear.updatableMetadata); + + assert.equal(trailAfterClear.immutableMetadata?.name, "Shipment Processing"); + assert.equal(trailAfterClear.updatableMetadata, undefined); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts new file mode 100644 index 00000000..1190929b --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts @@ -0,0 +1,123 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail and sets up the LockingAdmin and RecordAdmin roles. + * - **Locking admin client**: Controls write and delete locks. Holds the LockingAdmin capability. + * - **Record admin client**: Writes records. Used to demonstrate that the write lock is enforced + * per-sender, not just checked by the admin client. + * + * Demonstrates how to: + * 1. Delegate locking updates through a LockingAdmin role. + * 2. Freeze record creation with a write lock. + * 3. Restore writes and add a new record. + * 4. Update the delete-record window and delete-trail lock. + */ + +import { + CapabilityIssueOptions, + Data, + LockingConfig, + LockingWindow, + PermissionSet, + TimeLock, +} from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function configureLocking(): Promise { + console.log("=== Audit Trail: Configure Locking ===\n"); + + // `adminClient` creates the trail and delegates separate lock/write authority. + // `lockingAdminClient` controls locks; `recordAdminClient` writes records. + const adminClient = await getFundedClient(); + const lockingAdminClient = await getFundedClient(); + const recordAdminClient = await getFundedClient(); + + const { output: createdTrail } = await createTrailWithSeedRecord(adminClient); + const trailId = createdTrail.id; + + const lockingAdminRole = adminClient.trail(trailId).access().forRole("LockingAdmin"); + await lockingAdminRole + .create(PermissionSet.lockingAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await lockingAdminRole + .issueCapability(new CapabilityIssueOptions(lockingAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const recordAdminRole = adminClient.trail(trailId).access().forRole("RecordAdmin"); + await recordAdminRole + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await recordAdminRole + .issueCapability(new CapabilityIssueOptions(recordAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + // LockingAdmin freezes writes. + await lockingAdminClient + .trail(trailId) + .locking() + .updateWriteLock(TimeLock.withInfinite()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lockingAdminClient); + + const lockedTrail = await adminClient.trail(trailId).get(); + console.log("Write lock after update:", lockedTrail.lockingConfig.writeLock, "\n"); + assert.equal(lockedTrail.lockingConfig.writeLock.type, TimeLock.withInfinite().type); + + // RecordAdmin attempts to add a record while locked — should fail. + const blockedAdd = await recordAdminClient + .trail(trailId) + .records() + .add(Data.fromString("This write should fail"), "blocked") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(recordAdminClient) + .catch(() => null); + assert.equal(blockedAdd, null, "write lock should block adding records"); + + // LockingAdmin lifts the write lock. + await lockingAdminClient + .trail(trailId) + .locking() + .updateWriteLock(TimeLock.withNone()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lockingAdminClient); + + const recordAddedAfterUnlock = await recordAdminClient + .trail(trailId) + .records() + .add(Data.fromString("Write lock lifted"), "event:resumed") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(recordAdminClient); + console.log("Added record", recordAddedAfterUnlock.output.sequenceNumber, "after clearing the write lock.\n"); + + // LockingAdmin configures deletion window and trail lock. + await lockingAdminClient + .trail(trailId) + .locking() + .updateDeleteRecordWindow(LockingWindow.withCountBased(BigInt(2))) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lockingAdminClient); + await lockingAdminClient + .trail(trailId) + .locking() + .updateDeleteTrailLock(TimeLock.withInfinite()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lockingAdminClient); + + const finalTrail = await adminClient.trail(trailId).get(); + console.log("Final locking config:"); + console.log(" delete_record_window =", finalTrail.lockingConfig.deleteRecordWindow); + console.log(" delete_trail_lock =", finalTrail.lockingConfig.deleteTrailLock); + console.log(" write_lock =", finalTrail.lockingConfig.writeLock); + + assert.equal(finalTrail.lockingConfig.deleteRecordWindow.type, LockingWindow.withCountBased(BigInt(2)).type); + assert.equal(finalTrail.lockingConfig.deleteTrailLock.type, TimeLock.withInfinite().type); + assert.equal(finalTrail.lockingConfig.writeLock.type, TimeLock.withNone().type); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts new file mode 100644 index 00000000..c99afe7d --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -0,0 +1,138 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates and updates roles, issues capabilities, revokes and destroys them, + * and finally deletes the role once it is no longer needed. + * - **Operations user client**: The subject of all capability issuance. Capabilities are bound to + * this address to demonstrate that revocation immediately blocks their access. + * + * Demonstrates how to: + * 1. Create and update a custom role. + * 2. Issue a constrained capability for that role. + * 3. Revoke one capability and destroy another. + * 4. Remove the role after its capabilities are no longer needed. + */ + +import { CapabilityIssueOptions, Permission, PermissionSet } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function manageAccess(): Promise { + console.log("=== Audit Trail: Manage Access ===\n"); + + // `adminClient` manages roles and the full capability lifecycle. + // `operationsUserClient` is the target of constrained capability issuance. + const adminClient = await getFundedClient(); + const operationsUserClient = await getFundedClient(); + + const { output: createdTrail } = await createTrailWithSeedRecord(adminClient); + const trailId = createdTrail.id; + + const createdOperationsRole = await adminClient + .trail(trailId) + .access() + .forRole("Operations") + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + console.log("Created role:", createdOperationsRole.output.role, "\n"); + + // 2. Update the role permissions + const updatedPermissionValues = [ + Permission.AddRecord, + Permission.DeleteRecord, + Permission.DeleteAllRecords, + ]; + const updatedPermissions = new PermissionSet(updatedPermissionValues); + const updatedOperationsRole = await adminClient + .trail(trailId) + .access() + .forRole("Operations") + .updatePermissions(updatedPermissions) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + console.log( + "Updated role permissions:", + updatedOperationsRole.output.permissions.permissions.map((p) => p.toString()), + ); + + // 3. Issue a capability bound to operationsUserClient's address and expiry window. + const constrainedOperationsCapability = await adminClient + .trail(trailId) + .access() + .forRole("Operations") + .issueCapability( + new CapabilityIssueOptions(operationsUserClient.senderAddress(), undefined, BigInt(4_102_444_800_000)), + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + console.log("\nIssued constrained capability:"); + console.log(" id =", constrainedOperationsCapability.output.capabilityId); + console.log(" issued_to =", constrainedOperationsCapability.output.issuedTo); + console.log(" valid_until =", constrainedOperationsCapability.output.validUntil, "\n"); + + // Verify the on-chain role matches the updated permissions. + const onChainTrail = await adminClient.trail(trailId).get(); + const operationsRole = onChainTrail.roles.roles.find((r) => r.name === "Operations"); + assert.ok(operationsRole, "Operations role must exist"); + const operationsPermissionSet = new Set(operationsRole?.permissions.map((p) => p.toString())); + for (const perm of updatedPermissionValues) { + assert(operationsPermissionSet.has(perm.toString()), `role should contain ${perm}`); + } + + // 4. Revoke the constrained capability. + await adminClient + .trail(trailId) + .access() + .revokeCapability( + constrainedOperationsCapability.output.capabilityId, + constrainedOperationsCapability.output.validUntil, + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + console.log("Revoked capability", constrainedOperationsCapability.output.capabilityId, "\n"); + + // 5. Issue a disposable capability to the Admin actor and destroy it. + // destroyCapability consumes the capability object, so the signer must own it. + // The capability is issued to adminClient so adminClient can destroy it directly. + const disposableOperationsCapability = await adminClient + .trail(trailId) + .access() + .forRole("Operations") + .issueCapability(new CapabilityIssueOptions(adminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await adminClient + .trail(trailId) + .access() + .destroyCapability(disposableOperationsCapability.output.capabilityId) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + console.log("Destroyed capability", disposableOperationsCapability.output.capabilityId, "\n"); + + // 6. Clean up the revoked-capability registry entry so the role can be removed. + await adminClient + .trail(trailId) + .access() + .cleanupRevokedCapabilities() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + console.log("Cleaned up revoked capability registry entries.\n"); + + // 7. Delete the role. + await adminClient + .trail(trailId) + .access() + .forRole("Operations") + .delete() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + const trailAfterDelete = await adminClient.trail(trailId).get(); + const operationsRoleAfterDelete = trailAfterDelete.roles.roles.find((r) => r.name === "Operations"); + assert.equal(operationsRoleAfterDelete, undefined, "role should be removed from the trail"); + + console.log("Removed the custom role after its capability lifecycle completed."); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts new file mode 100644 index 00000000..de5e6370 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts @@ -0,0 +1,93 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail and sets up the RecordMaintenance role. + * - **Maintenance admin client**: Holds the RecordMaintenance capability. Adds records and then + * deletes them individually and in batch. + * + * Demonstrates how to: + * 1. Create records using a delegated record-maintenance role. + * 2. Delete a single record by sequence number. + * 3. Delete the remaining records in one batch. + */ + +import { CapabilityIssueOptions, Data, Permission, PermissionSet } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function deleteRecords(): Promise { + console.log("=== Audit Trail: Delete Records ===\n"); + + // Use a maintenance client to show deletes happening through a delegated capability. + const adminClient = await getFundedClient(); + const maintenanceAdminClient = await getFundedClient(); + + const { output: createdTrail } = await adminClient + .createTrail() + .withInitialRecordString("Initial record", "event:created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailId = createdTrail.id; + + const recordMaintenanceRole = adminClient.trail(trailId).access().forRole("RecordMaintenance"); + await recordMaintenanceRole + .create(new PermissionSet([Permission.AddRecord, Permission.DeleteRecord, Permission.DeleteAllRecords])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await recordMaintenanceRole + .issueCapability(new CapabilityIssueOptions(maintenanceAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const maintenanceRecords = maintenanceAdminClient.trail(trailId).records(); + + const firstAddedRecord = await maintenanceRecords + .add(Data.fromString("Second record"), "event:received") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(maintenanceAdminClient); + const secondAddedRecord = await maintenanceRecords + .add(Data.fromString("Third record"), "event:dispatched") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(maintenanceAdminClient); + + console.log( + "Trail has records at sequence numbers 0,", + firstAddedRecord.output.sequenceNumber, + ",", + secondAddedRecord.output.sequenceNumber, + ); + assert.equal(await maintenanceRecords.recordCount(), 3n); + + const deletedRecord = await maintenanceRecords + .delete(firstAddedRecord.output.sequenceNumber) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(maintenanceAdminClient); + console.log("Deleted record", deletedRecord.output.sequenceNumber); + + let recordCount = await maintenanceRecords.recordCount(); + console.log("Record count after single delete:", recordCount); + assert.equal(recordCount, 2n); + await assert.rejects( + () => maintenanceRecords.get(firstAddedRecord.output.sequenceNumber), + "deleted record should no longer be readable", + ); + + // Batch delete skips locked records and returns the deleted sequence numbers. + const deletedRemaining = await maintenanceRecords + .deleteBatch(BigInt(10)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(maintenanceAdminClient); + + console.log("Batch deleted the remaining sequence numbers:", deletedRemaining.output); + assert.deepEqual(Array.from(deletedRemaining.output), [ + 0n, + secondAddedRecord.output.sequenceNumber, + ]); + recordCount = await maintenanceRecords.recordCount(); + assert.equal(recordCount, 0n); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts new file mode 100644 index 00000000..65f0a092 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts @@ -0,0 +1,96 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail and sets up the RecordAdmin role. + * - **Record admin client**: Adds one follow-up record. All subsequent operations are read-only + * and can be performed by any address — no capability required. + * + * Demonstrates how to: + * 1. Load the full on-chain trail object. + * 2. Inspect metadata, roles, and locking configuration. + * 3. Read records individually and through pagination. + * 4. Query the record-count and lock-status helpers. + */ + +import { + CapabilityIssueOptions, + Data, + LockingConfig, + LockingWindow, + PermissionSet, + TimeLock, +} from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function accessReadOnlyMethods(): Promise { + console.log("=== Audit Trail: Read-Only Inspection ===\n"); + + // `adminClient` creates the trail and delegates one record write. + // `recordAdminClient` adds the follow-up record. + const adminClient = await getFundedClient(); + const recordAdminClient = await getFundedClient(); + + const { output: createdTrail } = await adminClient + .createTrail() + .withTrailMetadata("Operations Trail", "Used to inspect read-only accessors") + .withUpdatableMetadata("Status: Active") + .withLockingConfig( + new LockingConfig(LockingWindow.withCountBased(BigInt(2)), TimeLock.withNone(), TimeLock.withNone()), + ) + .withInitialRecordString("Initial record", "event:created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailId = createdTrail.id; + + const recordAdminRole = adminClient.trail(trailId).access().forRole("RecordAdmin"); + await recordAdminRole + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await recordAdminRole + .issueCapability(new CapabilityIssueOptions(recordAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + // RecordAdmin adds a follow-up record. + await recordAdminClient + .trail(trailId) + .records() + .add(Data.fromString("Follow-up record"), "event:updated") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(recordAdminClient); + + // All reads below require no capability — any address can inspect the trail. + const onChainTrail = await adminClient.trail(trailId).get(); + console.log("Trail summary:"); + console.log(" id =", onChainTrail.id); + console.log(" creator =", onChainTrail.creator); + console.log(" created_at =", onChainTrail.createdAt); + console.log(" sequence_number =", onChainTrail.sequenceNumber); + console.log(" immutable_metadata =", onChainTrail.immutableMetadata); + console.log(" updatable_metadata =", onChainTrail.updatableMetadata, "\n"); + + console.log("Roles:", onChainTrail.roles.roles.map((r) => r.name)); + console.log("Locking config:", onChainTrail.lockingConfig, "\n"); + + const trailHandle = adminClient.trail(trailId); + const count = await trailHandle.records().recordCount(); + const initialRecord = await trailHandle.records().get(0n); + const firstPage = await trailHandle.records().listPage(undefined, 10); + const recordZeroLocked = await trailHandle.locking().isRecordLocked(0n); + + console.log("Record count:", count); + console.log("Record #0:", initialRecord); + console.log("First page size:", firstPage.records.length, "(has_next_page =", firstPage.hasNextPage, ")"); + console.log("Is record #0 locked?", recordZeroLocked); + + assert.equal(count, 2n); + assert.equal(initialRecord.data.toString(), "Initial record"); + assert.equal(firstPage.records.length, 2); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts new file mode 100644 index 00000000..3dcf7a53 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts @@ -0,0 +1,89 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail and sets up the MaintenanceAdmin role. + * - **Maintenance admin client**: Holds delete permissions. Attempts (and fails) to delete the non-empty trail, then + * batch-deletes all records before removing the trail itself. + * + * Demonstrates how to: + * 1. Show that a non-empty trail cannot be deleted. + * 2. Empty the trail with deleteBatch. + * 3. Delete the trail once its records are gone. + */ + +import { CapabilityIssueOptions, Data, Permission, PermissionSet } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function deleteAuditTrail(): Promise { + console.log("=== Audit Trail: Delete Trail ===\n"); + + // Use a maintenance client to keep deletion permissions separate from trail creation. + const adminClient = await getFundedClient(); + const maintenanceAdminClient = await getFundedClient(); + + const { output: createdTrail } = await adminClient + .createTrail() + .withInitialRecordString("Initial record", "event:created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailId = createdTrail.id; + + const maintenanceAdminRole = adminClient.trail(trailId).access().forRole("MaintenanceAdmin"); + await maintenanceAdminRole + .create(new PermissionSet([Permission.DeleteAllRecords, Permission.DeleteAuditTrail])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await maintenanceAdminRole + .issueCapability(new CapabilityIssueOptions(maintenanceAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const maintenanceTrail = maintenanceAdminClient.trail(trailId); + + let deleteWhileNonEmptySucceeded = false; + try { + await maintenanceTrail + .deleteAuditTrail() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(maintenanceAdminClient); + deleteWhileNonEmptySucceeded = true; + } catch { + // Expected + } + assert.equal(deleteWhileNonEmptySucceeded, false, "a trail must be empty before deletion"); + console.log("Deleting the non-empty trail failed as expected.\n"); + + // Batch delete skips locked records and returns the deleted sequence numbers before trail deletion. + const deletedRecords = await maintenanceTrail + .records() + .deleteBatch(BigInt(10)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(maintenanceAdminClient); + console.log("Deleted record sequence numbers", deletedRecords.output, "before trail removal.\n"); + + const count = await maintenanceTrail.records().recordCount(); + assert.equal(count, 0n); + + const deletedTrail = await maintenanceTrail + .deleteAuditTrail() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(maintenanceAdminClient); + console.log("Trail deleted:"); + console.log(" trail_id =", deletedTrail.output.trailId); + console.log(" timestamp =", deletedTrail.output.timestamp); + + let getAfterDeleteSucceeded = false; + try { + await maintenanceTrail.get(); + getAfterDeleteSucceeded = true; + } catch { + // Expected + } + assert.equal(getAfterDeleteSucceeded, false, "deleted trail should no longer be readable"); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts new file mode 100644 index 00000000..61d69d16 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts @@ -0,0 +1,90 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail, defines the FinanceWriter role restricted to the + * `finance` tag, and issues a capability bound to `financeWriterClient`'s address. + * - **Finance writer client**: Holds the address-bound capability. Can add `finance`-tagged + * records but is blocked from writing `legal`-tagged records. + * + * Demonstrates how to: + * 1. Create a trail with a predefined tag registry. + * 2. Define a role that is restricted to one record tag. + * 3. Issue a capability bound to a specific wallet address. + * 4. Show that the holder can add only records matching the allowed tag. + */ + +import { CapabilityIssueOptions, Data, Permission, PermissionSet, RoleTags } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "../util"; + +export async function taggedRecords(): Promise { + console.log("=== Audit Trail Advanced: Tagged Records ===\n"); + + const adminClient = await getFundedClient(); + const financeWriterClient = await getFundedClient(); + + const { output: createdTrail } = await adminClient + .createTrail() + .withRecordTags(["finance", "legal"]) + .withInitialRecordString("Trail created", "event:created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailId = createdTrail.id; + + // The role is scoped to the "finance" tag before the capability is issued. + const financeWriterRole = adminClient.trail(trailId).access().forRole("FinanceWriter"); + await financeWriterRole + .create(new PermissionSet([Permission.AddRecord]), new RoleTags(["finance"])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const financeWriterCapability = await financeWriterRole + .issueCapability(new CapabilityIssueOptions(financeWriterClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + console.log( + "Issued FinanceWriter capability", + financeWriterCapability.output.capabilityId, + "to", + financeWriterClient.senderAddress(), + "\n", + ); + + // Capability selection is automatic from financeWriterClient's wallet. + const financeRecords = financeWriterClient.trail(trailId).records(); + + // Add a record with the allowed tag. + const addedFinanceRecord = await financeRecords + .add(Data.fromString("Invoice approved"), "department:finance", "finance") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(financeWriterClient); + + console.log( + "Added tagged record at sequence number", + addedFinanceRecord.output.sequenceNumber, + "with tag \"finance\".\n", + ); + + // Attempt to add a record with a different tag — should fail. + let wrongTagSucceeded = false; + try { + await financeRecords + .add(Data.fromString("Legal review completed"), "department:legal", "legal") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(financeWriterClient); + wrongTagSucceeded = true; + } catch { + // Expected + } + assert.equal(wrongTagSucceeded, false, "a finance-scoped role must not add a legal-tagged record"); + + const financeRecord = await financeRecords.get(addedFinanceRecord.output.sequenceNumber); + console.log("Stored tagged record:", financeRecord); + assert.equal(financeRecord.tag, "finance"); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts new file mode 100644 index 00000000..2ed733d8 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts @@ -0,0 +1,112 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability + * bound specifically to `intendedWriterClient`'s address. Also performs revocation. + * - **Intended writer client**: The authorised holder. Writes a record successfully before + * revocation, then is blocked after the capability is revoked. + * - **Wrong writer client**: An unauthorised actor who attempts to use the address-bound capability. + * All write attempts are rejected by the Move contract. + * + * Demonstrates how to: + * 1. Bind a capability to a specific wallet address. + * 2. Show that a different wallet cannot use it. + * 3. Revoke the capability and confirm the bound holder can no longer use it. + */ + +import { CapabilityIssueOptions, Data, PermissionSet } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "../util"; + +export async function capabilityConstraints(): Promise { + console.log("=== Audit Trail Advanced: Capability Constraints ===\n"); + + const adminClient = await getFundedClient(); + const intendedWriterClient = await getFundedClient(); + const wrongWriterClient = await getFundedClient(); + + const { output: createdTrail } = await createTrailWithSeedRecord(adminClient); + const trailId = createdTrail.id; + + // Create the role before delegating the address-bound capability. + await adminClient + .trail(trailId) + .access() + .forRole("RecordAdmin") + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const recordAdminCapability = await adminClient + .trail(trailId) + .access() + .forRole("RecordAdmin") + .issueCapability(new CapabilityIssueOptions(intendedWriterClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + console.log( + "Issued capability", + recordAdminCapability.output.capabilityId, + "to", + intendedWriterClient.senderAddress(), + "\n", + ); + + // The wrong wallet should not be able to add a record. + let wrongWriterSucceeded = false; + try { + await wrongWriterClient + .trail(trailId) + .records() + .add(Data.fromString("Wrong writer"), undefined, undefined) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(wrongWriterClient); + wrongWriterSucceeded = true; + } catch { + // Expected + } + assert.equal(wrongWriterSucceeded, false, "a capability bound to another address must not be usable"); + + // The intended writer CAN add a record. + const authorizedRecord = await intendedWriterClient + .trail(trailId) + .records() + .add(Data.fromString("Authorized writer"), undefined, undefined) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(intendedWriterClient); + + console.log("Bound holder added record", authorizedRecord.output.sequenceNumber, "successfully.\n"); + + // Revoke the capability. + await adminClient + .trail(trailId) + .access() + .revokeCapability(recordAdminCapability.output.capabilityId, recordAdminCapability.output.validUntil) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + // The intended writer should no longer be able to add a record. + let revokedSucceeded = false; + try { + await intendedWriterClient + .trail(trailId) + .records() + .add(Data.fromString("Should fail after revoke"), undefined, undefined) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(intendedWriterClient); + revokedSucceeded = true; + } catch { + // Expected + } + assert.equal(revokedSucceeded, false, "revoked capabilities must no longer authorize record writes"); + + console.log( + "Revoked capability", + recordAdminCapability.output.capabilityId, + "and verified it can no longer be used.", + ); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts new file mode 100644 index 00000000..72d7f823 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts @@ -0,0 +1,113 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * ## Actors + * + * - **Admin client**: Creates the trail and manages roles. + * - **Tag admin client**: Holds the TagAdmin capability. Adds and removes entries from the trail's + * tag registry. + * - **Finance writer client**: Holds a `finance`-scoped RecordAdmin capability. Writes a + * `finance`-tagged record that keeps the `finance` tag in use and therefore unremovable. + * + * Demonstrates how to: + * 1. Delegate record-tag registry management to a TagAdmin role. + * 2. Add and remove tags from the trail registry. + * 3. Show that tags still in use by roles or records cannot be removed. + */ + +import { CapabilityIssueOptions, Data, PermissionSet, RoleTags } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "../util"; + +export async function manageRecordTags(): Promise { + console.log("=== Audit Trail Advanced: Manage Record Tags ===\n"); + + // `adminClient` creates the trail and manages roles. + // `tagAdminClient` manages tags; `financeWriterClient` writes tagged records. + const adminClient = await getFundedClient(); + const tagAdminClient = await getFundedClient(); + const financeWriterClient = await getFundedClient(); + + const { output: createdTrail } = await adminClient + .createTrail() + .withRecordTags(["finance"]) + .withInitialRecordString("Trail created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailId = createdTrail.id; + + // Delegate tag management to a TagAdmin role. + const tagAdminRole = adminClient.trail(trailId).access().forRole("TagAdmin"); + await tagAdminRole + .create(PermissionSet.tagAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await tagAdminRole + .issueCapability(new CapabilityIssueOptions(tagAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + // TagAdmin adds a new tag to the registry before any role or record uses it. + await tagAdminClient.trail(trailId).tags().add("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute( + tagAdminClient, + ); + + let onChainTrail = await adminClient.trail(trailId).get(); + console.log("Registry after adding \"legal\":", onChainTrail.tags.map((t) => t.tag), "\n"); + assert.ok(onChainTrail.tags.some((t) => t.tag === "finance")); + assert.ok(onChainTrail.tags.some((t) => t.tag === "legal")); + + // Create a role scoped to the "finance" tag and issue to financeWriterClient. + await adminClient + .trail(trailId) + .access() + .forRole("FinanceWriter") + .create(PermissionSet.recordAdminPermissions(), new RoleTags(["finance"])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await adminClient + .trail(trailId) + .access() + .forRole("FinanceWriter") + .issueCapability(new CapabilityIssueOptions(financeWriterClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + // FinanceWriter adds a record using the "finance" tag. + await financeWriterClient + .trail(trailId) + .records() + .add(Data.fromString("Tagged finance entry"), undefined, "finance") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(financeWriterClient); + + // TagAdmin attempts to remove "finance" tag — should fail because it's in use. + let removeFinanceSucceeded = false; + try { + await tagAdminClient.trail(trailId).tags().remove("finance").withGasBudget(TEST_GAS_BUDGET).buildAndExecute( + tagAdminClient, + ); + removeFinanceSucceeded = true; + } catch { + // Expected + } + assert.equal(removeFinanceSucceeded, false, "a tag referenced by a role or record must not be removable"); + + // TagAdmin removes "legal" tag because nothing uses it. + await tagAdminClient + .trail(trailId) + .tags() + .remove("legal") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(tagAdminClient); + + onChainTrail = await adminClient.trail(trailId).get(); + console.log("Registry after removing \"legal\":", onChainTrail.tags.map((t) => t.tag), "\n"); + assert.ok(onChainTrail.tags.some((t) => t.tag === "finance"), "finance tag should still exist"); + assert.ok(!onChainTrail.tags.some((t) => t.tag === "legal"), "legal tag should be removed"); + + console.log("Tag management completed successfully."); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/main.ts b/bindings/wasm/audit_trail_wasm/examples/src/main.ts new file mode 100644 index 00000000..3ec08c8e --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/main.ts @@ -0,0 +1,61 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { createAuditTrail } from "./01_create_audit_trail"; +import { addAndReadRecords } from "./02_add_and_read_records"; +import { updateMetadata } from "./03_update_metadata"; +import { configureLocking } from "./04_configure_locking"; +import { manageAccess } from "./05_manage_access"; +import { deleteRecords } from "./06_delete_records"; +import { accessReadOnlyMethods } from "./07_access_read_only_methods"; +import { deleteAuditTrail } from "./08_delete_audit_trail"; +import { taggedRecords } from "./advanced/09_tagged_records"; +import { capabilityConstraints } from "./advanced/10_capability_constraints"; +import { manageRecordTags } from "./advanced/11_manage_record_tags"; +import { customsClearance } from "./real-world/01_customs_clearance"; +import { clinicalTrial } from "./real-world/02_clinical_trial"; +import { digitalProductPassport } from "./real-world/03_digital_product_passport"; + +export async function main(example?: string) { + const argument = example ?? process.argv?.[2]?.toLowerCase(); + if (!argument) { + throw new Error("Please specify an example name, e.g. '01_create_audit_trail'"); + } + + switch (argument) { + case "01_create_audit_trail": + return createAuditTrail(); + case "02_add_and_read_records": + return addAndReadRecords(); + case "03_update_metadata": + return updateMetadata(); + case "04_configure_locking": + return configureLocking(); + case "05_manage_access": + return manageAccess(); + case "06_delete_records": + return deleteRecords(); + case "07_access_read_only_methods": + return accessReadOnlyMethods(); + case "08_delete_audit_trail": + return deleteAuditTrail(); + case "09_tagged_records": + return taggedRecords(); + case "10_capability_constraints": + return capabilityConstraints(); + case "11_manage_record_tags": + return manageRecordTags(); + case "01_customs_clearance": + return customsClearance(); + case "02_clinical_trial": + return clinicalTrial(); + case "03_digital_product_passport": + return digitalProductPassport(); + default: + throw new Error(`Unknown example name: '${argument}'`); + } +} + +main().catch((error) => { + console.error("Example error:", error); +}); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts b/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts new file mode 100644 index 00000000..0cde78a9 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts @@ -0,0 +1,281 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * # Customs Clearance Example + * + * Models a customs-clearance process for a single shipment. + * + * ## Actors + * + * - **Admin client**: Creates the trail and sets up all roles and capabilities. + * - **DocsOperator**: Handles document submission (invoices, packing lists). Writes only + * `documents`-tagged records. + * - **ExportBroker**: Files export declarations and records clearance decisions at the origin. + * Writes only `export`-tagged records. + * - **ImportBroker**: Handles duty assessment and import clearance at the destination. + * Writes only `import`-tagged records. + * - **Inspector**: Records the outcome of a customs physical inspection. Writes only + * `inspection`-tagged records; the role is created mid-process when an inspection is triggered. + * - **Supervisor**: Updates the mutable trail metadata (processing status). No record-write + * permissions. + * - **Locking admin client**: Freezes the trail once the shipment is fully cleared. + * + * ## How the trail is used + * + * - immutable_metadata: shipment and declaration identity + * - updatable_metadata: current customs-processing status + * - record tags: documents, export, import, inspection + * - roles and capabilities: each operational role writes only the events it owns + * - locking: writes are frozen once the shipment is fully cleared + */ + +import { + CapabilityIssueOptions, + Data, + LockingConfig, + LockingWindow, + PermissionSet, + TimeLock, +} from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, issueTaggedRecordRole, TEST_GAS_BUDGET } from "../util"; + +export async function customsClearance(): Promise { + console.log("=== Customs Clearance ===\n"); + + const adminClient = await getFundedClient(); + const docsOperator = await getFundedClient(); + const exportBroker = await getFundedClient(); + const importBroker = await getFundedClient(); + const supervisor = await getFundedClient(); + const lockingAdminClient = await getFundedClient(); + const inspector = await getFundedClient(); + + // === Create the customs-clearance trail === + + console.log("Creating a customs-clearance trail..."); + + const { output: createdTrail } = await adminClient + .createTrail() + .withRecordTags(["documents", "export", "import", "inspection"]) + .withTrailMetadata( + "Shipment SHP-2026-CLEAR-001", + "Route: Hamburg, Germany -> Nairobi, Kenya | Declaration: DEC-2026-44017", + ) + .withUpdatableMetadata("Status: Documents Pending") + .withLockingConfig( + new LockingConfig(LockingWindow.withCountBased(BigInt(2)), TimeLock.withNone(), TimeLock.withNone()), + ) + .withInitialRecordString("Customs clearance case opened for inbound shipment", "event:case_opened", "documents") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailId = createdTrail.id; + + // === Set up roles and capabilities for each actor === + + await issueTaggedRecordRole(adminClient, trailId, "DocsOperator", "documents", docsOperator.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "ExportBroker", "export", exportBroker.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "ImportBroker", "import", importBroker.senderAddress()); + + // Supervisor can update metadata. + await adminClient + .trail(trailId) + .access() + .forRole("Supervisor") + .create(PermissionSet.metadataAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await adminClient + .trail(trailId) + .access() + .forRole("Supervisor") + .issueCapability(new CapabilityIssueOptions(supervisor.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + // LockingAdmin can manage locking. + await adminClient + .trail(trailId) + .access() + .forRole("LockingAdmin") + .create(PermissionSet.lockingAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await adminClient + .trail(trailId) + .access() + .forRole("LockingAdmin") + .issueCapability(new CapabilityIssueOptions(lockingAdminClient.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + // === Document submission === + + // Documents are stored off-chain in an access-controlled environment (e.g. a TWIN node). + // Only the SHA-256 fingerprint is committed on-chain for tamper-evidence. + const invoiceBytes = new TextEncoder().encode("invoice-SHP-2026-CLEAR-001-v1.pdf"); + const invoiceHash = new Uint8Array(await crypto.subtle.digest("SHA-256", invoiceBytes)); + const docsUploaded = await docsOperator + .trail(trailId) + .records() + .add(Data.fromBytes(invoiceHash), "event:documents_uploaded", "documents") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(docsOperator); + console.log("Docs operator added record", docsUploaded.output.sequenceNumber + ".\n"); + + await supervisor + .trail(trailId) + .updateMetadata("Status: Awaiting Export Clearance") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(supervisor); + + // === Export clearance === + + const exportFiled = await exportBroker + .trail(trailId) + .records() + .add( + Data.fromString("Export declaration filed with German customs"), + "event:export_declaration_filed", + "export", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(exportBroker); + + const exportCleared = await exportBroker + .trail(trailId) + .records() + .add(Data.fromString("Export clearance granted by Hamburg customs office"), "event:export_cleared", "export") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(exportBroker); + + console.log( + "Export broker added records", + exportFiled.output.sequenceNumber, + "and", + exportCleared.output.sequenceNumber + ".\n", + ); + + await supervisor + .trail(trailId) + .updateMetadata("Status: Awaiting Import Clearance") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(supervisor); + + // === Inspection gate === + + // The import broker does not hold an inspection-scoped capability at this point. + // The write attempt must fail to prove that tag-based access control is enforced. + let inspectionDenied = false; + try { + await importBroker + .trail(trailId) + .records() + .add( + Data.fromString("Import broker attempted to record an inspection result"), + "event:invalid_inspection_write", + "inspection", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(importBroker); + inspectionDenied = true; + } catch { + // Expected + } + assert.equal( + inspectionDenied, + false, + "inspection-tagged writes should fail before an inspection-scoped capability exists", + ); + console.log("Inspection write was correctly denied before the inspector role existed.\n"); + + // A customs inspection is triggered; the inspector role is created and issued mid-process. + await issueTaggedRecordRole(adminClient, trailId, "Inspector", "inspection", inspector.senderAddress()); + + const inspectionDone = await inspector + .trail(trailId) + .records() + .add( + Data.fromString("Customs inspection completed with no discrepancies"), + "event:inspection_completed", + "inspection", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(inspector); + console.log("Inspector added record", inspectionDone.output.sequenceNumber + ".\n"); + + // === Import clearance === + + const dutyAssessed = await importBroker + .trail(trailId) + .records() + .add(Data.fromString("Import duty assessed and paid"), "event:duty_assessed", "import") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(importBroker); + + const importCleared = await importBroker + .trail(trailId) + .records() + .add(Data.fromString("Import clearance granted by Nairobi customs"), "event:import_cleared", "import") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(importBroker); + + console.log( + "Import broker added records", + dutyAssessed.output.sequenceNumber, + "and", + importCleared.output.sequenceNumber + ".\n", + ); + + await supervisor + .trail(trailId) + .updateMetadata("Status: Cleared") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(supervisor); + + // === Final lock and verification === + + await lockingAdminClient + .trail(trailId) + .locking() + .updateWriteLock(TimeLock.withInfinite()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lockingAdminClient); + + const trailAfterLock = await adminClient.trail(trailId).get(); + console.log("Write lock after clearance:", trailAfterLock.lockingConfig.writeLock, "\n"); + + let lateWriteSucceeded = false; + try { + await docsOperator + .trail(trailId) + .records() + .add(Data.fromString("Late customs note after the case was closed"), "event:late_note", "documents") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(docsOperator); + lateWriteSucceeded = true; + } catch { + // Expected + } + assert.equal(lateWriteSucceeded, false, "cleared customs trail should reject late writes after the final lock"); + + const firstRecordsPage = await adminClient.trail(trailId).records().listPage(undefined, 20); + console.log("Recorded customs events:"); + for (const record of firstRecordsPage.records) { + console.log(` #${record.sequenceNumber} | ${record.data} | tag=${record.tag} | ${record.metadata}`); + } + + assert.equal( + firstRecordsPage.records.length, + 7, + "expected 7 customs records including the initial case-opened record", + ); + + const finalTrail = await adminClient.trail(trailId).get(); + assert.equal(finalTrail.updatableMetadata, "Status: Cleared", "customs case should finish in cleared state"); + + console.log("\nCustoms clearance completed successfully."); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/real-world/02_clinical_trial.ts b/bindings/wasm/audit_trail_wasm/examples/src/real-world/02_clinical_trial.ts new file mode 100644 index 00000000..69439e95 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/02_clinical_trial.ts @@ -0,0 +1,297 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * # Clinical Trial Data-Integrity Example + * + * Models a Phase III clinical trial where an immutable audit trail + * guarantees data integrity, role-scoped access, and time-constrained oversight. + * + * ## Actors + * + * - **Admin client**: Creates the trail and sets up all roles and capabilities. + * - **Enroller**: Writes enrollment events. Restricted to the `enrollment` tag. + * - **SafetyOfficer**: Records adverse events and safety observations. Restricted to `safety`. + * - **EfficacyReviewer**: Records treatment outcomes. Restricted to `efficacy`. + * - **PkAnalyst**: Records pharmacokinetic results. Restricted to the `pk` tag that is added + * mid-study when a PK sub-study is initiated. + * - **Monitor**: Updates the mutable study-phase metadata. Access is time-windowed to the + * active study period (90 days from now). + * - **DataSafetyBoard**: Controls write and delete locks. Freezes the dataset after review. + * - **Regulator**: Read-only verifier. In production this would use `AuditTrailClientReadOnly` + * (no signing key); here a funded client is used to keep the example self-contained. + * + * ## How the trail is used + * + * - immutable_metadata: protocol identity and study description + * - updatable_metadata: current study phase (updated as the trial progresses) + * - record tags: enrollment, safety, efficacy, pk (added mid-study) + * - roles and capabilities: each role writes only its designated tag + * - time-constrained capabilities: Monitor access is windowed to the study period + * - locking: a deletion window protects recent records; a time-lock freezes the + * dataset after the Data Safety Board completes its review + * - read-only verification: a regulator inspects the trail without write access + */ + +import { + CapabilityIssueOptions, + Data, + LockingConfig, + LockingWindow, + PermissionSet, + TimeLock, +} from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, issueTaggedRecordRole, TEST_GAS_BUDGET } from "../util"; + +export async function clinicalTrial(): Promise { + console.log("=== Clinical Trial Data Integrity ===\n"); + + const adminClient = await getFundedClient(); + const enroller = await getFundedClient(); + const safetyOfficer = await getFundedClient(); + const efficacyReviewer = await getFundedClient(); + const pkAnalyst = await getFundedClient(); + const monitor = await getFundedClient(); + const dataSafetyBoard = await getFundedClient(); + const regulator = await getFundedClient(); + + // === Create the clinical-trial trail === + + console.log("Creating the clinical-trial audit trail..."); + + const { output: createdTrail } = await adminClient + .createTrail() + .withRecordTags(["enrollment", "safety", "efficacy"]) + .withTrailMetadata( + "Protocol CTR-2026-03742", + "Phase III: Efficacy of Drug X vs Placebo in Moderate-to-Severe Asthma", + ) + .withUpdatableMetadata("Phase: Enrollment") + .withLockingConfig( + new LockingConfig(LockingWindow.withCountBased(BigInt(3)), TimeLock.withNone(), TimeLock.withNone()), + ) + .withInitialRecordString( + "Clinical trial CTR-2026-03742 opened for enrollment", + "event:trial_opened", + "enrollment", + ) + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const trailId = createdTrail.id; + console.log("Trail created with ID", trailId, "\n"); + + // === Define roles with tag-scoped permissions === + + console.log("Defining study roles..."); + + await issueTaggedRecordRole(adminClient, trailId, "Enroller", "enrollment", enroller.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "SafetyOfficer", "safety", safetyOfficer.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "EfficacyReviewer", "efficacy", efficacyReviewer.senderAddress()); + + // Monitor can update metadata (study phase) — valid for 90 days. + await adminClient + .trail(trailId) + .access() + .forRole("Monitor") + .create(PermissionSet.metadataAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + const nowMs = BigInt(Date.now()); + const studyEndMs = nowMs + BigInt(90 * 24 * 60 * 60 * 1000); + + await adminClient + .trail(trailId) + .access() + .forRole("Monitor") + .issueCapability(new CapabilityIssueOptions(monitor.senderAddress(), nowMs, studyEndMs)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + console.log("Monitor capability issued (expires at timestamp", studyEndMs + ")\n"); + + // Data Safety Board can manage locking. + await adminClient + .trail(trailId) + .access() + .forRole("DataSafetyBoard") + .create(PermissionSet.lockingAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await adminClient + .trail(trailId) + .access() + .forRole("DataSafetyBoard") + .issueCapability(new CapabilityIssueOptions(dataSafetyBoard.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + + // === Enrollment phase === + + console.log("--- Enrollment Phase ---"); + + const enrolled = await enroller + .trail(trailId) + .records() + .add(Data.fromString("Patient P-101 enrolled at Site Hamburg"), "event:patient_enrolled", "enrollment") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(enroller); + console.log("Enroller added record", enrolled.output.sequenceNumber + ".\n"); + + // === Study data collection === + + console.log("--- Study Data Collection ---"); + + const safetyEvent = await safetyOfficer + .trail(trailId) + .records() + .add( + Data.fromString("Adverse event: mild headache reported by Patient P-101"), + "event:adverse_event", + "safety", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(safetyOfficer); + + const efficacyRecord = await efficacyReviewer + .trail(trailId) + .records() + .add( + Data.fromString("Week 12: FEV1 improvement of 320 mL over baseline for P-101"), + "event:efficacy_observed", + "efficacy", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(efficacyReviewer); + + console.log( + "SafetyOfficer added record", + safetyEvent.output.sequenceNumber, + ", EfficacyReviewer added record", + efficacyRecord.output.sequenceNumber + ".\n", + ); + + // === Mid-study amendment: add pharmacokinetics tag === + + console.log("--- Mid-Study Amendment ---"); + + await adminClient.trail(trailId).tags().add("pk").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(adminClient); + console.log("Added tag \"pk\" (pharmacokinetics) to the trail."); + + await issueTaggedRecordRole(adminClient, trailId, "PkAnalyst", "pk", pkAnalyst.senderAddress()); + + const pkRecord = await pkAnalyst + .trail(trailId) + .records() + .add(Data.fromString("PK analysis: Cmax reached at 2.4 h, half-life 8.7 h"), "event:pk_result", "pk") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(pkAnalyst); + console.log("PkAnalyst added record", pkRecord.output.sequenceNumber + ".\n"); + + // === Deletion window enforcement === + + console.log("--- Deletion Window Enforcement ---"); + + // The PkAnalyst has RecordAdmin permissions, but the count-based deletion window + // protects the newest 3 records, so this attempt must fail. + let deleteSucceeded = false; + try { + await pkAnalyst + .trail(trailId) + .records() + .delete(pkRecord.output.sequenceNumber) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(pkAnalyst); + deleteSucceeded = true; + } catch { + // Expected + } + assert.equal(deleteSucceeded, false, "recent records must be protected by the count-based deletion window"); + console.log( + "Record", + pkRecord.output.sequenceNumber, + "is within the deletion window (newest 3) and cannot be deleted.\n", + ); + + // === Metadata update (Monitor) === + + console.log("--- Metadata Update ---"); + + await monitor + .trail(trailId) + .updateMetadata("Phase: Data Review") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(monitor); + + const trailAfterMetadataUpdate = await adminClient.trail(trailId).get(); + console.log("Study phase updated to:", trailAfterMetadataUpdate.updatableMetadata, "\n"); + + // === Data Safety Board locks the study dataset === + + console.log("--- Data Safety Board Lock ---"); + + const lockUntilMs = nowMs + BigInt(365 * 24 * 60 * 60 * 1000); // 1 year from now + + await dataSafetyBoard + .trail(trailId) + .locking() + .updateWriteLock(TimeLock.withUnlockAtMs(lockUntilMs)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(dataSafetyBoard); + + console.log("Write lock set to UnlockAtMs(" + lockUntilMs + ") — writes blocked until that timestamp.\n"); + + // Lock trail from deletion permanently. + await dataSafetyBoard + .trail(trailId) + .locking() + .updateDeleteTrailLock(TimeLock.withInfinite()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(dataSafetyBoard); + + const finalLockedTrail = await adminClient.trail(trailId).get(); + console.log( + "Delete-trail lock set to", + finalLockedTrail.lockingConfig.deleteTrailLock.type, + "— trail cannot be deleted.\n", + ); + + // === Regulator read-only verification === + + console.log("--- Regulator Verification ---"); + + // In production the regulator would use AuditTrailClientReadOnly (no signing key). + // Here a funded client is used to keep the example self-contained. + const regulatorHandle = regulator.trail(trailId); + const regulatorTrailView = await regulatorHandle.get(); + + console.log("Protocol:", regulatorTrailView.immutableMetadata); + console.log("Phase: ", regulatorTrailView.updatableMetadata); + console.log("Roles: ", regulatorTrailView.roles.roles.map((r) => r.name)); + console.log("Tags: ", regulatorTrailView.tags.map((t) => t.tag)); + + const firstRecordsPage = await regulatorHandle.records().listPage(undefined, 20); + console.log("\nVerified records (" + firstRecordsPage.records.length + " total):"); + for (const record of firstRecordsPage.records) { + console.log(` #${record.sequenceNumber} | tag=${record.tag} | ${record.metadata}`); + } + + assert.equal( + firstRecordsPage.records.length, + 5, + "expected 5 records (initial + enrolled + safety + efficacy + pk)", + ); + assert.ok(regulatorTrailView.tags.some((t) => t.tag === "pk"), "the 'pk' tag must exist after mid-study amendment"); + assert.equal( + regulatorTrailView.lockingConfig.deleteRecordWindow.type, + LockingWindow.withCountBased(BigInt(3)).type, + ); + assert.equal(regulatorTrailView.lockingConfig.deleteTrailLock.type, TimeLock.withInfinite().type); + assert.equal(regulatorTrailView.lockingConfig.writeLock.type, TimeLock.withUnlockAtMs(lockUntilMs).type); + assert.equal(regulatorTrailView.updatableMetadata, "Phase: Data Review"); + + console.log("\nClinical trial data-integrity verification completed successfully."); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/real-world/03_digital_product_passport.ts b/bindings/wasm/audit_trail_wasm/examples/src/real-world/03_digital_product_passport.ts new file mode 100644 index 00000000..9598a51d --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/03_digital_product_passport.ts @@ -0,0 +1,342 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/** + * # Digital Product Passport Example + * + * Models a Digital Product Passport (DPP) for an e-bike battery, inspired by the + * public IOTA DPP demo. + * + * Scope note: this example stays within the Audit Trail package. The demo's wider + * IOTA stack (Identity, Hierarchies, Tokenization, and Gas Station) is mapped + * here onto audit-trail-native concepts: + * + * - product identity, bill of materials, reward policy, and service history are + * captured as immutable audit records + * - service-network authorization is represented through role-scoped capabilities + * - Lifecycle Credit (LCC) payouts are documented as reward records rather than + * executed as token transfers + * + * ## Actors + * + * - **Manufacturer**: Creates the DPP, publishes manufacturing data, and + * administers roles and capabilities. + * - **LifecycleManager**: Updates the mutable lifecycle-stage metadata. + * - **Distributor**: Writes logistics and handover records. + * - **Consumer**: Writes the commissioning / in-use activation record. + * - **ServiceTechnician**: Reviews the passport, requests write access, and + * records the maintenance event once authorized. + * - **Recycler**: Prepared for future end-of-life events through a + * recycling-scoped capability. + * - **EPRO**: Records reward policy and the reward-payout evidence for verified + * maintenance. + * + * ## How the trail is used as a DPP + * + * - immutable_metadata: product identity for the battery passport + * - updatable_metadata: current lifecycle stage + * - record tags: manufacturing, logistics, ownership, maintenance, recycling, rewards + * - roles and capabilities: each actor can write only its assigned slice of the lifecycle + * - access-request flow: the technician is denied maintenance writes until the + * manufacturer issues the scoped capability + * - service evidence: the maintenance event mirrors the demo's "Annual + * Maintenance" / "Health Snapshot" pattern with a 76% health score and a + * 1-LCC reward record + */ + +import { AuditTrailClient, CapabilityIssueOptions, Data, PermissionSet, RoleTags } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; +import { getFundedClient, issueTaggedRecordRole, TEST_GAS_BUDGET } from "../util"; + +export async function digitalProductPassport(): Promise { + console.log("=== Digital Product Passport ===\n"); + + const manufacturer = await getFundedClient(); + const lifecycleManager = await getFundedClient(); + const distributor = await getFundedClient(); + const consumer = await getFundedClient(); + const serviceTechnician = await getFundedClient(); + const recycler = await getFundedClient(); + const epro = await getFundedClient(); + + console.log("Manufacturer wallet: ", manufacturer.senderAddress()); + console.log("Lifecycle manager wallet: ", lifecycleManager.senderAddress()); + console.log("Distributor wallet: ", distributor.senderAddress()); + console.log("Consumer wallet: ", consumer.senderAddress()); + console.log("Service technician wallet:", serviceTechnician.senderAddress()); + console.log("Recycler wallet: ", recycler.senderAddress()); + console.log("EPRO wallet: ", epro.senderAddress(), "\n"); + + // === Create the DPP trail === + + console.log("Creating the DPP trail for EcoBike's battery..."); + + const { output: createdTrail } = await manufacturer + .createTrail() + .withRecordTags(["manufacturing", "logistics", "ownership", "maintenance", "recycling", "rewards"]) + .withTrailMetadata("DPP: Pro 48V Battery", "Manufacturer: EcoBike | Serial: EB-48V-2024-001337") + .withUpdatableMetadata("Lifecycle Stage: Manufactured") + .withInitialRecordString( + "event=dpp_created\nproduct_name=Pro 48V Battery\nserial_number=EB-48V-2024-001337\nmanufacturer=EcoBike", + "event:dpp_created", + "manufacturing", + ) + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(manufacturer); + + const trailId = createdTrail.id; + console.log("Trail created with ID", trailId, "\n"); + + // === Define DPP roles and issue capabilities === + + console.log("Configuring DPP actor roles..."); + + await issueTaggedRecordRole(manufacturer, trailId, "Manufacturer", "manufacturing", manufacturer.senderAddress()); + await issueTaggedRecordRole(manufacturer, trailId, "Distributor", "logistics", distributor.senderAddress()); + await issueTaggedRecordRole(manufacturer, trailId, "Consumer", "ownership", consumer.senderAddress()); + await issueTaggedRecordRole(manufacturer, trailId, "Recycler", "recycling", recycler.senderAddress()); + await issueTaggedRecordRole(manufacturer, trailId, "EPRO", "rewards", epro.senderAddress()); + + await manufacturer + .trail(trailId) + .access() + .forRole("ServiceTechnician") + .create(PermissionSet.recordAdminPermissions(), new RoleTags(["maintenance"])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(manufacturer); + + await issueMetadataRole(manufacturer, trailId, "LifecycleManager", lifecycleManager.senderAddress()); + + // === Prepare the passport with lifecycle context from the DPP demo === + + console.log("Publishing product details, service-network context, and reward policy..."); + + await manufacturer + .trail(trailId) + .records() + .add( + Data.fromString( + "event=product_details_published\nproduct_name=Pro 48V Battery\nserial_number=EB-48V-2024-001337\nmanufacturer=EcoBike\nmanufacturer_did=did:iota:testnet:0xdc704ab63984d5763576c12ce5f62fe735766bc1fc9892a5e2a7be777a9af897\nbattery_details=48V removable e-bike battery with smart BMS\nbill_of_materials=cathode:NMC811;anode:graphite;housing:recycled_aluminum;bms:BMS-v3\ncompliance=CE,RoHS,UN38.3\nsustainability=recycled_aluminum_housing:35%\nservice_network=EcoBike certified service network", + ), + "event:product_details_published", + "manufacturing", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(manufacturer); + + await epro + .trail(trailId) + .records() + .add( + Data.fromString( + "event=reward_policy_published\nreward_type=LCC\nannual_maintenance_reward=1 LCC\nrecycling_reward=10 LCC\nfinal_owner_reward=10 LCC\nmanufacturer_return_reward=10 LCC\nend_of_life_bundle=30 LCC\nsettlement_operator=EcoCycle EPRO", + ), + "event:reward_policy_published", + "rewards", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(epro); + + await lifecycleManager + .trail(trailId) + .updateMetadata("Lifecycle Stage: In Distribution") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lifecycleManager); + + await distributor + .trail(trailId) + .records() + .add( + Data.fromString( + "event=distributed\nshipment_id=SHIP-EB-2026-0042\ntracking_status=Delivered to Nairobi certified service region\ntransport_certification=ADR-compliant battery transport", + ), + "event:distributed", + "logistics", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(distributor); + + await lifecycleManager + .trail(trailId) + .updateMetadata("Lifecycle Stage: In Use") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lifecycleManager); + + await consumer + .trail(trailId) + .records() + .add( + Data.fromString( + "event=commissioned\nowner_profile=Urban commuter fleet\nusage_status=Battery commissioned for daily e-bike service\nrepair_options=EcoBike certified annual maintenance available", + ), + "event:commissioned", + "ownership", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(consumer); + + // === Technician reviews history and requests maintenance access === + + console.log("Technician reviews the current DPP history..."); + + const historyBeforeService = await serviceTechnician.trail(trailId).records().listPage(undefined, 20); + console.log("Technician can already read", historyBeforeService.records.length, "public DPP records.\n"); + + let unauthorizedWriteSucceeded = false; + try { + await serviceTechnician + .trail(trailId) + .records() + .add( + Data.fromString("event=unauthorized_maintenance_attempt"), + "event:unauthorized_maintenance_attempt", + "maintenance", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(serviceTechnician); + unauthorizedWriteSucceeded = true; + } catch { + // Expected + } + assert.equal( + unauthorizedWriteSucceeded, + false, + "maintenance writes must fail until the technician is explicitly authorized", + ); + console.log("Maintenance write denied before access grant, as expected.\n"); + + const nowMs = BigInt(Date.now()); + const technicianValidUntilMs = nowMs + BigInt(30 * 24 * 60 * 60 * 1000); + + const serviceTechnicianCapability = await manufacturer + .trail(trailId) + .access() + .forRole("ServiceTechnician") + .issueCapability( + new CapabilityIssueOptions(serviceTechnician.senderAddress(), nowMs, technicianValidUntilMs), + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(manufacturer); + + console.log( + "Issued ServiceTechnician capability", + serviceTechnicianCapability.output.capabilityId, + "(valid until", + technicianValidUntilMs + ").\n", + ); + + await lifecycleManager + .trail(trailId) + .updateMetadata("Lifecycle Stage: Maintenance In Progress") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lifecycleManager); + + // === Perform the maintenance event described in the DPP demo === + + console.log("Recording the annual maintenance event..."); + + const maintenanceEvent = await serviceTechnician + .trail(trailId) + .records() + .add( + Data.fromString( + "entry_type=Annual Maintenance\nservice_action=Health Snapshot\nhealth_score=76%\nfindings=Routine maintenance completed successfully\nwork_performed=Battery contacts cleaned; cell balance check passed; firmware diagnostics passed\nnext_service_due=2027-04-20", + ), + "event:annual_maintenance", + "maintenance", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(serviceTechnician); + + console.log("Service technician added maintenance record", maintenanceEvent.output.sequenceNumber + ".\n"); + + const rewardEvent = await epro + .trail(trailId) + .records() + .add( + Data.fromString( + `event=lcc_reward_distributed\ntrigger_record=${maintenanceEvent.output.sequenceNumber}\nreward_type=LCC\namount=1\nreason=Annual maintenance completed\nbeneficiary=${serviceTechnician.senderAddress()}`, + ), + "event:lcc_reward_distributed", + "rewards", + ) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(epro); + + console.log( + "EPRO added reward record", + rewardEvent.output.sequenceNumber + " for the verified maintenance event.\n", + ); + + await lifecycleManager + .trail(trailId) + .updateMetadata("Lifecycle Stage: Maintained and Ready for Continued Use") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(lifecycleManager); + + // === Verify the resulting DPP === + + console.log("Verifying the resulting DPP..."); + + const onChainTrail = await manufacturer.trail(trailId).get(); + const firstRecordsPage = await manufacturer.trail(trailId).records().listPage(undefined, 20); + + console.log("Recorded DPP events:"); + for (const record of firstRecordsPage.records) { + console.log(` #${record.sequenceNumber} | tag=${record.tag} | metadata=${record.metadata}`); + } + + assert.equal( + firstRecordsPage.records.length, + 7, + "expected 7 DPP records (initial + product details + reward policy + distribution + commissioning + maintenance + reward payout)", + ); + assert.ok( + onChainTrail.tags.some((t) => t.tag === "maintenance") + && onChainTrail.tags.some((t) => t.tag === "recycling") + && onChainTrail.tags.some((t) => t.tag === "rewards"), + "expected the DPP tag registry to contain maintenance, recycling, and rewards", + ); + assert.ok( + onChainTrail.roles.roles.some((r) => r.name === "Manufacturer") + && onChainTrail.roles.roles.some((r) => r.name === "Distributor") + && onChainTrail.roles.roles.some((r) => r.name === "Consumer") + && onChainTrail.roles.roles.some((r) => r.name === "ServiceTechnician") + && onChainTrail.roles.roles.some((r) => r.name === "Recycler") + && onChainTrail.roles.roles.some((r) => r.name === "EPRO") + && onChainTrail.roles.roles.some((r) => r.name === "LifecycleManager"), + "expected all DPP roles to be registered", + ); + assert.equal(onChainTrail.updatableMetadata, "Lifecycle Stage: Maintained and Ready for Continued Use"); + + const maintenanceRecord = firstRecordsPage.records.find((record) => record.metadata === "event:annual_maintenance"); + assert.ok(maintenanceRecord, "expected the maintenance record to be present in the DPP history"); + + const rewardRecord = firstRecordsPage.records.find((record) => record.metadata === "event:lcc_reward_distributed"); + assert.ok(rewardRecord, "expected the reward payout record to be present in the DPP history"); + + console.log("\nDigital Product Passport scenario completed successfully."); +} + +async function issueMetadataRole( + adminClient: AuditTrailClient, + trailId: string, + roleName: string, + issuedTo: string, +): Promise { + await adminClient + .trail(trailId) + .access() + .forRole(roleName) + .create(PermissionSet.metadataAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await adminClient + .trail(trailId) + .access() + .forRole(roleName) + .issueCapability(new CapabilityIssueOptions(issuedTo)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/tests.ts b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts new file mode 100644 index 00000000..b7ba809a --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts @@ -0,0 +1,68 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, it } from "mocha"; + +import { createAuditTrail } from "./01_create_audit_trail"; +import { addAndReadRecords } from "./02_add_and_read_records"; +import { updateMetadata } from "./03_update_metadata"; +import { configureLocking } from "./04_configure_locking"; +import { manageAccess } from "./05_manage_access"; +import { deleteRecords } from "./06_delete_records"; +import { accessReadOnlyMethods } from "./07_access_read_only_methods"; +import { deleteAuditTrail } from "./08_delete_audit_trail"; +import { taggedRecords } from "./advanced/09_tagged_records"; +import { capabilityConstraints } from "./advanced/10_capability_constraints"; +import { manageRecordTags } from "./advanced/11_manage_record_tags"; +import { customsClearance } from "./real-world/01_customs_clearance"; +import { clinicalTrial } from "./real-world/02_clinical_trial"; +import { digitalProductPassport } from "./real-world/03_digital_product_passport"; + +describe("Audit trail wasm node examples", function() { + afterEach(() => { + console.log("\n----------------------------------------------------\n"); + }); + + it("creates a trail", async () => { + await createAuditTrail(); + }); + it("adds and reads records", async () => { + await addAndReadRecords(); + }); + it("updates metadata", async () => { + await updateMetadata(); + }); + it("configures locking", async () => { + await configureLocking(); + }); + it("manages access", async () => { + await manageAccess(); + }); + it("deletes records", async () => { + await deleteRecords(); + }); + it("accesses read-only methods", async () => { + await accessReadOnlyMethods(); + }); + it("deletes an audit trail", async () => { + await deleteAuditTrail(); + }); + it("uses tagged records", async () => { + await taggedRecords(); + }); + it("constrains capabilities", async () => { + await capabilityConstraints(); + }); + it("manages record tags", async () => { + await manageRecordTags(); + }); + it("runs customs clearance example", async () => { + await customsClearance(); + }); + it("runs clinical trial example", async () => { + await clinicalTrial(); + }); + it("runs digital product passport example", async () => { + await digitalProductPassport(); + }); +}); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/util.ts b/bindings/wasm/audit_trail_wasm/examples/src/util.ts new file mode 100644 index 00000000..3fd3ef1f --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/util.ts @@ -0,0 +1,122 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { + AuditTrailClient, + AuditTrailClientReadOnly, + CapabilityIssueOptions, + LockingConfig, + LockingWindow, + PackageOverrides, + Permission, + PermissionSet, + RoleTags, + TimeLock, +} from "@iota/audit-trails/node"; +import { Ed25519KeypairSigner } from "@iota/iota-interaction-ts/node/test_utils"; +import { IotaClient } from "@iota/iota-sdk/client"; +import { getFaucetHost, requestIotaFromFaucetV0 } from "@iota/iota-sdk/faucet"; +import { Ed25519Keypair } from "@iota/iota-sdk/keypairs/ed25519"; + +export const IOTA_AUDIT_TRAIL_PKG_ID = globalThis?.process?.env?.IOTA_AUDIT_TRAIL_PKG_ID || ""; +export const IOTA_TF_COMPONENTS_PKG_ID = globalThis?.process?.env?.IOTA_TF_COMPONENTS_PKG_ID || ""; +export const NETWORK_NAME_FAUCET = globalThis?.process?.env?.NETWORK_NAME_FAUCET || "localnet"; +export const NETWORK_URL = globalThis?.process?.env?.NETWORK_URL || "http://127.0.0.1:9000"; +export const TEST_GAS_BUDGET = BigInt(50_000_000); + +if (!IOTA_AUDIT_TRAIL_PKG_ID || !IOTA_TF_COMPONENTS_PKG_ID) { + throw new Error( + "IOTA_AUDIT_TRAIL_PKG_ID and IOTA_TF_COMPONENTS_PKG_ID env variables must be set to run the examples", + ); +} + +export async function requestFunds(address: string) { + await requestIotaFromFaucetV0({ + host: getFaucetHost(NETWORK_NAME_FAUCET), + recipient: address, + }); +} + +export async function getReadOnlyClient(): Promise { + const iotaClient = new IotaClient({ url: NETWORK_URL }); + return AuditTrailClientReadOnly.createWithPackageOverrides( + iotaClient, + new PackageOverrides(IOTA_AUDIT_TRAIL_PKG_ID, IOTA_TF_COMPONENTS_PKG_ID), + ); +} + +export async function getFundedClient(): Promise { + const readOnlyClient = await getReadOnlyClient(); + const keypair = Ed25519Keypair.generate(); + const signer = new Ed25519KeypairSigner(keypair); + const client = await AuditTrailClient.create(readOnlyClient, signer); + + await requestFunds(client.senderAddress()); + + const balance = await client.iotaClient().getBalance({ owner: client.senderAddress() }); + if (balance.totalBalance === "0") { + throw new Error("Balance is still 0 after faucet funding"); + } + + console.log(`Received gas from faucet: ${balance.totalBalance} for owner ${client.senderAddress()}`); + return client; +} + +export function defaultLockingConfig(): LockingConfig { + return new LockingConfig( + LockingWindow.withCountBased(BigInt(100)), + TimeLock.withNone(), + TimeLock.withNone(), + ); +} + +export async function createTrailWithSeedRecord(client: AuditTrailClient) { + return client + .createTrail() + .withTrailMetadata("Example Audit Trail", "WASM example trail") + .withUpdatableMetadata("seed metadata") + .withLockingConfig(defaultLockingConfig()) + .withInitialRecordString("seed record", "v1") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); +} + +export async function grantSelfRecordPermissions(client: AuditTrailClient, trailId: string): Promise { + const selfRecordWriterRole = client.trail(trailId).access().forRole("example-record-writer"); + const permissions = new PermissionSet([ + Permission.AddRecord, + Permission.DeleteRecord, + Permission.DeleteAllRecords, + Permission.CorrectRecord, + ]); + + await selfRecordWriterRole.create(permissions).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await selfRecordWriterRole + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); +} + +export async function issueTaggedRecordRole( + adminClient: AuditTrailClient, + trailId: string, + roleName: string, + tag: string, + issuedToAddress: string, +): Promise { + await adminClient + .trail(trailId) + .access() + .forRole(roleName) + .create(PermissionSet.recordAdminPermissions(), new RoleTags([tag])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); + await adminClient + .trail(trailId) + .access() + .forRole(roleName) + .issueCapability(new CapabilityIssueOptions(issuedToAddress)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(adminClient); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts b/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts new file mode 100644 index 00000000..8f82711e --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts @@ -0,0 +1,57 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { createAuditTrail } from "./01_create_audit_trail"; +import { addAndReadRecords } from "./02_add_and_read_records"; +import { updateMetadata } from "./03_update_metadata"; +import { configureLocking } from "./04_configure_locking"; +import { manageAccess } from "./05_manage_access"; +import { deleteRecords } from "./06_delete_records"; +import { accessReadOnlyMethods } from "./07_access_read_only_methods"; +import { deleteAuditTrail } from "./08_delete_audit_trail"; +import { taggedRecords } from "./advanced/09_tagged_records"; +import { capabilityConstraints } from "./advanced/10_capability_constraints"; +import { manageRecordTags } from "./advanced/11_manage_record_tags"; +import { customsClearance } from "./real-world/01_customs_clearance"; +import { clinicalTrial } from "./real-world/02_clinical_trial"; +import { digitalProductPassport } from "./real-world/03_digital_product_passport"; + +export async function main(example?: string) { + const argument = example ?? new URLSearchParams(window.location.search).get("example")?.toLowerCase(); + if (!argument) { + throw new Error("Please specify an example name, e.g. '01_create_audit_trail'"); + } + + switch (argument) { + case "01_create_audit_trail": + return createAuditTrail(); + case "02_add_and_read_records": + return addAndReadRecords(); + case "03_update_metadata": + return updateMetadata(); + case "04_configure_locking": + return configureLocking(); + case "05_manage_access": + return manageAccess(); + case "06_delete_records": + return deleteRecords(); + case "07_access_read_only_methods": + return accessReadOnlyMethods(); + case "08_delete_audit_trail": + return deleteAuditTrail(); + case "09_tagged_records": + return taggedRecords(); + case "10_capability_constraints": + return capabilityConstraints(); + case "11_manage_record_tags": + return manageRecordTags(); + case "01_customs_clearance": + return customsClearance(); + case "02_clinical_trial": + return clinicalTrial(); + case "03_digital_product_passport": + return digitalProductPassport(); + default: + throw new Error(`Unknown example name: '${argument}'`); + } +} diff --git a/bindings/wasm/audit_trail_wasm/examples/tsconfig.node.json b/bindings/wasm/audit_trail_wasm/examples/tsconfig.node.json new file mode 100644 index 00000000..3250200f --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2020", + "baseUrl": "./", + "lib": [ + "ES6", + "dom" + ], + "esModuleInterop": true, + "module": "commonjs", + "moduleResolution": "node", + "skipLibCheck": true, + "paths": { + "@iota/audit-trails/node": [ + "../node" + ] + } + }, + "include": [ + "./src/**/*.ts" + ] +} diff --git a/bindings/wasm/audit_trail_wasm/examples/tsconfig.web.json b/bindings/wasm/audit_trail_wasm/examples/tsconfig.web.json new file mode 100644 index 00000000..b4f376cd --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/tsconfig.web.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2020", + "outDir": "./dist/web", + "baseUrl": "./", + "lib": [ + "ES6", + "dom" + ], + "esModuleInterop": true, + "moduleResolution": "node", + "skipLibCheck": true, + "paths": { + "@iota/audit-trails/node": [ + "../web" + ] + } + }, + "exclude": [ + "tests" + ] +} diff --git a/bindings/wasm/audit_trail_wasm/lib/index.ts b/bindings/wasm/audit_trail_wasm/lib/index.ts new file mode 100644 index 00000000..dbbf1ead --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/lib/index.ts @@ -0,0 +1,5 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from "@iota/iota-interaction-ts/transaction_internal"; +export * from "~audit_trail_wasm"; diff --git a/bindings/wasm/audit_trail_wasm/lib/tsconfig.json b/bindings/wasm/audit_trail_wasm/lib/tsconfig.json new file mode 100644 index 00000000..7522c000 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/lib/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../tsconfig.node.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "~audit_trail_wasm": [ + "../node/audit_trail_wasm", + "./audit_trail_wasm.js" + ], + "@iota/iota-interaction-ts/*": [ + "../node_modules/@iota/iota-interaction-ts/node/*", + "@iota/iota-interaction-ts/node/" + ], + "../lib": [ + "." + ] + }, + "outDir": "../node", + "declarationDir": "../node" + } +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/lib/tsconfig.web.json b/bindings/wasm/audit_trail_wasm/lib/tsconfig.web.json new file mode 100644 index 00000000..9459a549 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/lib/tsconfig.web.json @@ -0,0 +1,22 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "~audit_trail_wasm": [ + "../web/audit_trail_wasm", + "./audit_trail_wasm.js" + ], + "@iota/iota-interaction-ts/*": [ + "../node_modules/@iota/iota-interaction-ts/web/*", + "@iota/iota-interaction-ts/web/" + ], + "../lib": [ + "." + ] + }, + "outDir": "../web", + "declarationDir": "../web", + "module": "ES2022" + } +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/package-lock.json b/bindings/wasm/audit_trail_wasm/package-lock.json new file mode 100644 index 00000000..d6492ff6 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/package-lock.json @@ -0,0 +1,4507 @@ +{ + "name": "@iota/audit-trails", + "version": "0.1.0-alpha", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@iota/audit-trails", + "version": "0.1.0-alpha", + "license": "Apache-2.0", + "dependencies": { + "@iota/iota-interaction-ts": "^0.13.0" + }, + "devDependencies": { + "@types/mocha": "^9.1.0", + "@types/node": "^22.0.0", + "cypress": "^14.2.0", + "dprint": "^0.33.0", + "mocha": "^9.2.0", + "rimraf": "^6.0.1", + "start-server-and-test": "^2.0.11", + "ts-mocha": "^9.0.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.1.0", + "typedoc": "^0.28.5", + "typedoc-plugin-markdown": "^4.4.1", + "typescript": "^5.7.3", + "wasm-opt": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@iota/iota-sdk": "^1.13.0" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", + "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "node_modules/@0no-co/graphqlsp": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/@0no-co/graphqlsp/-/graphqlsp-1.15.4.tgz", + "integrity": "sha512-Nt1DVHcZ08lKRKwhiU0amXH77fSdrO6DzyjLE0DkCxfbM/N1SAs32d76y1xtCzM5H9eT0iDS7SdksgRXWJu05g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@gql.tada/internal": "^1.0.0", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cypress/request": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", + "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~4.0.4", + "http-signature": "~1.4.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "performance-now": "^2.1.0", + "qs": "~6.14.1", + "safe-buffer": "^5.1.2", + "tough-cookie": "^5.0.0", + "tunnel-agent": "^0.6.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@cypress/xvfb": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", + "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "lodash.once": "^4.1.1" + } + }, + "node_modules/@cypress/xvfb/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@gql.tada/cli-utils": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@gql.tada/cli-utils/-/cli-utils-1.7.3.tgz", + "integrity": "sha512-3iQY5E/jvv3Lnh6D1Mh7zr+Bb9C/TGk1DHkm+lbIjQBnZAu2m+BcTcr1e3spUt6Aa6HG/xAN2XxpbWw9oZALEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/internal": "1.0.9", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/svelte-support": "1.0.2", + "@gql.tada/vue-support": "1.0.2", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@gql.tada/svelte-support": { + "optional": true + }, + "@gql.tada/vue-support": { + "optional": true + } + } + }, + "node_modules/@gql.tada/internal": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@gql.tada/internal/-/internal-1.0.9.tgz", + "integrity": "sha512-Bp8yi+kLrzIJ3l5Dfxhz48H4OCH2LCX+pShaPcJgh+oiBt6clrjUKDYNDD3Z78aDQ3+Tyrxe4dd0MfLgpSLPPg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@0no-co/graphql.web": "^1.0.5" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@iota/bcs": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@iota/bcs/-/bcs-1.6.0.tgz", + "integrity": "sha512-H8I9g+aPBQigSsHkydnnR6wmmTwOwI7iu/TghTOz6tikqVFM09cA2JlDQXRLDTSkW3Qlz6gONyVKzrBhoTkHxQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@scure/base": "^1.2.4" + } + }, + "node_modules/@iota/iota-interaction-ts": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@iota/iota-interaction-ts/-/iota-interaction-ts-0.13.0.tgz", + "integrity": "sha512-LL4nbgEbqqa3UqXkiAnbRMW3KSqKMic0cJEEooWlTz2VtwHSOHyVwzjF2HNt7C9o2svq5uKyCkkzVAxjMI1Esg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@iota/iota-sdk": "^1.13.0" + } + }, + "node_modules/@iota/iota-sdk": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@iota/iota-sdk/-/iota-sdk-1.13.0.tgz", + "integrity": "sha512-ay19PRu0z+1W9tnmGyexIR/Sp0Rpt7H0mUCuEZQ5B+7O6Qf61+Co8TrR1SRIrKwD7Fu90jPy5y5MwFXy/vLwKg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "@iota/bcs": "1.6.0", + "@noble/curves": "^1.4.2", + "@noble/hashes": "^1.4.0", + "@scure/base": "^1.2.4", + "@scure/bip32": "^1.4.0", + "@scure/bip39": "^1.3.0", + "bignumber.js": "^9.1.1", + "gql.tada": "^1.8.2", + "graphql": "^16.9.0", + "valibot": "^1.2.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/mocha": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", + "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", + "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sizzle": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", + "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@ungap/promise-all-settled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", + "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/blob-util": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", + "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cachedir": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", + "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-more-types": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", + "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", + "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "colors": "1.4.0" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cypress": { + "version": "14.5.4", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.5.4.tgz", + "integrity": "sha512-0Dhm4qc9VatOcI1GiFGVt8osgpPdqJLHzRwcAB5MSD/CAAts3oybvPUPawHyvJZUd8osADqZe/xzMsZ8sDTjXw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cypress/request": "^3.0.9", + "@cypress/xvfb": "^1.2.4", + "@types/sinonjs__fake-timers": "8.1.1", + "@types/sizzle": "^2.3.2", + "arch": "^2.2.0", + "blob-util": "^2.0.2", + "bluebird": "^3.7.2", + "buffer": "^5.7.1", + "cachedir": "^2.3.0", + "chalk": "^4.1.0", + "check-more-types": "^2.24.0", + "ci-info": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-table3": "0.6.1", + "commander": "^6.2.1", + "common-tags": "^1.8.0", + "dayjs": "^1.10.4", + "debug": "^4.3.4", + "enquirer": "^2.3.6", + "eventemitter2": "6.4.7", + "execa": "4.1.0", + "executable": "^4.1.1", + "extract-zip": "2.0.1", + "figures": "^3.2.0", + "fs-extra": "^9.1.0", + "getos": "^3.2.1", + "hasha": "5.2.2", + "is-installed-globally": "~0.4.0", + "lazy-ass": "^1.6.0", + "listr2": "^3.8.3", + "lodash": "^4.17.21", + "log-symbols": "^4.0.0", + "minimist": "^1.2.8", + "ospath": "^1.2.2", + "pretty-bytes": "^5.6.0", + "process": "^0.11.10", + "proxy-from-env": "1.0.0", + "request-progress": "^3.0.0", + "semver": "^7.7.1", + "supports-color": "^8.1.1", + "tmp": "~0.2.3", + "tree-kill": "1.2.2", + "untildify": "^4.0.0", + "yauzl": "^2.10.0" + }, + "bin": { + "cypress": "bin/cypress" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/cypress/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dprint": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/dprint/-/dprint-0.33.0.tgz", + "integrity": "sha512-VploASP7wL1HAYe5xWZKRwp8gW5zTdcG3Tb60DASv6QLnGKsl+OS+bY7wsXFrS4UcIbUNujXdsNG5FxBfRJIQg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "yauzl": "=2.10.0" + }, + "bin": { + "dprint": "bin.js" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, + "node_modules/eventemitter2": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", + "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/executable": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", + "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/getos": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", + "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.0" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gql.tada": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/gql.tada/-/gql.tada-1.9.2.tgz", + "integrity": "sha512-QxRHVpxtrOVdYXz6oavq0lBM+Zdp0swapLGJcD4SLpXDcsD337BHDFrzqqjfkbepv0sSAiO0LGabu1kI5D5Gyg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@0no-co/graphql.web": "^1.0.5", + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/cli-utils": "1.7.3", + "@gql.tada/internal": "1.0.9" + }, + "bin": { + "gql-tada": "bin/cli.js", + "gql.tada": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.x" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/http-signature": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", + "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^2.0.2", + "sshpk": "^1.18.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/joi": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsprim": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", + "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, + "node_modules/lazy-ass": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", + "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "> 0.8" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/listr2": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", + "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^2.1.0", + "colorette": "^2.0.16", + "log-update": "^4.0.0", + "p-map": "^4.0.0", + "rfdc": "^1.3.0", + "rxjs": "^7.5.1", + "through": "^2.3.8", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "enquirer": ">= 2.3.0 < 3" + }, + "peerDependenciesMeta": { + "enquirer": { + "optional": true + } + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", + "dev": true + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", + "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.2.2.tgz", + "integrity": "sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.3", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "4.2.1", + "ms": "2.1.3", + "nanoid": "3.3.1", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "which": "2.0.2", + "workerpool": "6.2.0", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", + "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==", + "dev": true, + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ospath": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", + "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dev": true, + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", + "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ps-tree": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-stream": "=3.3.4" + }, + "bin": { + "ps-tree": "bin/ps-tree.js" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/request-progress": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", + "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "throttleit": "^1.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/start-server-and-test": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.5.tgz", + "integrity": "sha512-A/SbXpgXE25ScSkpLLqvGvVZT0ykN6+AzS8tVqMBCTxbJy2Nwuen59opT+afalK5aS+AuQmZs0EsLwjnuDN+/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "arg": "^5.0.2", + "bluebird": "3.7.2", + "check-more-types": "2.24.0", + "debug": "4.4.3", + "execa": "5.1.1", + "lazy-ass": "1.6.0", + "ps-tree": "1.2.0", + "wait-on": "9.0.4" + }, + "bin": { + "server-test": "src/bin/start.js", + "start-server-and-test": "src/bin/start.js", + "start-test": "src/bin/start.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/start-server-and-test/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/start-server-and-test/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/start-server-and-test/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/start-server-and-test/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/start-server-and-test/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/throttleit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", + "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-mocha": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/ts-mocha/-/ts-mocha-9.0.2.tgz", + "integrity": "sha512-WyQjvnzwrrubl0JT7EC1yWmNpcsU3fOuBFfdps30zbmFBgKniSaSOyZMZx+Wq7kytUs5CY+pEbSYEbGfIKnXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-node": "7.0.1" + }, + "bin": { + "ts-mocha": "bin/ts-mocha" + }, + "engines": { + "node": ">= 6.X.X" + }, + "optionalDependencies": { + "tsconfig-paths": "^3.5.0" + }, + "peerDependencies": { + "mocha": "^3.X.X || ^4.X.X || ^5.X.X || ^6.X.X || ^7.X.X || ^8.X.X || ^9.X.X" + } + }, + "node_modules/ts-mocha/node_modules/diff": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.1.tgz", + "integrity": "sha512-Z3u54A8qGyqFOSr2pk0ijYs8mOE9Qz8kTvtKeBI+upoG9j04Sq+oI7W8zAJiQybDcESET8/uIdHzs0p3k4fZlw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ts-mocha/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/ts-mocha/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ts-mocha/node_modules/ts-node": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-7.0.1.tgz", + "integrity": "sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "arrify": "^1.0.0", + "buffer-from": "^1.1.0", + "diff": "^3.1.0", + "make-error": "^1.1.1", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "source-map-support": "^0.5.6", + "yn": "^2.0.0" + }, + "bin": { + "ts-node": "dist/bin.js" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/ts-mocha/node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/ts-mocha/node_modules/yn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", + "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/typedoc": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.17.tgz", + "integrity": "sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.17.0", + "lunr": "^2.3.9", + "markdown-it": "^14.1.0", + "minimatch": "^9.0.5", + "yaml": "^2.8.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" + } + }, + "node_modules/typedoc-plugin-markdown": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.10.0.tgz", + "integrity": "sha512-psrg8Rtnv4HPWCsoxId+MzEN8TVK5jeKCnTbnGAbTBqcDapR9hM41bJT/9eAyKn9C2MDG9Qjh3MkltAYuLDoXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "typedoc": "0.28.x" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/wait-on": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", + "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.13.5", + "joi": "^18.0.2", + "lodash": "^4.17.23", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/wasm-opt": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/wasm-opt/-/wasm-opt-1.4.0.tgz", + "integrity": "sha512-wIsxxp0/FOSphokH4VOONy1zPkVREQfALN+/JTvJPK8gFSKbsmrcfECu2hT7OowqPfb4WEMSMceHgNL0ipFRyw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.9", + "tar": "^6.1.13" + }, + "bin": { + "wasm-opt": "bin/wasm-opt.js" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workerpool": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.0.tgz", + "integrity": "sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/bindings/wasm/audit_trail_wasm/package.json b/bindings/wasm/audit_trail_wasm/package.json new file mode 100644 index 00000000..b3782ffa --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/package.json @@ -0,0 +1,73 @@ +{ + "name": "@iota/audit-trails", + "author": "IOTA Foundation ", + "description": "WASM bindings for IOTA Audit Trail. To be used in JavaScript/TypeScript.", + "homepage": "https://www.iota.org", + "version": "0.1.0-alpha", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/iotaledger/notarization.git" + }, + "directories": { + "example": "examples" + }, + "scripts": { + "build:src": "cargo build --lib --release --target wasm32-unknown-unknown --target-dir ../target", + "build:src:nodejs": "cargo build --lib --release --target wasm32-unknown-unknown --target-dir ../target", + "prebundle:nodejs": "rimraf node", + "bundle:nodejs": "wasm-bindgen ../target/wasm32-unknown-unknown/release/audit_trail_wasm.wasm --typescript --weak-refs --target nodejs --out-dir node && node ../build/node audit_trail_wasm && tsc --project ./lib/tsconfig.json && node ../build/replace_paths ./lib/tsconfig.json node audit_trail_wasm", + "prebundle:web": "rimraf web", + "bundle:web": "wasm-bindgen ../target/wasm32-unknown-unknown/release/audit_trail_wasm.wasm --typescript --target web --out-dir web && node ../build/web audit_trail_wasm && tsc --project ./lib/tsconfig.web.json && node ../build/replace_paths ./lib/tsconfig.web.json web audit_trail_wasm", + "build:nodejs": "npm run build:src:nodejs && npm run bundle:nodejs && wasm-opt -O node/audit_trail_wasm_bg.wasm -o node/audit_trail_wasm_bg.wasm", + "build:web": "npm run build:src && npm run bundle:web && wasm-opt -O web/audit_trail_wasm_bg.wasm -o web/audit_trail_wasm_bg.wasm", + "build:docs": "typedoc && npm run fix_docs", + "build:examples:web": "tsc --project ./examples/tsconfig.web.json && node ../build/replace_paths ./tsconfig.web.json dist audit_trail_wasm/examples resolve", + "build": "npm run build:web && npm run build:nodejs && npm run build:docs", + "example:node": "ts-node --project ./examples/tsconfig.node.json -r tsconfig-paths/register ./examples/src/main.ts", + "test": "npm run test:node", + "test:node": "ts-mocha -r tsconfig-paths/register -p ./examples/tsconfig.node.json ./examples/src/tests.ts --parallel --jobs 4 --retries 3 --timeout 180000 --exit", + "test:browser": "start-server-and-test example:web http://0.0.0.0:5173 'cypress run --headless'", + "test:browser:firefox": "start-server-and-test example:web http://0.0.0.0:5173 'cypress run --headless --browser firefox'", + "test:browser:chrome": "start-server-and-test example:web http://0.0.0.0:5173 'cypress run --headless --browser chrome'", + "example:web": "npm i --prefix ./cypress/app/ && npm run dev --prefix ./cypress/app/ -- --host", + "cypress": "cypress open", + "fmt": "dprint fmt", + "fix_docs": "find ./docs/wasm/ -type f -name '*.md' -exec sed -E -i.bak -e 's/(\\.md?#([^#]*)?)#/\\1/' {} ';' -exec rm {}.bak ';'" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "web/*", + "node/*" + ], + "devDependencies": { + "@types/mocha": "^9.1.0", + "@types/node": "^22.0.0", + "cypress": "^14.2.0", + "dprint": "^0.33.0", + "mocha": "^9.2.0", + "rimraf": "^6.0.1", + "start-server-and-test": "^2.0.11", + "ts-mocha": "^9.0.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.1.0", + "typedoc": "^0.28.5", + "typedoc-plugin-markdown": "^4.4.1", + "typescript": "^5.7.3", + "wasm-opt": "^1.4.0" + }, + "dependencies": { + "@iota/iota-interaction-ts": "^0.13.0" + }, + "peerDependencies": { + "@iota/iota-sdk": "^1.13.0" + }, + "config": { + "CYPRESS_VERIFY_TIMEOUT": 100000 + }, + "engines": { + "node": ">=20" + } +} diff --git a/bindings/wasm/audit_trail_wasm/rust-toolchain.toml b/bindings/wasm/audit_trail_wasm/rust-toolchain.toml new file mode 100644 index 00000000..825d39b5 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "stable" +components = ["rustfmt"] +targets = ["wasm32-unknown-unknown"] +profile = "minimal" diff --git a/bindings/wasm/audit_trail_wasm/src/builder.rs b/bindings/wasm/audit_trail_wasm/src/builder.rs new file mode 100644 index 00000000..bc979428 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/builder.rs @@ -0,0 +1,172 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use audit_trails::core::builder::AuditTrailBuilder; +use iota_interaction_ts::wasm_error::{Result, WasmResult}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::{into_transaction_builder, parse_wasm_iota_address}; +use product_common::bindings::WasmIotaAddress; +use wasm_bindgen::prelude::*; + +use crate::trail::WasmCreateTrail; +use crate::types::WasmLockingConfig; + +/// Builder that assembles the parameters for creating a new audit trail. +/// +/// @remarks +/// The resulting transaction publishes the trail as a *shared* object, seeds the reserved +/// {@link RoleMap.initialAdminRoleName | Admin} role with the permission set returned by +/// {@link PermissionSet.adminPermissions}, and transfers a freshly minted initial-admin +/// {@link Capability} to the configured admin address. An +/// admin address must be set (either through {@link AuditTrailBuilder.withAdmin} or by constructing +/// the builder via {@link AuditTrailClient.createTrail}, which seeds it with the signer); otherwise +/// {@link AuditTrailBuilder.finish} produces a transaction that fails to build. When an initial +/// record is set, its tag — if any — must already be in the configured record-tag list. +#[wasm_bindgen(js_name = AuditTrailBuilder, inspectable)] +pub struct WasmAuditTrailBuilder(pub(crate) AuditTrailBuilder); + +#[wasm_bindgen(js_class = AuditTrailBuilder)] +impl WasmAuditTrailBuilder { + /// Sets the initial record using a UTF-8 string payload. + /// + /// @remarks + /// The record is stored at sequence number `0`. + /// + /// When `tag` is provided it must already appear in the list passed to + /// {@link AuditTrailBuilder.withRecordTags}; the on-chain call aborts otherwise. + /// Bumps the tag's usage count on success. + /// + /// @param data - UTF-8 text payload for the initial record. + /// @param metadata - Optional application-defined metadata stored alongside the record. + /// @param tag - Optional trail-owned tag attached to the record. + /// + /// @returns The same builder, with the initial record configured. + #[wasm_bindgen(js_name = withInitialRecordString)] + pub fn with_initial_record_string(self, data: String, metadata: Option, tag: Option) -> Self { + Self(self.0.with_initial_record_parts(data, metadata, tag)) + } + + /// Sets the initial record using a raw byte payload. + /// + /// @remarks + /// The record is stored at sequence number `0`. + /// When `tag` is provided it must already appear in the list passed to + /// {@link AuditTrailBuilder.withRecordTags}; the on-chain call aborts otherwise. + /// Bumps the tag's usage count on success. + /// + /// @param data - Raw bytes stored as the initial record payload. + /// @param metadata - Optional application-defined metadata stored alongside the record. + /// @param tag - Optional trail-owned tag attached to the record. + /// + /// @returns The same builder, with the initial record configured. + #[wasm_bindgen(js_name = withInitialRecordBytes)] + pub fn with_initial_record_bytes( + self, + data: js_sys::Uint8Array, + metadata: Option, + tag: Option, + ) -> Self { + Self(self.0.with_initial_record_parts(data.to_vec(), metadata, tag)) + } + + /// Sets the trail's {@link ImmutableMetadata} (name and optional description). + /// + /// @remarks + /// Stored once at trail creation and exposed read-only thereafter. Use + /// {@link AuditTrailBuilder.withUpdatableMetadata} for the mutable counterpart. + /// + /// @param name - Human-readable trail name. + /// @param description - Optional human-readable description. + /// + /// @returns The same builder, with the immutable metadata configured. + #[wasm_bindgen(js_name = withTrailMetadata)] + pub fn with_trail_metadata(self, name: String, description: Option) -> Self { + Self(self.0.with_trail_metadata_parts(name, description)) + } + + /// Sets the trail's `updatableMetadata` field. + /// + /// @remarks + /// This field can later be replaced or cleared by holders of {@link Permission.UpdateMetadata} + /// via {@link AuditTrailHandle.updateMetadata}. + /// + /// @param metadata - Initial value of the trail's `updatableMetadata` field. + /// + /// @returns The same builder, with the updatable metadata configured. + #[wasm_bindgen(js_name = withUpdatableMetadata)] + pub fn with_updatable_metadata(self, metadata: String) -> Self { + Self(self.0.with_updatable_metadata(metadata)) + } + + /// Sets the {@link LockingConfig} for the trail. + /// + /// @remarks + /// `config.deleteTrailLock` must not be {@link TimeLock.withUntilDestroyed}; trail creation + /// aborts on-chain otherwise. + /// + /// @param config - Combined delete-record window, delete-trail lock, and write lock. + /// + /// @returns The same builder, with the locking configuration applied. + #[wasm_bindgen(js_name = withLockingConfig)] + pub fn with_locking_config(self, config: WasmLockingConfig) -> Self { + Self(self.0.with_locking_config(config.into())) + } + + /// Sets the canonical list of record tags owned by the trail. + /// + /// @remarks + /// Every tag name later referenced by an initial record, an {@link TrailRecords.add} call, or a + /// role's {@link RoleTags} allowlist must appear in this list. Tags are inserted with a usage + /// count of zero. + /// + /// @param tags - Tag names that the trail will recognize. + /// + /// @returns The same builder, with the record-tag registry configured. + #[wasm_bindgen(js_name = withRecordTags)] + pub fn with_record_tags(self, tags: Vec) -> Self { + Self(self.0.with_record_tags(tags)) + } + + /// Sets the initial admin address. + /// + /// @remarks + /// On execution the trail's role map is seeded with a single role named `"Admin"` carrying the + /// permission set returned by {@link PermissionSet.adminPermissions}, and a freshly minted + /// initial-admin capability is transferred to this address. Setting an admin is required before + /// {@link AuditTrailBuilder.finish} can + /// produce a viable transaction; constructing the builder via + /// {@link AuditTrailClient.createTrail} already seeds it with the signer address. + /// + /// @param admin - Address that will receive the initial-admin capability. + /// + /// @returns The same builder, with the admin address configured. + /// + /// @throws When `admin` is not a valid IOTA address. + #[wasm_bindgen(js_name = withAdmin)] + pub fn with_admin(self, admin: WasmIotaAddress) -> Result { + let admin = parse_wasm_iota_address(&admin)?; + Ok(Self(self.0.with_admin(admin))) + } + + /// Finalizes the builder into a transaction wrapper. + /// + /// @remarks + /// On execution the audit-trail package shares the new trail object, seeds the reserved + /// {@link RoleMap.initialAdminRoleName | Admin} role with the permission set returned by + /// {@link PermissionSet.adminPermissions}, transfers an initial-admin capability to the + /// configured admin address, and optionally stores the initial record at sequence number + /// `0`. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link CreateTrail} transaction. + /// + /// @throws When the builder is missing a required field or its initial record references a tag + /// that is not in the record-tag list, or when the configured {@link LockingConfig} is invalid + /// (for example, a count-based delete window with `count == 0`). + /// + /// Emits an {@link AuditTrailCreated} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn finish(self) -> Result { + let tx = self.0.finish().wasm_result()?.into_inner(); + Ok(into_transaction_builder(WasmCreateTrail(tx))) + } +} diff --git a/bindings/wasm/audit_trail_wasm/src/client.rs b/bindings/wasm/audit_trail_wasm/src/client.rs new file mode 100644 index 00000000..1625a51d --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/client.rs @@ -0,0 +1,250 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly, PackageOverrides}; +use iota_interaction_ts::bindings::{WasmIotaClient, WasmPublicKey, WasmTransactionSigner}; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; +use product_common::bindings::utils::parse_wasm_object_id; +use product_common::bindings::WasmObjectID; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use wasm_bindgen::prelude::*; + +use crate::builder::WasmAuditTrailBuilder; +use crate::client_read_only::{WasmAuditTrailClientReadOnly, WasmPackageOverrides}; +use crate::trail_handle::WasmAuditTrailHandle; + +/// Signing audit-trail client. +/// +/// @remarks +/// Wraps an {@link AuditTrailClientReadOnly} together with a transaction signer so that typed +/// write transactions can be built. The actual transaction submission and execution remain the +/// responsibility of the caller. +#[derive(Clone)] +#[wasm_bindgen(js_name = AuditTrailClient)] +pub struct WasmAuditTrailClient(pub(crate) AuditTrailClient); + +#[wasm_bindgen(js_class = AuditTrailClient)] +impl WasmAuditTrailClient { + /// Creates a signing client from an existing read-only client and signer. + /// + /// @param client - Read-only client whose network and package configuration will be reused. + /// @param signer - Signer that will sign transactions built by this client. + /// + /// @returns A signing audit-trail client bound to `client`'s network and the given signer. + /// + /// @throws When the signer cannot be queried for its public key or address. + #[wasm_bindgen(js_name = create)] + pub async fn new( + client: WasmAuditTrailClientReadOnly, + signer: WasmTransactionSigner, + ) -> Result { + let client = AuditTrailClient::new(client.0, signer).await.wasm_result()?; + Ok(Self(client)) + } + + /// Creates a signing client directly from an IOTA client and signer. + /// + /// @remarks + /// Pass `packageId` when connecting to a custom deployment that is not known to the package + /// registry; otherwise package IDs are resolved from the connected network. + /// + /// @param iotaClient - IOTA client used to talk to the network. + /// @param signer - Signer that will sign transactions built by this client. + /// @param packageId - Optional audit-trail package ID override. + /// + /// @returns A signing audit-trail client bound to the resolved or supplied package IDs. + /// + /// @throws When package resolution fails or the supplied `packageId` is malformed. + #[wasm_bindgen(js_name = createFromIotaClient)] + pub async fn create_from_iota_client( + iota_client: WasmIotaClient, + signer: WasmTransactionSigner, + package_id: Option, + ) -> Result { + let read_only = if let Some(package_id) = package_id { + let package_id = parse_wasm_object_id(&package_id)?; + AuditTrailClientReadOnly::new_with_package_overrides( + iota_client, + PackageOverrides { + audit_trail: Some(package_id), + tf_component: None, + }, + ) + .await + .wasm_result()? + } else { + AuditTrailClientReadOnly::new(iota_client).await.wasm_result()? + }; + + let client = AuditTrailClient::new(read_only, signer).await.wasm_result()?; + Ok(Self(client)) + } + + /// Creates a signing client directly from an IOTA client, signer, and full package overrides. + /// + /// @param iotaClient - IOTA client used to talk to the network. + /// @param signer - Signer that will sign transactions built by this client. + /// @param packageOverrides - Optional explicit package IDs; when omitted the registry is used. + /// + /// @returns A signing audit-trail client bound to the resolved or supplied package IDs. + /// + /// @throws When package resolution fails or the supplied overrides are malformed. + #[wasm_bindgen(js_name = createFromIotaClientWithPackageOverrides)] + pub async fn create_from_iota_client_with_package_overrides( + iota_client: WasmIotaClient, + signer: WasmTransactionSigner, + package_overrides: Option, + ) -> Result { + let read_only = if let Some(package_overrides) = package_overrides { + let package_overrides = PackageOverrides::try_from(package_overrides)?; + AuditTrailClientReadOnly::new_with_package_overrides(iota_client, package_overrides) + .await + .wasm_result()? + } else { + AuditTrailClientReadOnly::new(iota_client).await.wasm_result()? + }; + + let client = AuditTrailClient::new(read_only, signer).await.wasm_result()?; + Ok(Self(client)) + } + + /// Returns the public key of the address that signs transactions built by this client. + /// + /// @returns Public key bound to the signer. + /// + /// @throws When the signer's public key cannot be converted to the expected representation. + #[wasm_bindgen(js_name = senderPublicKey)] + pub fn sender_public_key(&self) -> Result { + self.0.public_key().try_into() + } + + /// Returns the address that signs transactions built by this client. + /// + /// @returns Stringified IOTA address of the signer. + #[wasm_bindgen(js_name = senderAddress)] + pub fn sender_address(&self) -> String { + self.0.address().to_string() + } + + /// Returns the human-readable name of the network this client is connected to. + /// + /// @returns Network name (e.g. `"mainnet"`, `"testnet"`, `"localnet"`). + #[wasm_bindgen] + pub fn network(&self) -> String { + self.0.network().to_string() + } + + /// Returns the chain ID of the network this client is connected to. + /// + /// @returns Hex-encoded chain identifier. + #[wasm_bindgen(js_name = chainId)] + pub fn chain_id(&self) -> String { + self.0.chain_id().to_string() + } + + /// Returns the audit-trail package ID currently in use. + /// + /// @returns Stringified object ID of the resolved audit-trail package. + #[wasm_bindgen(js_name = packageId)] + pub fn package_id(&self) -> String { + self.0.package_id().to_string() + } + + /// Returns the `tf_components` package ID currently in use. + /// + /// @returns Stringified object ID of the resolved `tf_components` package. + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> String { + self.0.tf_components_package_id().to_string() + } + + /// Returns the resolved audit-trail package upgrade history. + /// + /// @returns Stringified object IDs of every published version, most recent first. + #[wasm_bindgen(js_name = packageHistory)] + pub fn package_history(&self) -> Vec { + self.0 + .package_history() + .into_iter() + .map(|pkg_id| pkg_id.to_string()) + .collect() + } + + /// Returns the underlying IOTA client used to talk to the network. + /// + /// @returns The IOTA client carried by the wrapped read-only client. + #[wasm_bindgen(js_name = iotaClient)] + pub fn iota_client(&self) -> WasmIotaClient { + self.0.read_only().iota_client().clone().into_inner() + } + + /// Returns the signer attached to this client. + /// + /// @returns A clone of the configured transaction signer. + #[wasm_bindgen] + pub fn signer(&self) -> WasmTransactionSigner { + self.0.signer().clone() + } + + /// Returns a clone of this client whose transactions are signed by `signer` instead. + /// + /// @remarks + /// Network and package configuration are preserved. The returned client's + /// {@link AuditTrailClient.senderAddress} reflects the new signer. + /// + /// @param signer - Replacement transaction signer. + /// + /// @returns A new client with the signer swapped in. + /// + /// @throws When the replacement signer cannot be queried for its public key or address. + #[wasm_bindgen(js_name = withSigner)] + pub async fn with_signer(self, signer: WasmTransactionSigner) -> Result { + let client = self + .0 + .with_signer(signer) + .await + .map_err(|err| wasm_error(anyhow!(err.to_string())))?; + Ok(Self(client)) + } + + /// Returns the read-only view of this client. + /// + /// @remarks + /// Useful when passing the client into code that only needs read capabilities. + /// + /// @returns A {@link AuditTrailClientReadOnly} sharing this client's network configuration. + #[wasm_bindgen(js_name = readOnly)] + pub fn read_only(&self) -> WasmAuditTrailClientReadOnly { + WasmAuditTrailClientReadOnly(self.0.read_only().clone()) + } + + /// Creates a builder for a new audit trail. + /// + /// @remarks + /// The builder is pre-populated with the signer address as the initial admin, so the trail's + /// initial-admin capability lands in the signer's wallet on execution. Override with + /// {@link AuditTrailBuilder.withAdmin} when a different recipient is needed. + /// + /// @returns A pre-configured {@link AuditTrailBuilder}. + #[wasm_bindgen(js_name = createTrail)] + pub fn create_trail(&self) -> WasmAuditTrailBuilder { + WasmAuditTrailBuilder(self.0.create_trail()) + } + + /// Returns a trail-scoped handle for the given trail object ID. + /// + /// @remarks + /// Creating the handle is cheap. Network reads and transaction building happen on the returned + /// handle and its subsystem wrappers. + /// + /// @param trailId - Object ID of the trail this handle should target. + /// + /// @returns A signing {@link AuditTrailHandle} bound to `trailId`. + /// + /// @throws When `trailId` is not a valid object ID. + pub fn trail(&self, trail_id: WasmObjectID) -> Result { + let trail_id = parse_wasm_object_id(&trail_id)?; + Ok(WasmAuditTrailHandle::from_full(self.0.clone(), trail_id)) + } +} diff --git a/bindings/wasm/audit_trail_wasm/src/client_read_only.rs b/bindings/wasm/audit_trail_wasm/src/client_read_only.rs new file mode 100644 index 00000000..9a469b86 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/client_read_only.rs @@ -0,0 +1,222 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use audit_trails::{AuditTrailClientReadOnly, PackageOverrides}; +use iota_interaction_ts::bindings::WasmIotaClient; +use iota_interaction_ts::wasm_error::{Result, WasmResult}; +use product_common::bindings::utils::parse_wasm_object_id; +use product_common::bindings::WasmObjectID; +use product_common::core_client::CoreClientReadOnly; +use wasm_bindgen::prelude::*; + +use crate::trail_handle::WasmAuditTrailHandle; + +/// Package-ID overrides used when targeting custom audit-trail deployments. +/// +/// @remarks +/// Pass an instance of this type to +/// {@link AuditTrailClientReadOnly.createWithPackageOverrides} or +/// {@link AuditTrailClient.createFromIotaClientWithPackageOverrides} when the connected network +/// hosts the audit-trail package — and optionally the `tf_components` package — at addresses that +/// are not part of the audit-trail package's built-in registry. Leave a field unset to fall back to the registry +/// lookup for that package. +#[derive(Clone)] +#[wasm_bindgen(js_name = PackageOverrides, getter_with_clone, inspectable)] +pub struct WasmPackageOverrides { + /// Override for the audit-trail package ID. + #[wasm_bindgen(js_name = auditTrailPackageId)] + pub audit_trail_package_id: Option, + /// Override for the `tf_components` package ID. + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub tf_components_package_id: Option, +} + +#[wasm_bindgen(js_class = PackageOverrides)] +impl WasmPackageOverrides { + /// Creates package overrides for custom deployments. + /// + /// @param auditTrailPackageId - Optional audit-trail package ID to use instead of the registry + /// entry. + /// @param tfComponentsPackageId - Optional `tf_components` package ID to use instead of the + /// registry entry. + #[wasm_bindgen(constructor)] + pub fn new( + audit_trail_package_id: Option, + tf_components_package_id: Option, + ) -> WasmPackageOverrides { + Self { + audit_trail_package_id, + tf_components_package_id, + } + } +} + +impl TryFrom for PackageOverrides { + type Error = JsValue; + + fn try_from(value: WasmPackageOverrides) -> std::result::Result { + Ok(Self { + audit_trail: value + .audit_trail_package_id + .as_ref() + .map(parse_wasm_object_id) + .transpose()?, + tf_component: value + .tf_components_package_id + .as_ref() + .map(parse_wasm_object_id) + .transpose()?, + }) + } +} + +/// Read-only audit-trail client. +/// +/// @remarks +/// This is the main entry point for package resolution and typed reads. Use +/// {@link AuditTrailClientReadOnly.trail} to obtain an {@link AuditTrailHandle} bound to a single +/// trail object. +#[derive(Clone)] +#[wasm_bindgen(js_name = AuditTrailClientReadOnly)] +pub struct WasmAuditTrailClientReadOnly(pub(crate) AuditTrailClientReadOnly); + +#[wasm_bindgen(js_class = AuditTrailClientReadOnly)] +impl WasmAuditTrailClientReadOnly { + /// Creates a read-only client by resolving package IDs from the connected network. + /// + /// @remarks + /// This is the recommended constructor for official deployments tracked by the built-in + /// package registry. + /// + /// @param iotaClient - IOTA client used to talk to the network. + /// + /// @returns A read-only audit-trail client bound to the resolved package IDs. + /// + /// @throws When package resolution fails for the connected network. + #[wasm_bindgen(js_name = create)] + pub async fn new(iota_client: WasmIotaClient) -> Result { + let client = AuditTrailClientReadOnly::new(iota_client).await.wasm_result()?; + Ok(Self(client)) + } + + /// Creates a read-only client with explicit package overrides. + /// + /// @remarks + /// Prefer this when targeting a local deployment, preview environment, or any package pair + /// that is not yet part of the package's built-in registry. + /// + /// @param iotaClient - IOTA client used to talk to the network. + /// @param packageOverrides - Package IDs to use instead of registry lookups. + /// + /// @returns A read-only audit-trail client bound to the supplied package IDs. + /// + /// @throws When the supplied package IDs are malformed or cannot be resolved. + #[wasm_bindgen(js_name = createWithPackageOverrides)] + pub async fn new_with_package_overrides( + iota_client: WasmIotaClient, + package_overrides: WasmPackageOverrides, + ) -> Result { + let package_overrides = PackageOverrides::try_from(package_overrides)?; + let client = AuditTrailClientReadOnly::new_with_package_overrides(iota_client, package_overrides) + .await + .wasm_result()?; + Ok(Self(client)) + } + + /// Creates a read-only client while overriding only the audit-trail package ID. + /// + /// @remarks + /// Compatibility helper for callers that need exactly one package override. + /// + /// @param iotaClient - IOTA client used to talk to the network. + /// @param packageId - Audit-trail package ID to use instead of the registry entry. + /// + /// @returns A read-only audit-trail client bound to `packageId`. + /// + /// @throws When `packageId` is malformed or cannot be resolved. + #[wasm_bindgen(js_name = createWithPkgId)] + pub async fn new_with_pkg_id( + iota_client: WasmIotaClient, + package_id: WasmObjectID, + ) -> Result { + let package_id = parse_wasm_object_id(&package_id)?; + let client = AuditTrailClientReadOnly::new_with_package_overrides( + iota_client, + PackageOverrides { + audit_trail: Some(package_id), + tf_component: None, + }, + ) + .await + .wasm_result()?; + Ok(Self(client)) + } + + /// Returns the audit-trail package ID currently in use. + /// + /// @returns Stringified object ID of the resolved audit-trail package. + #[wasm_bindgen(js_name = packageId)] + pub fn package_id(&self) -> String { + self.0.package_id().to_string() + } + + /// Returns the `tf_components` package ID currently in use. + /// + /// @returns Stringified object ID of the resolved `tf_components` package. + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> String { + self.0.tf_components_package_id().to_string() + } + + /// Returns the resolved audit-trail package upgrade history. + /// + /// @returns Stringified object IDs of every published version, most recent first. + #[wasm_bindgen(js_name = packageHistory)] + pub fn package_history(&self) -> Vec { + self.0 + .package_history() + .into_iter() + .map(|pkg_id| pkg_id.to_string()) + .collect() + } + + /// Returns the human-readable name of the network this client is connected to. + /// + /// @returns Network name (e.g. `"mainnet"`, `"testnet"`, `"localnet"`). + #[wasm_bindgen] + pub fn network(&self) -> String { + self.0.network().to_string() + } + + /// Returns the chain ID of the network this client is connected to. + /// + /// @returns Hex-encoded chain identifier. + #[wasm_bindgen(js_name = chainId)] + pub fn chain_id(&self) -> String { + self.0.chain_id().to_string() + } + + /// Returns the underlying IOTA client used to talk to the network. + /// + /// @returns The IOTA client passed to (or constructed during) creation of this client. + #[wasm_bindgen(js_name = iotaClient)] + pub fn iota_client(&self) -> WasmIotaClient { + self.0.iota_client().clone().into_inner() + } + + /// Returns a trail-scoped handle for the given trail object ID. + /// + /// @remarks + /// Creating the handle is cheap. Reads only happen when methods are called on the returned + /// handle. + /// + /// @param trailId - Object ID of the trail this handle should target. + /// + /// @returns Read-only {@link AuditTrailHandle} bound to `trailId`. + /// + /// @throws When `trailId` is not a valid object ID. + pub fn trail(&self, trail_id: WasmObjectID) -> Result { + let trail_id = parse_wasm_object_id(&trail_id)?; + Ok(WasmAuditTrailHandle::from_read_only(self.0.clone(), trail_id)) + } +} diff --git a/bindings/wasm/audit_trail_wasm/src/lib.rs b/bindings/wasm/audit_trail_wasm/src/lib.rs new file mode 100644 index 00000000..8e19f317 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/lib.rs @@ -0,0 +1,41 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![doc = include_str!("../README.md")] +#![warn(rustdoc::all)] +#![allow(deprecated)] +#![allow(clippy::upper_case_acronyms)] +#![allow(clippy::drop_non_drop)] +#![allow(clippy::unused_unit)] +#![allow(clippy::await_holding_refcell_ref)] + +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsValue; + +pub(crate) mod builder; +pub(crate) mod client; +pub(crate) mod client_read_only; +mod trail; +pub(crate) mod trail_handle; +pub(crate) mod types; + +/// Shared wasm bindings re-exported from `product_common`. +pub use product_common::bindings::*; + +/// Installs the panic hook used by the wasm bindings. +#[wasm_bindgen(start)] +pub fn start() -> std::result::Result<(), JsValue> { + console_error_panic_hook::set_once(); + Ok(()) +} + +#[wasm_bindgen(typescript_custom_section)] +const CUSTOM_IMPORTS: &str = r#" +import { + Transaction, + TransactionOutput, + TransactionBuilder, + CoreClient, + CoreClientReadOnly +} from '../lib/index'; +"#; diff --git a/bindings/wasm/audit_trail_wasm/src/trail.rs b/bindings/wasm/audit_trail_wasm/src/trail.rs new file mode 100644 index 00000000..578f9339 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/trail.rs @@ -0,0 +1,1230 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use audit_trails::core::access::{ + CleanupRevokedCapabilities, CreateRole, DeleteRole, DestroyCapability, DestroyInitialAdminCapability, + IssueCapability, RevokeCapability, RevokeInitialAdminCapability, UpdateRole, +}; +use audit_trails::core::create::{CreateTrail, TrailCreated}; +use audit_trails::core::locking::{ + UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLockingConfig, UpdateWriteLock, +}; +use audit_trails::core::records::{AddRecord, DeleteRecord, DeleteRecordsBatch}; +use audit_trails::core::tags::{AddRecordTag, RemoveRecordTag}; +use audit_trails::core::trail::{DeleteAuditTrail, Migrate, UpdateMetadata}; +use audit_trails::core::types::{ + AuditTrailDeleted, CapabilityDestroyed, CapabilityIssued, CapabilityRevoked, OnChainAuditTrail, RecordAdded, + RecordDeleted, RevokedCapabilitiesCleanedUp, RoleCreated, RoleDeleted, RoleUpdated, +}; +use iota_interaction_ts::bindings::{WasmIotaTransactionBlockEffects, WasmIotaTransactionBlockEvents}; +use iota_interaction_ts::core_client::WasmCoreClientReadOnly; +use iota_interaction_ts::wasm_error::{Result, WasmResult}; +use product_common::bindings::core_client::WasmManagedCoreClientReadOnly; +use product_common::bindings::utils::{apply_with_events, build_programmable_transaction}; +use wasm_bindgen::prelude::*; + +use crate::builder::WasmAuditTrailBuilder; +use crate::types::{ + WasmAuditTrailDeleted, WasmCapabilityDestroyed, WasmCapabilityIssued, WasmCapabilityRevoked, WasmEmpty, + WasmImmutableMetadata, WasmLinkedTable, WasmLockingConfig, WasmRecordAdded, WasmRecordDeleted, WasmRecordTagEntry, + WasmRevokedCapabilitiesCleanedUp, WasmRoleCreated, WasmRoleDeleted, WasmRoleMap, WasmRoleUpdated, +}; + +/// Read-only view of an on-chain audit trail. +/// +/// @remarks +/// The trail is a *shared*, tamper-evident object that maintains an ordered sequence of records. +/// Each record is assigned a unique, auto-incrementing sequence number that is never reused (the +/// counter does not decrement on deletion). Access is governed by capability-based RBAC: every +/// mutating call must present a {@link Capability} bound to a role whose permissions cover the +/// operation. +#[wasm_bindgen(js_name = OnChainAuditTrail, inspectable)] +#[derive(Clone)] +pub struct WasmOnChainAuditTrail(pub(crate) OnChainAuditTrail); + +#[wasm_bindgen(js_class = OnChainAuditTrail)] +impl WasmOnChainAuditTrail { + pub(crate) fn new(trail: OnChainAuditTrail) -> Self { + Self(trail) + } + + /// Returns the trail object ID. + /// + /// @returns Stringified object ID of this trail. + #[wasm_bindgen(getter)] + pub fn id(&self) -> String { + self.0.id.id.to_string() + } + + /// Returns the address that created this trail. + /// + /// @returns Stringified IOTA address of the trail creator. + #[wasm_bindgen(getter)] + pub fn creator(&self) -> String { + self.0.creator.to_string() + } + + /// Returns the creation timestamp in milliseconds since the Unix epoch. + /// + /// @returns Creation timestamp in milliseconds. + #[wasm_bindgen(js_name = createdAt, getter)] + pub fn created_at(&self) -> u64 { + self.0.created_at + } + + /// Returns the next sequence number that will be assigned to a new record. + /// + /// @remarks + /// This is a monotonic counter that never decrements, even after records are deleted, so + /// existing sequence numbers remain unique for the lifetime of the trail. + /// + /// @returns Sequence number that the next added record will receive. + #[wasm_bindgen(js_name = sequenceNumber, getter)] + pub fn sequence_number(&self) -> u64 { + self.0.sequence_number + } + + /// Returns the active locking configuration that governs record deletion, trail deletion, and + /// record writes. + /// + /// @returns Active {@link LockingConfig} for the trail. + #[wasm_bindgen(js_name = lockingConfig, getter)] + pub fn locking_config(&self) -> WasmLockingConfig { + self.0.locking_config.clone().into() + } + + /// Returns the linked-table metadata for record storage. + /// + /// @remarks + /// Returns table size and head/tail sequence numbers; record contents must be loaded via + /// {@link TrailRecords}. + /// + /// @returns {@link LinkedTable} metadata for the record table. + #[wasm_bindgen(getter)] + pub fn records(&self) -> WasmLinkedTable { + self.0.records.clone().into() + } + + /// Returns the canonical list of tags that may be attached to records in this trail, together + /// with their combined usage counts. + /// + /// @returns Tag entries sorted alphabetically by tag name. + #[wasm_bindgen(getter)] + pub fn tags(&self) -> Vec { + let mut tags: Vec = self + .0 + .tags + .iter() + .map(|(tag, usage_count)| (tag.clone(), *usage_count).into()) + .collect(); + tags.sort_unstable_by(|left, right| left.tag.cmp(&right.tag)); + tags + } + + /// Returns the trail's role definitions, the revoked-capability denylist, and the permissions + /// required to administer roles and capabilities. + /// + /// @returns The trail's {@link RoleMap}. + #[wasm_bindgen(getter)] + pub fn roles(&self) -> WasmRoleMap { + self.0.roles.clone().into() + } + + /// Returns metadata fixed at creation time, when present. + /// + /// @returns The trail's {@link ImmutableMetadata}, or `null` when none was set. + #[wasm_bindgen(js_name = immutableMetadata, getter)] + pub fn immutable_metadata(&self) -> Option { + self.0.immutable_metadata.clone().map(Into::into) + } + + /// Returns metadata that holders of {@link Permission.UpdateMetadata} can change after + /// creation, when present. + /// + /// @returns Current value of `updatableMetadata`, or `null` when the field is unset. + #[wasm_bindgen(js_name = updatableMetadata, getter)] + pub fn updatable_metadata(&self) -> Option { + self.0.updatable_metadata.clone() + } + + /// Returns the on-chain package version of the trail object. + /// + /// @remarks + /// Use {@link AuditTrailHandle.migrate} after a package upgrade if this lags behind the package's + /// expected version. + /// + /// @returns Stored package version of the trail object. + #[wasm_bindgen(getter)] + pub fn version(&self) -> u64 { + self.0.version + } +} + +impl From for WasmOnChainAuditTrail { + fn from(value: OnChainAuditTrail) -> Self { + Self::new(value) + } +} + +async fn apply_trail_created( + tx: CreateTrail, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, +) -> Result { + let managed_client = WasmManagedCoreClientReadOnly::from_wasm(client)?; + let created: TrailCreated = apply_with_events(tx, wasm_effects, wasm_events, client).await?; + let trail = created.fetch_audit_trail(&managed_client).await.wasm_result()?; + Ok(trail.into()) +} + +/// Transaction wrapper for trail creation. +/// +/// @remarks +/// On execution the audit-trail package shares the new trail object, seeds the reserved +/// {@link RoleMap.initialAdminRoleName | Admin} role with the permission set returned by +/// {@link PermissionSet.adminPermissions}, transfers a fresh initial-admin capability to +/// the admin address, and optionally stores the initial record at sequence number `0`, validating +/// its tag against the registry. +/// +/// Emits an {@link AuditTrailCreated} event on success. +#[wasm_bindgen(js_name = CreateTrail, inspectable)] +pub struct WasmCreateTrail(pub(crate) CreateTrail); + +#[wasm_bindgen(js_class = CreateTrail)] +impl WasmCreateTrail { + /// Creates a transaction wrapper from an {@link AuditTrailBuilder}. + /// + /// @param builder - Fully configured {@link AuditTrailBuilder}. + #[wasm_bindgen(constructor)] + pub fn new(builder: WasmAuditTrailBuilder) -> Self { + Self(CreateTrail::new(builder.0)) + } + + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and then fetches the created trail object. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used to fetch the new trail object. + /// + /// @returns The on-chain {@link OnChainAuditTrail} created by the transaction. + /// + /// @throws When the expected event is missing or the trail cannot be fetched. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_trail_created(self.0, wasm_effects, wasm_events, client).await + } +} + +/// Transaction wrapper for mutable-metadata updates. +/// +/// @remarks +/// Passing `null`/`undefined` for the new metadata clears the `updatableMetadata` field on-chain. +/// +/// Requires the {@link Permission.UpdateMetadata} permission. +/// +/// Emits a {@link MetadataUpdated} event on success. +#[wasm_bindgen(js_name = UpdateMetadata, inspectable)] +pub struct WasmUpdateMetadata(pub(crate) UpdateMetadata); + +#[wasm_bindgen(js_class = UpdateMetadata)] +impl WasmUpdateMetadata { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @throws When transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +/// Transaction wrapper for trail migration. +/// +/// @remarks +/// Succeeds only when the on-chain trail's package version is strictly less than the package +/// version this binding targets. +/// +/// Requires the {@link Permission.Migrate} permission. +/// +/// Emits an {@link AuditTrailMigrated} event on success. +#[wasm_bindgen(js_name = Migrate, inspectable)] +pub struct WasmMigrate(pub(crate) Migrate); + +#[wasm_bindgen(js_class = Migrate)] +impl WasmMigrate { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @throws When transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +/// Transaction wrapper for deleting a trail. +/// +/// @remarks +/// Aborts on-chain when records still exist or while the configured trail-delete time lock is +/// active. +/// +/// Requires the {@link Permission.DeleteAuditTrail} permission. +/// +/// Emits an {@link AuditTrailDeleted} event on success. +#[wasm_bindgen(js_name = DeleteAuditTrail, inspectable)] +pub struct WasmDeleteAuditTrail(pub(crate) DeleteAuditTrail); + +#[wasm_bindgen(js_class = DeleteAuditTrail)] +impl WasmDeleteAuditTrail { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link AuditTrailDeleted} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: AuditTrailDeleted = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +/// Transaction wrapper for replacing the full locking configuration. +/// +/// @remarks +/// The supplied configuration's `deleteTrailLock` must not be {@link TimeLock.withUntilDestroyed}; +/// the call aborts on-chain otherwise. +/// +/// Requires the {@link Permission.UpdateLockingConfig} permission. +/// +/// Emits a {@link LockingConfigUpdated} event on success. +#[wasm_bindgen(js_name = UpdateLockingConfig, inspectable)] +pub struct WasmUpdateLockingConfig(pub(crate) UpdateLockingConfig); + +#[wasm_bindgen(js_class = UpdateLockingConfig)] +impl WasmUpdateLockingConfig { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @throws When transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +/// Transaction wrapper for updating the delete-record window. +/// +/// @remarks +/// Updates only the rule that locks individual records against deletion (time-based or +/// count-based). +/// +/// Requires the {@link Permission.UpdateLockingConfigForDeleteRecord} permission. +/// +/// Emits a {@link LockingConfigUpdated} event on success. +#[wasm_bindgen(js_name = UpdateDeleteRecordWindow, inspectable)] +pub struct WasmUpdateDeleteRecordWindow(pub(crate) UpdateDeleteRecordWindow); + +#[wasm_bindgen(js_class = UpdateDeleteRecordWindow)] +impl WasmUpdateDeleteRecordWindow { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @throws When transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +/// Transaction wrapper for updating the delete-trail lock. +/// +/// @remarks +/// The new lock must not be {@link TimeLock.withUntilDestroyed}. +/// +/// Requires the {@link Permission.UpdateLockingConfigForDeleteTrail} permission. +/// +/// Emits a {@link LockingConfigUpdated} event on success. +#[wasm_bindgen(js_name = UpdateDeleteTrailLock, inspectable)] +pub struct WasmUpdateDeleteTrailLock(pub(crate) UpdateDeleteTrailLock); + +#[wasm_bindgen(js_class = UpdateDeleteTrailLock)] +impl WasmUpdateDeleteTrailLock { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @throws When transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +/// Transaction wrapper for updating the write lock. +/// +/// @remarks +/// While the new lock is active, {@link TrailRecords.add} aborts on-chain. +/// +/// Requires the {@link Permission.UpdateLockingConfigForWrite} permission. +/// +/// Emits a {@link LockingConfigUpdated} event on success. +#[wasm_bindgen(js_name = UpdateWriteLock, inspectable)] +pub struct WasmUpdateWriteLock(pub(crate) UpdateWriteLock); + +#[wasm_bindgen(js_class = UpdateWriteLock)] +impl WasmUpdateWriteLock { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @throws When transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +/// Transaction wrapper for creating a role. +/// +/// @remarks +/// Any `roleTags` supplied must already exist in the trail's record-tag registry; the on-chain +/// call aborts otherwise. +/// +/// Requires the {@link Permission.AddRoles} permission. +/// +/// Emits a {@link RoleCreated} event on success. +#[wasm_bindgen(js_name = CreateRole, inspectable)] +pub struct WasmCreateRole(pub(crate) CreateRole); + +#[wasm_bindgen(js_class = CreateRole)] +impl WasmCreateRole { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link RoleCreated} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: RoleCreated = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +/// Transaction wrapper for updating a role. +/// +/// @remarks +/// Replaces both the role's permissions and its `roleTags`; any newly supplied tag must already be +/// in the trail's record-tag registry. +/// +/// Requires the {@link Permission.UpdateRoles} permission. +/// +/// Emits a {@link RoleUpdated} event on success. +#[wasm_bindgen(js_name = UpdateRole, inspectable)] +pub struct WasmUpdateRole(pub(crate) UpdateRole); + +#[wasm_bindgen(js_class = UpdateRole)] +impl WasmUpdateRole { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link RoleUpdated} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: RoleUpdated = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +/// Transaction wrapper for deleting a role. +/// +/// @remarks +/// The reserved initial-admin role (`"Admin"`) cannot be deleted. +/// +/// Requires the {@link Permission.DeleteRoles} permission. +/// +/// Emits a {@link RoleDeleted} event on success. +#[wasm_bindgen(js_name = DeleteRole, inspectable)] +pub struct WasmDeleteRole(pub(crate) DeleteRole); + +#[wasm_bindgen(js_class = DeleteRole)] +impl WasmDeleteRole { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link RoleDeleted} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: RoleDeleted = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +/// Transaction wrapper for issuing a capability. +/// +/// @remarks +/// Mints a new {@link Capability} for the role and transfers it to the configured recipient (or +/// the caller when none was set). The validity window configured via +/// {@link CapabilityIssueOptions} is enforced when the capability is later presented for +/// authorization. +/// +/// Requires the {@link Permission.AddCapabilities} permission. +/// +/// Emits a {@link CapabilityIssued} event on success. +#[wasm_bindgen(js_name = IssueCapability, inspectable)] +pub struct WasmIssueCapability(pub(crate) IssueCapability); + +#[wasm_bindgen(js_class = IssueCapability)] +impl WasmIssueCapability { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link CapabilityIssued} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityIssued = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +/// Transaction wrapper for revoking a capability. +/// +/// @remarks +/// Adds the capability ID to the trail's denylist. Pass `capabilityValidUntil` so +/// {@link CleanupRevokedCapabilities} can later prune the entry once that timestamp elapses; pass +/// `null` to keep the denylist entry permanently. +/// +/// Requires the {@link Permission.RevokeCapabilities} permission. +/// +/// Emits a {@link CapabilityRevoked} event on success. +#[wasm_bindgen(js_name = RevokeCapability, inspectable)] +pub struct WasmRevokeCapability(pub(crate) RevokeCapability); + +#[wasm_bindgen(js_class = RevokeCapability)] +impl WasmRevokeCapability { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link CapabilityRevoked} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityRevoked = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +/// Transaction wrapper for destroying a capability. +/// +/// @remarks +/// Consumes the owned capability object. This path is for ordinary capabilities only — +/// initial-admin capabilities must use {@link DestroyInitialAdminCapability}. +/// +/// Requires the {@link Permission.RevokeCapabilities} permission. +/// +/// Emits a {@link CapabilityDestroyed} event on success. +#[wasm_bindgen(js_name = DestroyCapability, inspectable)] +pub struct WasmDestroyCapability(pub(crate) DestroyCapability); + +#[wasm_bindgen(js_class = DestroyCapability)] +impl WasmDestroyCapability { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link CapabilityDestroyed} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityDestroyed = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +/// Transaction wrapper for destroying an initial-admin capability. +/// +/// @remarks +/// Self-service: the holder consumes their own initial-admin capability without presenting another +/// authorization capability. **Warning:** if every initial-admin capability is destroyed (and none +/// was issued separately), the trail is permanently sealed with no admin access. +/// +/// Emits a {@link CapabilityDestroyed} event on success. +#[wasm_bindgen(js_name = DestroyInitialAdminCapability, inspectable)] +pub struct WasmDestroyInitialAdminCapability(pub(crate) DestroyInitialAdminCapability); + +#[wasm_bindgen(js_class = DestroyInitialAdminCapability)] +impl WasmDestroyInitialAdminCapability { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link CapabilityDestroyed} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityDestroyed = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +/// Transaction wrapper for revoking an initial-admin capability. +/// +/// @remarks +/// Same denylist semantics as {@link RevokeCapability} but uses the dedicated entry point reserved +/// for initial-admin capability IDs. **Warning:** revoking every initial-admin capability +/// permanently seals the trail. +/// +/// Requires the {@link Permission.RevokeCapabilities} permission. +/// +/// Emits a {@link CapabilityRevoked} event on success. +#[wasm_bindgen(js_name = RevokeInitialAdminCapability, inspectable)] +pub struct WasmRevokeInitialAdminCapability(pub(crate) RevokeInitialAdminCapability); + +#[wasm_bindgen(js_class = RevokeInitialAdminCapability)] +impl WasmRevokeInitialAdminCapability { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link CapabilityRevoked} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityRevoked = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +/// Transaction wrapper for cleaning up expired revoked-capability entries. +/// +/// @remarks +/// Only prunes denylist entries whose stored `validUntil` is non-zero and strictly less than the +/// current clock time. Entries with `validUntil == 0` are kept indefinitely. Does not revoke +/// additional capabilities. +/// +/// Requires the {@link Permission.RevokeCapabilities} permission. +/// +/// Emits a {@link RevokedCapabilitiesCleanedUp} event on success. +#[wasm_bindgen(js_name = CleanupRevokedCapabilities, inspectable)] +pub struct WasmCleanupRevokedCapabilities(pub(crate) CleanupRevokedCapabilities); + +#[wasm_bindgen(js_class = CleanupRevokedCapabilities)] +impl WasmCleanupRevokedCapabilities { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link RevokedCapabilitiesCleanedUp} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let cleaned: RevokedCapabilitiesCleanedUp = + apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(cleaned.into()) + } +} + +/// Transaction wrapper for adding a record. +/// +/// @remarks +/// While the trail's `writeLock` is active the call aborts. Tagged writes additionally require the +/// tag to exist in the trail registry and the supplied capability's role to allow that tag. +/// Records are assigned the trail's current monotonic sequence number, which is never reused even +/// after deletions. +/// +/// Requires the {@link Permission.AddRecord} permission. +/// +/// Emits a {@link RecordAdded} event on success. +#[wasm_bindgen(js_name = AddRecord, inspectable)] +pub struct WasmAddRecord(pub(crate) AddRecord); + +#[wasm_bindgen(js_class = AddRecord)] +impl WasmAddRecord { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link RecordAdded} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let added: RecordAdded = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(added.into()) + } +} + +/// Transaction wrapper for deleting a single record. +/// +/// @remarks +/// Aborts on-chain when no record exists at the supplied sequence number or while the +/// delete-record window still protects it. Tag-aware authorization additionally applies when the +/// record carries a tag. +/// +/// Requires the {@link Permission.DeleteRecord} permission. +/// +/// Emits a {@link RecordDeleted} event on success. +#[wasm_bindgen(js_name = DeleteRecord, inspectable)] +pub struct WasmDeleteRecord(pub(crate) DeleteRecord); + +#[wasm_bindgen(js_class = DeleteRecord)] +impl WasmDeleteRecord { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching event payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Decoded {@link RecordDeleted} event payload. + /// + /// @throws When the expected event is missing or transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let deleted: RecordDeleted = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(deleted.into()) + } +} + +/// Transaction wrapper for deleting records in batch form. +/// +/// @remarks +/// Walks the trail from the front and silently skips records still inside the delete-record +/// window or whose tag the capability does not allow. The set of locked records is fixed at +/// the start of the on-chain call: count-based windows protect the last `count` records present +/// when the call begins, and time-based windows are evaluated against the clock timestamp +/// captured at that point. +/// +/// `limit` caps the number of records actually deleted, not the number of records inspected. +/// Ineligible records at the front of the trail are silently walked past without counting +/// toward `limit`, so more than `limit` records may be visited before `limit` deletions +/// accumulate. +/// +/// Requires the {@link Permission.DeleteAllRecords} permission. +/// +/// Emits one {@link RecordDeleted} event per deletion. +#[wasm_bindgen(js_name = DeleteRecordsBatch, inspectable)] +pub struct WasmDeleteRecordsBatch(pub(crate) DeleteRecordsBatch); + +#[wasm_bindgen(js_class = DeleteRecordsBatch)] +impl WasmDeleteRecordsBatch { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events and decodes the matching payload. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @returns Sequence numbers of the records deleted in this batch, in deletion order — at + /// most the requested limit, possibly fewer. + /// + /// @throws When transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result> { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +/// Transaction wrapper for adding a record tag to the trail registry. +/// +/// @remarks +/// Aborts on-chain if the tag is already in the registry. +/// +/// Requires the {@link Permission.AddRecordTags} permission. +/// +/// Emits a {@link RecordTagAdded} event on success. +#[wasm_bindgen(js_name = AddRecordTag, inspectable)] +pub struct WasmAddRecordTag(pub(crate) AddRecordTag); + +#[wasm_bindgen(js_class = AddRecordTag)] +impl WasmAddRecordTag { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @throws When transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +/// Transaction wrapper for removing a record tag from the trail registry. +/// +/// @remarks +/// Aborts on-chain if the tag is not in the registry or while it is still referenced by any +/// existing record or role-tag restriction. +/// +/// Requires the {@link Permission.DeleteRecordTags} permission. +/// +/// Emits a {@link RecordTagRemoved} event on success. +#[wasm_bindgen(js_name = RemoveRecordTag, inspectable)] +pub struct WasmRemoveRecordTag(pub(crate) RemoveRecordTag); + +#[wasm_bindgen(js_class = RemoveRecordTag)] +impl WasmRemoveRecordTag { + /// Builds the programmable transaction bytes for submission. + /// + /// @param client - Read-only core client used to resolve packages and serialize the + /// transaction. + /// + /// @returns BCS-encoded programmable transaction bytes ready for signing and submission. + /// + /// @throws When transaction serialization fails. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + /// Applies transaction effects and events. + /// + /// @param wasmEffects - Effects of the executed transaction. + /// @param wasmEvents - Events emitted by the executed transaction. + /// @param client - Read-only core client used during application. + /// + /// @throws When transaction application fails. + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/access.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/access.rs new file mode 100644 index 00000000..54b3c0b4 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/access.rs @@ -0,0 +1,380 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use audit_trails::AuditTrailClient; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::{into_transaction_builder, parse_wasm_object_id}; +use product_common::bindings::WasmObjectID; +use wasm_bindgen::prelude::*; + +use crate::trail::{ + WasmCleanupRevokedCapabilities, WasmCreateRole, WasmDeleteRole, WasmDestroyCapability, + WasmDestroyInitialAdminCapability, WasmIssueCapability, WasmRevokeCapability, WasmRevokeInitialAdminCapability, + WasmUpdateRole, +}; +use crate::types::{WasmCapabilityIssueOptions, WasmPermissionSet, WasmRoleTags}; + +/// Access-control API scoped to a specific trail. +/// +/// @remarks +/// Exposes role-management and capability-management operations for one trail. Per-role operations +/// live on {@link RoleHandle}, which is reached through {@link TrailAccess.forRole}. +#[derive(Clone)] +#[wasm_bindgen(js_name = TrailAccess, inspectable)] +pub struct WasmTrailAccess { + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmTrailAccess { + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "TrailAccess was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = TrailAccess)] +impl WasmTrailAccess { + /// Returns a role-scoped handle for the given role name. + /// + /// @remarks + /// The returned handle only identifies the role. If a role with `name` does not yet exist, the + /// handle can still be used to create it via {@link RoleHandle.create}. + /// + /// @param name - Role name to bind the handle to. + /// + /// @returns A {@link RoleHandle} bound to `name` inside this trail. + #[wasm_bindgen(js_name = forRole)] + pub fn for_role(&self, name: String) -> WasmRoleHandle { + WasmRoleHandle { + full: self.full.clone(), + trail_id: self.trail_id, + name, + } + } + + /// Builds a capability-revocation transaction. + /// + /// @remarks + /// Adds `capabilityId` to the trail's revoked-capability denylist. Initial-admin capabilities + /// cannot be revoked through this path — use + /// {@link TrailAccess.revokeInitialAdminCapability} instead. + /// + /// Requires the {@link Permission.RevokeCapabilities} permission. + /// + /// @param capabilityId - Object ID of the capability to revoke. + /// @param capabilityValidUntil - Original capability expiry in milliseconds since the Unix + /// epoch. Pass it so {@link CleanupRevokedCapabilities} can later prune the denylist entry once + /// the timestamp has elapsed; pass `null` to keep the entry permanently. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link RevokeCapability} transaction. + /// + /// @throws When `capabilityId` is malformed or the wrapper was created from a read-only + /// client. + /// + /// Emits a {@link CapabilityRevoked} event on success. + #[wasm_bindgen(js_name = revokeCapability, unchecked_return_type = "TransactionBuilder")] + pub fn revoke_capability( + &self, + capability_id: WasmObjectID, + capability_valid_until: Option, + ) -> Result { + let capability_id = parse_wasm_object_id(&capability_id)?; + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .revoke_capability(capability_id, capability_valid_until) + .into_inner(); + Ok(into_transaction_builder(WasmRevokeCapability(tx))) + } + + /// Builds a capability-destruction transaction. + /// + /// @remarks + /// Consumes the owned capability object and removes any matching denylist entry. This path is + /// for ordinary capabilities only — initial-admin capabilities must use + /// {@link TrailAccess.destroyInitialAdminCapability}. + /// + /// Requires the {@link Permission.RevokeCapabilities} permission. + /// + /// @param capabilityId - Object ID of the capability to destroy. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link DestroyCapability} transaction. + /// + /// @throws When `capabilityId` is malformed or the wrapper was created from a read-only + /// client. + /// + /// Emits a {@link CapabilityDestroyed} event on success. + #[wasm_bindgen(js_name = destroyCapability, unchecked_return_type = "TransactionBuilder")] + pub fn destroy_capability(&self, capability_id: WasmObjectID) -> Result { + let capability_id = parse_wasm_object_id(&capability_id)?; + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .destroy_capability(capability_id) + .into_inner(); + Ok(into_transaction_builder(WasmDestroyCapability(tx))) + } + + /// Builds an initial-admin-capability destruction transaction. + /// + /// @remarks + /// Self-service: the holder consumes their own initial-admin capability without presenting + /// another authorization capability. Initial-admin capability IDs are tracked separately and + /// cannot be removed through the generic destroy path. **Warning:** if every initial-admin + /// capability is destroyed (and none was issued separately), the trail is permanently sealed + /// with no admin access possible. + /// + /// @param capabilityId - Object ID of the initial-admin capability to destroy. + /// + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link DestroyInitialAdminCapability} transaction. + /// + /// @throws When `capabilityId` is malformed or the wrapper was created from a read-only + /// client. + /// + /// Emits a {@link CapabilityDestroyed} event on success. + #[wasm_bindgen(js_name = destroyInitialAdminCapability, unchecked_return_type = "TransactionBuilder")] + pub fn destroy_initial_admin_capability(&self, capability_id: WasmObjectID) -> Result { + let capability_id = parse_wasm_object_id(&capability_id)?; + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .destroy_initial_admin_capability(capability_id) + .into_inner(); + Ok(into_transaction_builder(WasmDestroyInitialAdminCapability(tx))) + } + + /// Builds an initial-admin-capability revocation transaction. + /// + /// @remarks + /// Same denylist semantics as {@link TrailAccess.revokeCapability} but uses the dedicated entry + /// point reserved for initial-admin capability IDs. **Warning:** revoking every initial-admin + /// capability permanently seals the trail with no admin access possible. + /// + /// Requires the {@link Permission.RevokeCapabilities} permission. + /// + /// @param capabilityId - Object ID of the initial-admin capability to revoke. + /// @param capabilityValidUntil - Original capability expiry in milliseconds since the Unix + /// epoch. Pass it so {@link CleanupRevokedCapabilities} can later prune the denylist entry once + /// the timestamp has elapsed; pass `null` to keep the entry permanently. + /// + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link RevokeInitialAdminCapability} transaction. + /// + /// @throws When `capabilityId` is malformed or the wrapper was created from a read-only + /// client. + /// + /// Emits a {@link CapabilityRevoked} event on success. + #[wasm_bindgen(js_name = revokeInitialAdminCapability, unchecked_return_type = "TransactionBuilder")] + pub fn revoke_initial_admin_capability( + &self, + capability_id: WasmObjectID, + capability_valid_until: Option, + ) -> Result { + let capability_id = parse_wasm_object_id(&capability_id)?; + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .revoke_initial_admin_capability(capability_id, capability_valid_until) + .into_inner(); + Ok(into_transaction_builder(WasmRevokeInitialAdminCapability(tx))) + } + + /// Builds a cleanup transaction for expired revoked-capability entries. + /// + /// @remarks + /// Only prunes denylist entries whose stored `validUntil` is non-zero and strictly less than + /// the current clock time. Entries with `validUntil == 0` (revocations without a known expiry) + /// remain on the denylist indefinitely. Does not revoke additional capabilities and does not + /// destroy any objects. + /// + /// Requires the {@link Permission.RevokeCapabilities} permission. + /// + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link CleanupRevokedCapabilities} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link RevokedCapabilitiesCleanedUp} event on success. + #[wasm_bindgen(js_name = cleanupRevokedCapabilities, unchecked_return_type = "TransactionBuilder")] + pub fn cleanup_revoked_capabilities(&self) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .cleanup_revoked_capabilities() + .into_inner(); + Ok(into_transaction_builder(WasmCleanupRevokedCapabilities(tx))) + } +} + +/// Role-scoped access-control API. +/// +/// @remarks +/// Identifies one role name inside the trail's access-control state and builds transactions that +/// act on that role. +#[derive(Clone)] +#[wasm_bindgen(js_name = RoleHandle, inspectable)] +pub struct WasmRoleHandle { + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, + pub(crate) name: String, +} + +impl WasmRoleHandle { + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "RoleHandle was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = RoleHandle)] +impl WasmRoleHandle { + /// Returns the role name represented by this handle. + /// + /// @returns The role name bound to this handle. + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.name.clone() + } + + /// Builds a role-creation transaction. + /// + /// @remarks + /// Creates this role with `permissions` and the optional `roleTags` allowlist. Each tag + /// referenced by `roleTags` must already exist in the trail-owned tag registry; the on-chain + /// call aborts otherwise and bumps that tag's usage counter on success. + /// + /// Requires the {@link Permission.AddRoles} permission. + /// + /// @param permissions - {@link PermissionSet} granted by the new role. + /// @param roleTags - Optional {@link RoleTags} allowlist that restricts the role's reach to + /// records carrying one of these tags. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link CreateRole} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link RoleCreated} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn create( + &self, + permissions: WasmPermissionSet, + role_tags: Option, + ) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .for_role(self.name.clone()) + .create(permissions.into(), role_tags.map(Into::into)) + .into_inner(); + Ok(into_transaction_builder(WasmCreateRole(tx))) + } + + /// Builds a capability-issuance transaction for this role. + /// + /// @remarks + /// The resulting capability always targets this trail and grants exactly this role. Only + /// `options.issuedTo`, `options.validFromMs`, and `options.validUntilMs` configure restrictions + /// on the issued object; enforcement happens on-chain when the capability is later presented + /// for authorization. The capability is transferred to `options.issuedTo` if set, otherwise to + /// the caller. + /// + /// Requires the {@link Permission.AddCapabilities} permission. + /// + /// @param options - {@link CapabilityIssueOptions} configuring recipient and validity window. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link IssueCapability} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link CapabilityIssued} event on success. + #[wasm_bindgen(js_name = issueCapability, unchecked_return_type = "TransactionBuilder")] + pub fn issue_capability(&self, options: WasmCapabilityIssueOptions) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .for_role(self.name.clone()) + .issue_capability(options.into()) + .into_inner(); + Ok(into_transaction_builder(WasmIssueCapability(tx))) + } + + /// Builds a role-update transaction for this role. + /// + /// @remarks + /// Replaces both the role's permission set and its `roleTags` allowlist. Any newly supplied tag + /// must already exist in the trail's record-tag registry; tag usage counters are adjusted to + /// reflect the difference between the old and the new role-tag sets. Updating the + /// initial-admin role with permissions that do not include every permission configured in the + /// trail's role- and capability-admin permission sets aborts on-chain. + /// + /// Requires the {@link Permission.UpdateRoles} permission. + /// + /// @param permissions - Replacement {@link PermissionSet} for the role. + /// @param roleTags - Replacement {@link RoleTags} allowlist, or `null` to clear the + /// restriction. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link UpdateRole} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link RoleUpdated} event on success. + #[wasm_bindgen(js_name = updatePermissions, unchecked_return_type = "TransactionBuilder")] + pub fn update_permissions( + &self, + permissions: WasmPermissionSet, + role_tags: Option, + ) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .for_role(self.name.clone()) + .update_permissions(permissions.into(), role_tags.map(Into::into)) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateRole(tx))) + } + + /// Builds a role-deletion transaction for this role. + /// + /// @remarks + /// Decrements the usage count of every tag the role's `roleTags` referenced. The reserved + /// initial-admin role cannot be deleted. + /// + /// Requires the {@link Permission.DeleteRoles} permission. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link DeleteRole} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link RoleDeleted} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn delete(&self) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .for_role(self.name.clone()) + .delete() + .into_inner(); + Ok(into_transaction_builder(WasmDeleteRole(tx))) + } +} diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs new file mode 100644 index 00000000..0e1df8c1 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs @@ -0,0 +1,181 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::into_transaction_builder; +use wasm_bindgen::prelude::*; + +use crate::trail::{ + WasmUpdateDeleteRecordWindow, WasmUpdateDeleteTrailLock, WasmUpdateLockingConfig, WasmUpdateWriteLock, +}; +use crate::types::{WasmLockingConfig, WasmLockingWindow, WasmTimeLock}; + +/// Locking API scoped to a specific trail. +/// +/// @remarks +/// Updates the trail's {@link LockingConfig} and queries whether an individual record is currently +/// locked against deletion. +#[derive(Clone)] +#[wasm_bindgen(js_name = TrailLocking, inspectable)] +pub struct WasmTrailLocking { + pub(crate) read_only: AuditTrailClientReadOnly, + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmTrailLocking { + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "TrailLocking was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = TrailLocking)] +impl WasmTrailLocking { + /// Builds a transaction that replaces the full locking configuration. + /// + /// @remarks + /// Overwrites all three locking dimensions at once: delete-record window, delete-trail lock, + /// and write lock. `config.deleteTrailLock` must not be {@link TimeLock.withUntilDestroyed}, + /// and a count-based `config.deleteRecordWindow` must use `count > 0` — + /// use {@link LockingWindow.withNone} to express "no deletion lock". `config.writeLock` may + /// still be {@link TimeLock.withUntilDestroyed}. + /// + /// Requires the {@link Permission.UpdateLockingConfig} permission. + /// + /// @param config - Replacement {@link LockingConfig}. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link UpdateLockingConfig} transaction. + /// + /// @throws When the wrapper was created from a read-only client, or when `config` violates + /// one of the constraints above. + /// + /// Emits a {@link LockingConfigUpdated} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn update(&self, config: WasmLockingConfig) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .locking() + .update(config.into()) + .wasm_result()? + .into_inner(); + Ok(into_transaction_builder(WasmUpdateLockingConfig(tx))) + } + + /// Builds a transaction that updates only the delete-record window. + /// + /// @remarks + /// Replaces the trail's `deleteRecordWindow`. Records currently inside the new window + /// immediately become locked against deletion. A count-based window must use `count > 0` — + /// use {@link LockingWindow.withNone} to express "no deletion lock". + /// + /// Requires the {@link Permission.UpdateLockingConfigForDeleteRecord} permission. + /// + /// @param window - Replacement {@link LockingWindow}. + /// + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link UpdateDeleteRecordWindow} transaction. + /// + /// @throws When the wrapper was created from a read-only client, or when `window` is a + /// count-based window with `count == 0`. + /// + /// Emits a {@link LockingConfigUpdated} event on success. + #[wasm_bindgen(js_name = updateDeleteRecordWindow, unchecked_return_type = "TransactionBuilder")] + pub fn update_delete_record_window(&self, window: WasmLockingWindow) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .locking() + .update_delete_record_window(window.into()) + .wasm_result()? + .into_inner(); + Ok(into_transaction_builder(WasmUpdateDeleteRecordWindow(tx))) + } + + /// Builds a transaction that updates only the delete-trail lock. + /// + /// @remarks + /// Replaces the trail's `deleteTrailLock`. The new lock must not be + /// {@link TimeLock.withUntilDestroyed}; that variant is reserved for the write lock. + /// + /// Requires the {@link Permission.UpdateLockingConfigForDeleteTrail} permission. + /// + /// @param lock - Replacement delete-trail {@link TimeLock}. + /// + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link UpdateDeleteTrailLock} transaction. + /// + /// @throws When the wrapper was created from a read-only client, or when `lock` is + /// {@link TimeLock.withUntilDestroyed}. + /// + /// Emits a {@link LockingConfigUpdated} event on success. + #[wasm_bindgen(js_name = updateDeleteTrailLock, unchecked_return_type = "TransactionBuilder")] + pub fn update_delete_trail_lock(&self, lock: WasmTimeLock) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .locking() + .update_delete_trail_lock(lock.into()) + .wasm_result()? + .into_inner(); + Ok(into_transaction_builder(WasmUpdateDeleteTrailLock(tx))) + } + + /// Builds a transaction that updates only the write lock. + /// + /// @remarks + /// Replaces the trail's `writeLock`. While the new lock is active, {@link TrailRecords.add} + /// aborts on-chain. {@link TimeLock.withUntilDestroyed} is permitted here. + /// + /// Requires the {@link Permission.UpdateLockingConfigForWrite} permission. + /// + /// @param lock - Replacement write {@link TimeLock}. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link UpdateWriteLock} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link LockingConfigUpdated} event on success. + #[wasm_bindgen(js_name = updateWriteLock, unchecked_return_type = "TransactionBuilder")] + pub fn update_write_lock(&self, lock: WasmTimeLock) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .locking() + .update_write_lock(lock.into()) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateWriteLock(tx))) + } + + /// Returns whether a record is currently locked against deletion. + /// + /// @remarks + /// Evaluates the trail's `deleteRecordWindow` against the record at `sequenceNumber`. For + /// count-based windows, the result reflects the last `count` records currently present in + /// trail order at call time; time-based windows are evaluated against the current clock time. + /// + /// @param sequenceNumber - Sequence number of the record to inspect. + /// + /// @returns `true` when the record is still inside the delete-record window, `false` + /// otherwise. + /// + /// @throws When no record exists at `sequenceNumber`. + #[wasm_bindgen(js_name = isRecordLocked)] + pub async fn is_record_locked(&self, sequence_number: u64) -> Result { + self.read_only + .trail(self.trail_id) + .locking() + .is_record_locked(sequence_number) + .await + .wasm_result() + } +} diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs new file mode 100644 index 00000000..12678424 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs @@ -0,0 +1,205 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Trail-scoped handle wrappers. + +mod access; +mod locking; +mod records; +mod tags; + +pub(crate) use access::WasmTrailAccess; +use anyhow::anyhow; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; +pub(crate) use locking::WasmTrailLocking; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::into_transaction_builder; +pub(crate) use records::WasmTrailRecords; +pub(crate) use tags::WasmTrailTags; +use wasm_bindgen::prelude::*; + +use crate::trail::{WasmDeleteAuditTrail, WasmMigrate, WasmOnChainAuditTrail, WasmUpdateMetadata}; + +/// Handle bound to a specific audit-trail object. +/// +/// @remarks +/// `AuditTrailHandle` keeps one trail ID together with the originating client so all trail-scoped +/// reads and transaction builders can be discovered from a single value. Use the subsystem +/// accessors {@link AuditTrailHandle.records}, {@link AuditTrailHandle.access}, +/// {@link AuditTrailHandle.locking}, and {@link AuditTrailHandle.tags} to reach the corresponding +/// APIs. +#[derive(Clone)] +#[wasm_bindgen(js_name = AuditTrailHandle, inspectable)] +pub struct WasmAuditTrailHandle { + pub(crate) read_only: AuditTrailClientReadOnly, + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmAuditTrailHandle { + pub(crate) fn from_read_only(read_only: AuditTrailClientReadOnly, trail_id: ObjectID) -> Self { + Self { + read_only, + full: None, + trail_id, + } + } + + pub(crate) fn from_full(full: AuditTrailClient, trail_id: ObjectID) -> Self { + Self { + read_only: full.read_only().clone(), + full: Some(full), + trail_id, + } + } + + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "AuditTrailHandle was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = AuditTrailHandle)] +impl WasmAuditTrailHandle { + /// Loads the full on-chain trail object. + /// + /// @remarks + /// Each call fetches a fresh snapshot from chain state. + /// + /// @returns The current {@link OnChainAuditTrail} state of this trail. + /// + /// @throws When the trail object cannot be fetched or decoded. + pub async fn get(&self) -> Result { + let trail = self.read_only.trail(self.trail_id).get().await.wasm_result()?; + Ok(trail.into()) + } + + /// Builds a migration transaction for this trail. + /// + /// @remarks + /// Bumps the trail's stored data layout to the current package version. Intended to be called + /// once after the audit-trail Move package is upgraded. + /// + /// Requires the {@link Permission.Migrate} permission. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link Migrate} transaction. + /// + /// @throws When the handle was created from a read-only client. + /// + /// Emits an {@link AuditTrailMigrated} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn migrate(&self) -> Result { + let tx = self.require_write()?.trail(self.trail_id).migrate().into_inner(); + Ok(into_transaction_builder(WasmMigrate(tx))) + } + + /// Builds a delete transaction for this trail. + /// + /// @remarks + /// Deletion additionally requires the trail to be empty (the on-chain call aborts otherwise) + /// and the configured `deleteTrailLock` to have elapsed. + /// + /// Requires the {@link Permission.DeleteAuditTrail} permission. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link DeleteAuditTrail} transaction. + /// + /// @throws When the handle was created from a read-only client. + /// + /// Emits an {@link AuditTrailDeleted} event on success. + #[wasm_bindgen(js_name = deleteAuditTrail, unchecked_return_type = "TransactionBuilder")] + pub fn delete_audit_trail(&self) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .delete_audit_trail() + .into_inner(); + Ok(into_transaction_builder(WasmDeleteAuditTrail(tx))) + } + + /// Builds a mutable-metadata update transaction for this trail. + /// + /// @remarks + /// Replaces or clears the trail's `updatableMetadata` field. + /// + /// Requires the {@link Permission.UpdateMetadata} permission. + /// + /// @param metadata - New value for the trail's `updatableMetadata` field, or `null` to clear + /// it. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link UpdateMetadata} transaction. + /// + /// @throws When the handle was created from a read-only client. + /// + /// Emits a {@link MetadataUpdated} event on success. + #[wasm_bindgen(js_name = updateMetadata, unchecked_return_type = "TransactionBuilder")] + pub fn update_metadata(&self, metadata: Option) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .update_metadata(metadata) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateMetadata(tx))) + } + + /// Returns the record API scoped to this trail. + /// + /// @remarks + /// Use this for record reads, appends, and deletions. + /// + /// @returns A {@link TrailRecords} wrapper bound to this trail. + pub fn records(&self) -> WasmTrailRecords { + WasmTrailRecords { + read_only: self.read_only.clone(), + full: self.full.clone(), + trail_id: self.trail_id, + } + } + + /// Returns the access-control API scoped to this trail. + /// + /// @remarks + /// Use this for roles, capabilities, and access-policy updates. + /// + /// @returns A {@link TrailAccess} wrapper bound to this trail. + pub fn access(&self) -> WasmTrailAccess { + WasmTrailAccess { + full: self.full.clone(), + trail_id: self.trail_id, + } + } + + /// Returns the locking API scoped to this trail. + /// + /// @remarks + /// Use this for inspecting lock state and updating locking rules. + /// + /// @returns A {@link TrailLocking} wrapper bound to this trail. + pub fn locking(&self) -> WasmTrailLocking { + WasmTrailLocking { + read_only: self.read_only.clone(), + full: self.full.clone(), + trail_id: self.trail_id, + } + } + + /// Returns the tag-registry API scoped to this trail. + /// + /// @remarks + /// Use this for managing the canonical tag registry that record writes and role-tag + /// restrictions must reference. + /// + /// @returns A {@link TrailTags} wrapper bound to this trail. + pub fn tags(&self) -> WasmTrailTags { + WasmTrailTags { + read_only: self.read_only.clone(), + full: self.full.clone(), + trail_id: self.trail_id, + } + } +} diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs new file mode 100644 index 00000000..8bb660fa --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs @@ -0,0 +1,263 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use audit_trails::core::types::Data as AuditTrailData; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::into_transaction_builder; +use wasm_bindgen::prelude::*; + +use crate::trail::{WasmAddRecord, WasmDeleteRecord, WasmDeleteRecordsBatch}; +use crate::types::{WasmData, WasmEmpty, WasmPaginatedRecord, WasmRecord}; + +/// Record API scoped to a specific trail. +/// +/// @remarks +/// Builds record-oriented transactions and loads record data from the trail's linked-table +/// storage. +#[derive(Clone)] +#[wasm_bindgen(js_name = TrailRecords, inspectable)] +pub struct WasmTrailRecords { + pub(crate) read_only: AuditTrailClientReadOnly, + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmTrailRecords { + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "TrailRecords was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = TrailRecords)] +impl WasmTrailRecords { + /// Loads one record by sequence number. + /// + /// @param sequenceNumber - Sequence number of the record to load. + /// + /// @returns The record stored at `sequenceNumber`. + /// + /// @throws When no record exists at the requested sequence number or the data cannot be + /// deserialized. + pub async fn get(&self, sequence_number: u64) -> Result { + let record = self + .read_only + .trail(self.trail_id) + .records() + .get(sequence_number) + .await + .wasm_result()?; + Ok(record.into()) + } + + /// Returns the number of records currently stored in the trail. + /// + /// @returns Current record count. + /// + /// @throws When the trail object cannot be fetched. + #[wasm_bindgen(js_name = recordCount)] + pub async fn record_count(&self) -> Result { + self.read_only + .trail(self.trail_id) + .records() + .record_count() + .await + .wasm_result() + } + + /// Lists all records in sequence-number order. + /// + /// @remarks + /// Traverses the full on-chain linked table and can be expensive for large trails. For + /// paginated access, use {@link TrailRecords.listPage}. + /// + /// @returns Every record in the trail, sorted by ascending sequence number. + /// + /// @throws When the trail object cannot be fetched or a record cannot be deserialized. + pub async fn list(&self) -> Result> { + let mut records: Vec<_> = self + .read_only + .trail(self.trail_id) + .records() + .list() + .await + .wasm_result()? + .into_iter() + .collect(); + records.sort_unstable_by_key(|(sequence_number, _)| *sequence_number); + Ok(records.into_iter().map(|(_, record)| record.into()).collect()) + } + + /// Lists all records while enforcing a maximum number of entries. + /// + /// @remarks + /// Use this as a safety net against unexpectedly large traversals. + /// + /// @param maxEntries - Upper bound on the number of records the caller is willing to load. + /// + /// @returns Every record in the trail, sorted by ascending sequence number. + /// + /// @throws When the trail's linked-table size exceeds `maxEntries`. + #[wasm_bindgen(js_name = listWithLimit)] + pub async fn list_with_limit(&self, max_entries: usize) -> Result> { + let mut records: Vec<_> = self + .read_only + .trail(self.trail_id) + .records() + .list_with_limit(max_entries) + .await + .wasm_result()? + .into_iter() + .collect(); + records.sort_unstable_by_key(|(sequence_number, _)| *sequence_number); + Ok(records.into_iter().map(|(_, record)| record.into()).collect()) + } + + /// Loads one page of records starting at `cursor`. + /// + /// @param cursor - Sequence-number cursor for the page boundary; pass `null` for the first + /// page and reuse {@link PaginatedRecord.nextCursor} for subsequent pages. + /// @param limit - Maximum number of records to return; may not exceed the maximum page size defined in the + /// Audit Trails Rust crate. + /// + /// @returns A {@link PaginatedRecord} carrying the loaded records and pagination metadata. + /// + /// @throws When the trail object cannot be fetched, a record cannot be deserialized, or + /// `limit` exceeds the maximum page size defined in the Audit Trails Rust crate. + #[wasm_bindgen(js_name = listPage)] + pub async fn list_page(&self, cursor: Option, limit: usize) -> Result { + let page = self + .read_only + .trail(self.trail_id) + .records() + .list_page(cursor, limit) + .await + .wasm_result()?; + Ok(page.into()) + } + + /// Executes the correction helper for a record payload. + /// + /// @remarks + /// Placeholder for a future correction helper — currently always throws because the underlying + /// implementation is not yet wired up. + /// + /// @param replaces - Sequence numbers of the records that the correction supersedes. + /// @param data - Replacement record payload. + /// @param metadata - Optional application-defined metadata stored alongside the correction. + /// + /// @throws Always; the helper is not yet implemented. + pub async fn correct(&self, replaces: Vec, data: WasmData, metadata: Option) -> Result { + self.require_write()? + .trail(self.trail_id) + .records() + .correct(replaces, data.into(), metadata) + .await + .wasm_result()?; + Ok(WasmEmpty) + } + + /// Builds a record-add transaction. + /// + /// @remarks + /// Records are appended sequentially with auto-assigned, monotonically increasing sequence + /// numbers that are never reused. While the trail's `writeLock` is active the on-chain call + /// aborts. When `tag` is set, it must already exist in the trail's record-tag registry and the + /// supplied capability's role must allow that tag. + /// + /// Requires the {@link Permission.AddRecord} permission. + /// + /// @param data - {@link Data} payload of the new record. + /// @param metadata - Optional application-defined metadata stored alongside the record. + /// @param tag - Optional trail-owned tag attached to the record. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link AddRecord} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link RecordAdded} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn add(&self, data: WasmData, metadata: Option, tag: Option) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .records() + .add(AuditTrailData::from(data), metadata, tag) + .into_inner(); + Ok(into_transaction_builder(WasmAddRecord(tx))) + } + + /// Builds a single-record delete transaction. + /// + /// @remarks + /// The on-chain call aborts when no record exists at `sequenceNumber` or while the configured + /// delete-record window still protects it. When the record carries a tag, the supplied + /// capability's role must allow that tag. + /// + /// Requires the {@link Permission.DeleteRecord} permission. + /// + /// @param sequenceNumber - Sequence number of the record to delete. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link DeleteRecord} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link RecordDeleted} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn delete(&self, sequence_number: u64) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .records() + .delete(sequence_number) + .into_inner(); + Ok(into_transaction_builder(WasmDeleteRecord(tx))) + } + + /// Builds a batched record-delete transaction. + /// + /// @remarks + /// Walks the trail from the front and silently skips records still inside the delete-record + /// window or whose tag the capability does not allow, deleting up to `limit` eligible records + /// in trail order. The set of locked records is fixed at the start of the on-chain call: + /// count-based windows protect the last `count` records present when the call begins, and + /// time-based windows are evaluated against the clock timestamp captured at that point. + /// Running this batch with `limit` therefore yields the same final trail state as deleting + /// each eligible sequence number one at a time, provided the locking configuration is not + /// mutated and no records are appended between calls. + /// + /// `limit` caps the number of records actually deleted, not the number of records inspected. + /// Ineligible records at the front of the trail are silently walked past without counting + /// toward `limit`, so more than `limit` records may be visited before `limit` deletions + /// accumulate. + /// + /// Requires the {@link Permission.DeleteAllRecords} permission. + /// + /// @param limit - Maximum number of records to delete in this batch. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link DeleteRecordsBatch} transaction; + /// when applied it resolves to the sequence numbers of the records deleted in this batch, in + /// deletion order — at most `limit` entries, possibly fewer. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits one {@link RecordDeleted} event per deletion. + #[wasm_bindgen(js_name = deleteBatch, unchecked_return_type = "TransactionBuilder")] + pub fn delete_batch(&self, limit: u64) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .records() + .delete_records_batch(limit) + .into_inner(); + Ok(into_transaction_builder(WasmDeleteRecordsBatch(tx))) + } +} diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs new file mode 100644 index 00000000..161f326c --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs @@ -0,0 +1,104 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::into_transaction_builder; +use wasm_bindgen::prelude::*; + +use crate::trail::{WasmAddRecordTag, WasmRemoveRecordTag}; +use crate::types::WasmRecordTagEntry; + +/// Tag-registry API scoped to a specific trail. +/// +/// @remarks +/// The registry defines the canonical set of tags that record writes and {@link RoleTags} +/// restrictions may reference. +#[derive(Clone)] +#[wasm_bindgen(js_name = TrailTags, inspectable)] +pub struct WasmTrailTags { + pub(crate) read_only: AuditTrailClientReadOnly, + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmTrailTags { + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "TrailTags was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = TrailTags)] +impl WasmTrailTags { + /// Lists every tag in the trail's registry alongside its current usage count. + /// + /// @returns Tag entries sorted alphabetically by tag name. + /// + /// @throws When the trail object cannot be fetched. + pub async fn list(&self) -> Result> { + let trail = self.read_only.trail(self.trail_id).get().await.wasm_result()?; + let mut tags: Vec = trail + .tags + .iter() + .map(|(tag, usage_count)| (tag.clone(), *usage_count).into()) + .collect(); + tags.sort_unstable_by(|left, right| left.tag.cmp(&right.tag)); + Ok(tags) + } + + /// Builds a transaction that adds a tag to the trail registry. + /// + /// @remarks + /// Inserted with a usage count of zero. The on-chain call aborts when the tag is already in + /// the registry. Added tags become available to future tagged record writes and role-tag + /// restrictions. + /// + /// Requires the {@link Permission.AddRecordTags} permission. + /// + /// @param tag - Tag name to add to the registry. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link AddRecordTag} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link RecordTagAdded} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn add(&self, tag: String) -> Result { + let tx = self.require_write()?.trail(self.trail_id).tags().add(tag).into_inner(); + Ok(into_transaction_builder(WasmAddRecordTag(tx))) + } + + /// Builds a transaction that removes a tag from the trail registry. + /// + /// @remarks + /// The tag must currently be in the registry and must not be referenced by any record or + /// role-tag restriction; the on-chain call aborts otherwise. + /// + /// Requires the {@link Permission.DeleteRecordTags} permission. + /// + /// @param tag - Tag name to remove from the registry. + /// + /// @returns A {@link TransactionBuilder} wrapping the {@link RemoveRecordTag} transaction. + /// + /// @throws When the wrapper was created from a read-only client. + /// + /// Emits a {@link RecordTagRemoved} event on success. + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn remove(&self, tag: String) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .tags() + .remove(tag) + .into_inner(); + Ok(into_transaction_builder(WasmRemoveRecordTag(tx))) + } +} diff --git a/bindings/wasm/audit_trail_wasm/src/types.rs b/bindings/wasm/audit_trail_wasm/src/types.rs new file mode 100644 index 00000000..3f49eabb --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/src/types.rs @@ -0,0 +1,1587 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{HashMap, HashSet}; + +use audit_trails::core::types::{ + AuditTrailCreated, AuditTrailDeleted, AuditTrailMigrated, Capability, CapabilityAdminPermissions, + CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Data, ImmutableMetadata, + LockingConfig, LockingConfigUpdated, LockingWindow, MetadataUpdated, PaginatedRecord, Permission, PermissionSet, + Record, RecordAdded, RecordCorrection, RecordDeleted, RecordTagAdded, RecordTagRemoved, + RevokedCapabilitiesCleanedUp, Role, RoleAdminPermissions, RoleCreated, RoleDeleted, RoleMap, RoleTags, RoleUpdated, + TimeLock, +}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::collection_types::LinkedTable; +use js_sys::Uint8Array; +use product_common::bindings::WasmIotaAddress; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; + +/// Placeholder type used as the resolved value of transactions that carry no payload. +#[wasm_bindgen(js_name = Empty, inspectable)] +pub struct WasmEmpty; + +impl From<()> for WasmEmpty { + fn from(_: ()) -> Self { + Self + } +} + +/// Audit-trail record payload. +/// +/// @remarks +/// Holds either a UTF-8 string or a raw byte sequence. Use {@link Data.fromString} or +/// {@link Data.fromBytes} to construct an instance, and {@link Data.toString} or +/// {@link Data.toBytes} to extract the payload as the desired representation. +#[wasm_bindgen(js_name = Data, inspectable)] +#[derive(Clone)] +pub struct WasmData(pub(crate) Data); + +#[wasm_bindgen(js_class = Data)] +impl WasmData { + /// Returns the underlying payload in its original representation. + /// + /// @returns A `string` for text payloads or a `Uint8Array` for byte payloads. + #[wasm_bindgen(getter)] + pub fn value(&self) -> JsValue { + match &self.0 { + Data::Bytes(bytes) => Uint8Array::from(bytes.as_slice()).into(), + Data::Text(text) => JsValue::from(text), + } + } + + /// Returns the payload as a string. + /// + /// @remarks + /// Byte payloads are decoded with lossy UTF-8 conversion (invalid sequences become the U+FFFD + /// replacement character). + /// + /// @returns A string view of the payload. + #[wasm_bindgen(js_name = toString)] + pub fn to_string(&self) -> String { + match &self.0 { + Data::Bytes(bytes) => String::from_utf8_lossy(bytes).to_string(), + Data::Text(text) => text.clone(), + } + } + + /// Returns the payload as raw bytes. + /// + /// @remarks + /// Text payloads are encoded as UTF-8. + /// + /// @returns A byte view of the payload. + #[wasm_bindgen(js_name = toBytes)] + pub fn to_bytes(&self) -> Vec { + match &self.0 { + Data::Bytes(bytes) => bytes.clone(), + Data::Text(text) => text.as_bytes().to_vec(), + } + } + + /// Creates a text payload. + /// + /// @param data - UTF-8 string to wrap. + /// + /// @returns A {@link Data} carrying `data` as text. + #[wasm_bindgen(js_name = fromString)] + pub fn from_string(data: String) -> Self { + Self(Data::text(data)) + } + + /// Creates a binary payload. + /// + /// @param data - Raw bytes to wrap. + /// + /// @returns A {@link Data} carrying `data` as bytes. + #[wasm_bindgen(js_name = fromBytes)] + pub fn from_bytes(data: Uint8Array) -> Self { + Self(Data::bytes(data.to_vec())) + } +} + +impl From for WasmData { + fn from(value: Data) -> Self { + Self(value) + } +} + +impl From for Data { + fn from(value: WasmData) -> Self { + value.0 + } +} + +fn permission_sort_key(permission: Permission) -> u8 { + match permission { + Permission::DeleteAuditTrail => 0, + Permission::DeleteAllRecords => 1, + Permission::AddRecord => 2, + Permission::DeleteRecord => 3, + Permission::CorrectRecord => 4, + Permission::UpdateLockingConfig => 5, + Permission::UpdateLockingConfigForDeleteRecord => 6, + Permission::UpdateLockingConfigForDeleteTrail => 7, + Permission::UpdateLockingConfigForWrite => 8, + Permission::AddRoles => 9, + Permission::UpdateRoles => 10, + Permission::DeleteRoles => 11, + Permission::AddCapabilities => 12, + Permission::RevokeCapabilities => 13, + Permission::UpdateMetadata => 14, + Permission::DeleteMetadata => 15, + Permission::Migrate => 16, + Permission::AddRecordTags => 17, + Permission::DeleteRecordTags => 18, + } +} + +fn sorted_permissions_from_set(permissions: HashSet) -> Vec { + let mut permissions: Vec<_> = permissions.into_iter().collect(); + permissions.sort_unstable_by_key(|permission| permission_sort_key(*permission)); + permissions.into_iter().map(Into::into).collect() +} + +fn sorted_tag_names(tags: HashSet) -> Vec { + let mut tags: Vec<_> = tags.into_iter().collect(); + tags.sort_unstable(); + tags +} + +fn sorted_object_ids(ids: HashSet) -> Vec { + let mut ids: Vec<_> = ids.into_iter().map(|id| id.to_string()).collect(); + ids.sort_unstable(); + ids +} + +fn optional_object_id(id: Option) -> Option { + id.map(|id| id.to_string()) +} + +fn sorted_role_entries(roles: HashMap) -> Vec { + let mut roles: Vec<_> = roles + .into_iter() + .map(|(name, role)| WasmRolePermissionsEntry { + name, + permissions: sorted_permissions_from_set(role.permissions), + role_tags: role.data.map(Into::into), + }) + .collect(); + roles.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + roles +} + +/// Permission variants enumerated by Audit Trails. +/// +/// @remarks +/// Each variant authorizes one operation on a trail. Variants are grouped by the proposed role +/// that typically owns them (`Admin`, `RecordAdmin`, `LockingAdmin`, `RoleAdmin`, `CapAdmin`, +/// `MetadataAdmin`, `TagAdmin`); see {@link PermissionSet} for the recommended sets. +#[wasm_bindgen(js_name = Permission)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum WasmPermission { + /// Authorizes deleting the trail itself. + DeleteAuditTrail, + /// Authorizes the batched record-deletion entry point. + DeleteAllRecords, + /// Authorizes appending a record. + AddRecord, + /// Authorizes deleting an individual record. + DeleteRecord, + /// Authorizes adding a record that supersedes earlier records via `RecordCorrection`. + CorrectRecord, + /// Authorizes replacing the full {@link LockingConfig}. + UpdateLockingConfig, + /// Authorizes updating only the delete-record window of the locking configuration. + UpdateLockingConfigForDeleteRecord, + /// Authorizes updating only the delete-trail lock of the locking configuration. + UpdateLockingConfigForDeleteTrail, + /// Authorizes updating only the write lock of the locking configuration. + UpdateLockingConfigForWrite, + /// Authorizes creating roles. + AddRoles, + /// Authorizes updating existing roles. + UpdateRoles, + /// Authorizes deleting roles. + DeleteRoles, + /// Authorizes issuing capabilities. + AddCapabilities, + /// Authorizes revoking, destroying, and cleaning up capabilities. + RevokeCapabilities, + /// Authorizes replacing the trail's `updatableMetadata`. + UpdateMetadata, + /// Authorizes clearing the trail's `updatableMetadata`. + DeleteMetadata, + /// Authorizes the migration entry point used after package upgrades. + Migrate, + /// Authorizes adding entries to the trail's record-tag registry. + AddRecordTags, + /// Authorizes removing entries from the trail's record-tag registry. + DeleteRecordTags, +} + +impl From for WasmPermission { + fn from(value: Permission) -> Self { + match value { + Permission::DeleteAuditTrail => Self::DeleteAuditTrail, + Permission::DeleteAllRecords => Self::DeleteAllRecords, + Permission::AddRecord => Self::AddRecord, + Permission::DeleteRecord => Self::DeleteRecord, + Permission::CorrectRecord => Self::CorrectRecord, + Permission::UpdateLockingConfig => Self::UpdateLockingConfig, + Permission::UpdateLockingConfigForDeleteRecord => Self::UpdateLockingConfigForDeleteRecord, + Permission::UpdateLockingConfigForDeleteTrail => Self::UpdateLockingConfigForDeleteTrail, + Permission::UpdateLockingConfigForWrite => Self::UpdateLockingConfigForWrite, + Permission::AddRoles => Self::AddRoles, + Permission::UpdateRoles => Self::UpdateRoles, + Permission::DeleteRoles => Self::DeleteRoles, + Permission::AddCapabilities => Self::AddCapabilities, + Permission::RevokeCapabilities => Self::RevokeCapabilities, + Permission::UpdateMetadata => Self::UpdateMetadata, + Permission::DeleteMetadata => Self::DeleteMetadata, + Permission::Migrate => Self::Migrate, + Permission::AddRecordTags => Self::AddRecordTags, + Permission::DeleteRecordTags => Self::DeleteRecordTags, + } + } +} + +impl From for Permission { + fn from(value: WasmPermission) -> Self { + match value { + WasmPermission::DeleteAuditTrail => Self::DeleteAuditTrail, + WasmPermission::DeleteAllRecords => Self::DeleteAllRecords, + WasmPermission::AddRecord => Self::AddRecord, + WasmPermission::DeleteRecord => Self::DeleteRecord, + WasmPermission::CorrectRecord => Self::CorrectRecord, + WasmPermission::UpdateLockingConfig => Self::UpdateLockingConfig, + WasmPermission::UpdateLockingConfigForDeleteRecord => Self::UpdateLockingConfigForDeleteRecord, + WasmPermission::UpdateLockingConfigForDeleteTrail => Self::UpdateLockingConfigForDeleteTrail, + WasmPermission::UpdateLockingConfigForWrite => Self::UpdateLockingConfigForWrite, + WasmPermission::AddRoles => Self::AddRoles, + WasmPermission::UpdateRoles => Self::UpdateRoles, + WasmPermission::DeleteRoles => Self::DeleteRoles, + WasmPermission::AddCapabilities => Self::AddCapabilities, + WasmPermission::RevokeCapabilities => Self::RevokeCapabilities, + WasmPermission::UpdateMetadata => Self::UpdateMetadata, + WasmPermission::DeleteMetadata => Self::DeleteMetadata, + WasmPermission::Migrate => Self::Migrate, + WasmPermission::AddRecordTags => Self::AddRecordTags, + WasmPermission::DeleteRecordTags => Self::DeleteRecordTags, + } + } +} + +/// Set of permissions granted by a role. +#[wasm_bindgen(js_name = PermissionSet, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmPermissionSet { + /// Permissions granted by this set. + pub permissions: Vec, +} + +#[wasm_bindgen(js_class = PermissionSet)] +impl WasmPermissionSet { + /// Creates a permission set from an explicit list of permissions. + /// + /// @param permissions - Permissions to include in the set. + #[wasm_bindgen(constructor)] + pub fn new(permissions: Vec) -> Self { + Self { permissions } + } + + /// Returns the recommended permission set for the reserved initial-admin role. + /// + /// @remarks + /// Includes the following permissions: + /// + /// - {@link Permission.AddCapabilities} + /// - {@link Permission.RevokeCapabilities} + /// - {@link Permission.AddRecordTags} + /// - {@link Permission.DeleteRecordTags} + /// - {@link Permission.AddRoles} + /// - {@link Permission.UpdateRoles} + /// - {@link Permission.DeleteRoles} + /// - {@link Permission.Migrate} + /// + /// @returns A {@link PermissionSet} that authorizes role and capability administration. + #[wasm_bindgen(js_name = adminPermissions)] + pub fn admin_permissions() -> Self { + PermissionSet::admin_permissions().into() + } + + /// Returns the permissions needed to administer records. + /// + /// @remarks + /// Includes the following permissions: + /// + /// - {@link Permission.AddRecord} + /// - {@link Permission.DeleteRecord} + /// - {@link Permission.CorrectRecord} + /// + /// @returns A {@link PermissionSet} that authorizes record reads, writes, and deletions. + #[wasm_bindgen(js_name = recordAdminPermissions)] + pub fn record_admin_permissions() -> Self { + PermissionSet::record_admin_permissions().into() + } + + /// Returns the permissions needed to administer locking rules. + /// + /// @remarks + /// Includes the following permissions: + /// + /// - {@link Permission.UpdateLockingConfig} + /// - {@link Permission.UpdateLockingConfigForDeleteTrail} + /// - {@link Permission.UpdateLockingConfigForDeleteRecord} + /// - {@link Permission.UpdateLockingConfigForWrite} + /// + /// @returns A {@link PermissionSet} that authorizes updates to all locking dimensions. + #[wasm_bindgen(js_name = lockingAdminPermissions)] + pub fn locking_admin_permissions() -> Self { + PermissionSet::locking_admin_permissions().into() + } + + /// Returns the permissions needed to administer roles. + /// + /// @remarks + /// Includes the following permissions: + /// + /// - {@link Permission.AddRoles} + /// - {@link Permission.UpdateRoles} + /// - {@link Permission.DeleteRoles} + /// + /// @returns A {@link PermissionSet} that authorizes adding, updating, and deleting roles. + #[wasm_bindgen(js_name = roleAdminPermissions)] + pub fn role_admin_permissions() -> Self { + PermissionSet::role_admin_permissions().into() + } + + /// Returns the permissions needed to issue and revoke capabilities. + /// + /// @remarks + /// Includes the following permissions: + /// + /// - {@link Permission.AddCapabilities} + /// - {@link Permission.RevokeCapabilities} + /// + /// @returns A {@link PermissionSet} that authorizes the capability lifecycle. + #[wasm_bindgen(js_name = capAdminPermissions)] + pub fn cap_admin_permissions() -> Self { + PermissionSet::cap_admin_permissions().into() + } + + /// Returns the permissions needed to administer mutable metadata. + /// + /// @remarks + /// Includes the following permissions: + /// + /// - {@link Permission.UpdateMetadata} + /// - {@link Permission.DeleteMetadata} + /// + /// @returns A {@link PermissionSet} that authorizes updating and clearing + /// `updatableMetadata`. + #[wasm_bindgen(js_name = metadataAdminPermissions)] + pub fn metadata_admin_permissions() -> Self { + PermissionSet::metadata_admin_permissions().into() + } + + /// Returns the permissions needed to administer record tags. + /// + /// @remarks + /// Includes the following permissions: + /// + /// - {@link Permission.AddRecordTags} + /// - {@link Permission.DeleteRecordTags} + /// + /// @returns A {@link PermissionSet} that authorizes adding and removing entries from the + /// trail's record-tag registry. + #[wasm_bindgen(js_name = tagAdminPermissions)] + pub fn tag_admin_permissions() -> Self { + PermissionSet::tag_admin_permissions().into() + } +} + +impl From for WasmPermissionSet { + fn from(value: PermissionSet) -> Self { + Self { + permissions: sorted_permissions_from_set(value.permissions), + } + } +} + +impl From for PermissionSet { + fn from(value: WasmPermissionSet) -> Self { + Self { + permissions: value.permissions.into_iter().map(Into::into).collect(), + } + } +} + +/// Linked-table metadata for record storage. +#[wasm_bindgen(js_name = LinkedTable, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmLinkedTable { + /// Linked-table object ID. + pub id: String, + /// Declared number of entries in the table. + pub size: u64, + /// Sequence number of the first entry, if any. + pub head: Option, + /// Sequence number of the last entry, if any. + pub tail: Option, +} + +impl From> for WasmLinkedTable { + fn from(value: LinkedTable) -> Self { + Self { + id: value.id.to_string(), + size: value.size, + head: value.head, + tail: value.tail, + } + } +} + +/// Permissions required to administer roles, as enforced by the trail. +#[wasm_bindgen(js_name = RoleAdminPermissions, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmRoleAdminPermissions { + /// Permission required to create roles. + pub add: WasmPermission, + /// Permission required to delete roles. + pub delete: WasmPermission, + /// Permission required to update roles. + pub update: WasmPermission, +} + +impl From for WasmRoleAdminPermissions { + fn from(value: RoleAdminPermissions) -> Self { + Self { + add: value.add.into(), + delete: value.delete.into(), + update: value.update.into(), + } + } +} + +/// Permissions required to administer capabilities, as enforced by the trail. +#[wasm_bindgen(js_name = CapabilityAdminPermissions, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmCapabilityAdminPermissions { + /// Permission required to issue capabilities. + pub add: WasmPermission, + /// Permission required to revoke capabilities. + pub revoke: WasmPermission, +} + +impl From for WasmCapabilityAdminPermissions { + fn from(value: CapabilityAdminPermissions) -> Self { + Self { + add: value.add.into(), + revoke: value.revoke.into(), + } + } +} + +/// Flattened role entry exposed inside {@link RoleMap}. +#[wasm_bindgen(js_name = RolePermissionsEntry, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRolePermissionsEntry { + /// Role name. + pub name: String, + /// Permissions granted by the role. + pub permissions: Vec, + /// Optional role-scoped record-tag restrictions. + #[wasm_bindgen(js_name = roleTags)] + pub role_tags: Option, +} + +/// Allowlisted record tags stored on a role. +/// +/// @remarks +/// Every tag listed here must already exist in the trail's record-tag registry before the role is +/// created or updated; otherwise the on-chain call aborts. +#[wasm_bindgen(js_name = RoleTags, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmRoleTags { + /// Sorted tag names allowed by the role. + pub tags: Vec, +} + +#[wasm_bindgen(js_class = RoleTags)] +impl WasmRoleTags { + /// Creates role-tag restrictions from a list of tag names. + /// + /// @remarks + /// The supplied names are sorted alphabetically and de-duplicated. + /// + /// @param tags - Tag names allowed by the role. + #[wasm_bindgen(constructor)] + pub fn new(tags: Vec) -> Self { + let mut tags = tags; + tags.sort_unstable(); + tags.dedup(); + Self { tags } + } +} + +impl From for WasmRoleTags { + fn from(value: RoleTags) -> Self { + Self { + tags: sorted_tag_names(value.tags), + } + } +} + +impl From for RoleTags { + fn from(value: WasmRoleTags) -> Self { + RoleTags::new(value.tags) + } +} + +/// Trail-owned record tag plus its usage count. +#[wasm_bindgen(js_name = RecordTagEntry, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmRecordTagEntry { + /// Tag name. + pub tag: String, + /// Combined number of records and roles currently referencing the tag. + #[wasm_bindgen(js_name = usageCount)] + pub usage_count: u64, +} + +impl From<(String, u64)> for WasmRecordTagEntry { + fn from((tag, usage_count): (String, u64)) -> Self { + Self { tag, usage_count } + } +} + +/// Snapshot of the trail's role map. +/// +/// @remarks +/// Mirrors the access-control state maintained by the audit-trail package, including the reserved +/// initial-admin role, the revoked-capability denylist, and the role data used for tag-aware +/// authorization. +#[wasm_bindgen(js_name = RoleMap, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRoleMap { + /// Trail object ID that this role map protects. + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + /// Role definitions sorted by role name. + pub roles: Vec, + /// Reserved role name used for initial-admin capabilities. + /// + /// Always equals `"Admin"`. The role bearing this name cannot be deleted. + #[wasm_bindgen(js_name = initialAdminRoleName)] + pub initial_admin_role_name: String, + /// Denylist of revoked capability IDs. + #[wasm_bindgen(js_name = revokedCapabilities)] + pub revoked_capabilities: WasmObjectIdLinkedTable, + /// Capability IDs currently recognized as initial-admin capabilities. + #[wasm_bindgen(js_name = initialAdminCapIds)] + pub initial_admin_cap_ids: Vec, + /// Permissions required to administer roles. + #[wasm_bindgen(js_name = roleAdminPermissions)] + pub role_admin_permissions: WasmRoleAdminPermissions, + /// Permissions required to administer capabilities. + #[wasm_bindgen(js_name = capabilityAdminPermissions)] + pub capability_admin_permissions: WasmCapabilityAdminPermissions, +} + +impl From for WasmRoleMap { + fn from(value: RoleMap) -> Self { + Self { + target_key: value.target_key.to_string(), + roles: sorted_role_entries(value.roles), + initial_admin_role_name: value.initial_admin_role_name, + revoked_capabilities: value.revoked_capabilities.into(), + initial_admin_cap_ids: sorted_object_ids(value.initial_admin_cap_ids), + role_admin_permissions: value.role_admin_permissions.into(), + capability_admin_permissions: value.capability_admin_permissions.into(), + } + } +} + +/// Linked-table metadata keyed by object IDs. +#[wasm_bindgen(js_name = ObjectIdLinkedTable, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmObjectIdLinkedTable { + /// Linked-table object ID. + pub id: String, + /// Declared number of entries in the table. + pub size: u64, + /// Object ID of the first entry, if any. + pub head: Option, + /// Object ID of the last entry, if any. + pub tail: Option, +} + +impl From> for WasmObjectIdLinkedTable { + fn from(value: LinkedTable) -> Self { + Self { + id: value.id.to_string(), + size: value.size, + head: optional_object_id(value.head), + tail: optional_object_id(value.tail), + } + } +} + +/// Capability issuance options. +/// +/// @remarks +/// These fields configure restrictions on the issued capability object. Matching against the +/// current caller and the on-chain timestamp happens whenever the capability is later presented +/// for authorization, not at issue time. +#[wasm_bindgen(js_name = CapabilityIssueOptions, getter_with_clone, inspectable)] +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct WasmCapabilityIssueOptions { + /// Address that should own the issued capability. When `null`, the capability is transferred + /// to the caller. + #[wasm_bindgen(js_name = issuedTo)] + pub issued_to: Option, + /// Earliest millisecond timestamp (since the Unix epoch) at which the capability becomes + /// valid. When `null`, the capability is valid from its creation time. + #[wasm_bindgen(js_name = validFromMs)] + pub valid_from_ms: Option, + /// Latest millisecond timestamp (since the Unix epoch) at which the capability is still + /// valid. When `null`, the capability does not expire. + #[wasm_bindgen(js_name = validUntilMs)] + pub valid_until_ms: Option, +} + +#[wasm_bindgen(js_class = CapabilityIssueOptions)] +impl WasmCapabilityIssueOptions { + /// Creates capability issuance options. + /// + /// @param issuedTo - Optional recipient address; `null` keeps the capability with the caller. + /// @param validFromMs - Optional earliest valid timestamp in milliseconds since the Unix + /// epoch. + /// @param validUntilMs - Optional latest valid timestamp in milliseconds since the Unix epoch. + #[wasm_bindgen(constructor)] + pub fn new(issued_to: Option, valid_from_ms: Option, valid_until_ms: Option) -> Self { + Self { + issued_to, + valid_from_ms, + valid_until_ms, + } + } +} + +impl From for WasmCapabilityIssueOptions { + fn from(value: CapabilityIssueOptions) -> Self { + Self { + issued_to: value.issued_to.map(|address| address.to_string()), + valid_from_ms: value.valid_from_ms, + valid_until_ms: value.valid_until_ms, + } + } +} + +impl From for CapabilityIssueOptions { + fn from(value: WasmCapabilityIssueOptions) -> Self { + Self { + issued_to: value.issued_to.and_then(|address| address.parse().ok()), + valid_from_ms: value.valid_from_ms, + valid_until_ms: value.valid_until_ms, + } + } +} + +/// Capability data describing a granted role and its validity window. +/// +/// @remarks +/// A capability grants exactly one role against exactly one trail and may additionally restrict +/// who may use it and during which time window it is valid. +#[wasm_bindgen(js_name = Capability, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmCapability { + /// Capability object ID. + pub id: String, + /// Trail object ID protected by the capability. + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + /// Role granted by the capability. + pub role: String, + /// Address bound to the capability. When `null`, any holder may present the capability for + /// authorization. + #[wasm_bindgen(js_name = issuedTo)] + pub issued_to: Option, + /// Earliest millisecond timestamp (since the Unix epoch, inclusive) at which the capability + /// is valid. When `null`, the capability is valid from its creation time. + #[wasm_bindgen(js_name = validFrom)] + pub valid_from: Option, + /// Latest millisecond timestamp (since the Unix epoch, inclusive) at which the capability is + /// still valid. When `null`, the capability does not expire. + #[wasm_bindgen(js_name = validUntil)] + pub valid_until: Option, +} + +impl From for WasmCapability { + fn from(value: Capability) -> Self { + Self { + id: value.id.id.to_string(), + target_key: value.target_key.to_string(), + role: value.role, + issued_to: value.issued_to.map(|address| address.to_string()), + valid_from: value.valid_from, + valid_until: value.valid_until, + } + } +} + +/// Event payload emitted when a trail is created. +#[wasm_bindgen(js_name = AuditTrailCreated, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmAuditTrailCreated { + /// Newly created trail object ID. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Address that created the trail. + pub creator: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmAuditTrailCreated { + fn from(value: AuditTrailCreated) -> Self { + Self { + trail_id: value.trail_id.to_string(), + creator: value.creator.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a trail is deleted. +#[wasm_bindgen(js_name = AuditTrailDeleted, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmAuditTrailDeleted { + /// Deleted trail object ID. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmAuditTrailDeleted { + fn from(value: AuditTrailDeleted) -> Self { + Self { + trail_id: value.trail_id.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a trail is migrated. +#[wasm_bindgen(js_name = AuditTrailMigrated, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmAuditTrailMigrated { + /// Migrated trail object ID. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Address that migrated the trail. + #[wasm_bindgen(js_name = migratedBy)] + pub migrated_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmAuditTrailMigrated { + fn from(value: AuditTrailMigrated) -> Self { + Self { + trail_id: value.trail_id.to_string(), + migrated_by: value.migrated_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when mutable trail metadata is updated. +#[wasm_bindgen(js_name = MetadataUpdated, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmMetadataUpdated { + /// Trail object ID whose metadata changed. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Address that updated the metadata. + #[wasm_bindgen(js_name = updatedBy)] + pub updated_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmMetadataUpdated { + fn from(value: MetadataUpdated) -> Self { + Self { + trail_id: value.trail_id.to_string(), + updated_by: value.updated_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when the trail locking configuration is updated. +#[wasm_bindgen(js_name = LockingConfigUpdated, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmLockingConfigUpdated { + /// Trail object ID whose locking configuration changed. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Address that updated the locking configuration. + #[wasm_bindgen(js_name = updatedBy)] + pub updated_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmLockingConfigUpdated { + fn from(value: LockingConfigUpdated) -> Self { + Self { + trail_id: value.trail_id.to_string(), + updated_by: value.updated_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a record is added. +#[wasm_bindgen(js_name = RecordAdded, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRecordAdded { + /// Trail object ID receiving the new record. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Sequence number assigned to the new record. + #[wasm_bindgen(js_name = sequenceNumber)] + pub sequence_number: u64, + /// Address that added the record. + #[wasm_bindgen(js_name = addedBy)] + pub added_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmRecordAdded { + fn from(value: RecordAdded) -> Self { + Self { + trail_id: value.trail_id.to_string(), + sequence_number: value.sequence_number, + added_by: value.added_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a record is deleted. +#[wasm_bindgen(js_name = RecordDeleted, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRecordDeleted { + /// Trail object ID from which the record was deleted. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Sequence number of the deleted record. + #[wasm_bindgen(js_name = sequenceNumber)] + pub sequence_number: u64, + /// Address that deleted the record. + #[wasm_bindgen(js_name = deletedBy)] + pub deleted_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmRecordDeleted { + fn from(value: RecordDeleted) -> Self { + Self { + trail_id: value.trail_id.to_string(), + sequence_number: value.sequence_number, + deleted_by: value.deleted_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a record tag is added to the trail registry. +#[wasm_bindgen(js_name = RecordTagAdded, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRecordTagAdded { + /// Trail object ID whose registry changed. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Address that added the tag. + #[wasm_bindgen(js_name = addedBy)] + pub added_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmRecordTagAdded { + fn from(value: RecordTagAdded) -> Self { + Self { + trail_id: value.trail_id.to_string(), + added_by: value.added_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a record tag is removed from the trail registry. +#[wasm_bindgen(js_name = RecordTagRemoved, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRecordTagRemoved { + /// Trail object ID whose registry changed. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Address that removed the tag. + #[wasm_bindgen(js_name = removedBy)] + pub removed_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmRecordTagRemoved { + fn from(value: RecordTagRemoved) -> Self { + Self { + trail_id: value.trail_id.to_string(), + removed_by: value.removed_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a capability is issued. +#[wasm_bindgen(js_name = CapabilityIssued, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmCapabilityIssued { + /// Trail object ID protected by the capability. + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + /// Newly created capability object ID. + #[wasm_bindgen(js_name = capabilityId)] + pub capability_id: String, + /// Role granted by the capability. + pub role: String, + /// Address bound to the capability, if one was assigned at issue time. + #[wasm_bindgen(js_name = issuedTo)] + pub issued_to: Option, + /// Earliest millisecond timestamp (since the Unix epoch, inclusive) at which the capability + /// becomes valid. `null` when no lower bound was set. + #[wasm_bindgen(js_name = validFrom)] + pub valid_from: Option, + /// Latest millisecond timestamp (since the Unix epoch, inclusive) at which the capability is + /// still valid. `null` when no expiry was set. + #[wasm_bindgen(js_name = validUntil)] + pub valid_until: Option, +} + +impl From for WasmCapabilityIssued { + fn from(value: CapabilityIssued) -> Self { + Self { + target_key: value.target_key.to_string(), + capability_id: value.capability_id.to_string(), + role: value.role, + issued_to: value.issued_to.map(|address| address.to_string()), + valid_from: value.valid_from, + valid_until: value.valid_until, + } + } +} + +/// Event payload emitted when a capability is destroyed. +#[wasm_bindgen(js_name = CapabilityDestroyed, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmCapabilityDestroyed { + /// Trail object ID protected by the capability. + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + /// Destroyed capability object ID. + #[wasm_bindgen(js_name = capabilityId)] + pub capability_id: String, + /// Role granted by the capability. + pub role: String, + /// Address bound to the capability, if one had been assigned. + #[wasm_bindgen(js_name = issuedTo)] + pub issued_to: Option, + /// Earliest millisecond timestamp (since the Unix epoch, inclusive) at which the capability + /// became valid. `null` when no lower bound had been set. + #[wasm_bindgen(js_name = validFrom)] + pub valid_from: Option, + /// Latest millisecond timestamp (since the Unix epoch, inclusive) at which the capability had + /// been valid. `null` when no expiry had been set. + #[wasm_bindgen(js_name = validUntil)] + pub valid_until: Option, +} + +impl From for WasmCapabilityDestroyed { + fn from(value: CapabilityDestroyed) -> Self { + Self { + target_key: value.target_key.to_string(), + capability_id: value.capability_id.to_string(), + role: value.role, + issued_to: value.issued_to.map(|address| address.to_string()), + valid_from: value.valid_from, + valid_until: value.valid_until, + } + } +} + +/// Event payload emitted when a capability is revoked. +#[wasm_bindgen(js_name = CapabilityRevoked, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmCapabilityRevoked { + /// Trail object ID protected by the capability. + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + /// Revoked capability object ID. + #[wasm_bindgen(js_name = capabilityId)] + pub capability_id: String, + /// Millisecond timestamp retained for denylist cleanup. + /// + /// `0` when the capability had no expiry — denylist entries with `validUntil == 0` are kept + /// indefinitely. + #[wasm_bindgen(js_name = validUntil)] + pub valid_until: u64, +} + +impl From for WasmCapabilityRevoked { + fn from(value: CapabilityRevoked) -> Self { + Self { + target_key: value.target_key.to_string(), + capability_id: value.capability_id.to_string(), + valid_until: value.valid_until, + } + } +} + +/// Event payload emitted when expired revoked-capability entries are cleaned up. +#[wasm_bindgen(js_name = RevokedCapabilitiesCleanedUp, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRevokedCapabilitiesCleanedUp { + /// Trail object ID whose denylist was pruned. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Number of expired entries removed by this cleanup call. + #[wasm_bindgen(js_name = cleanedCount)] + pub cleaned_count: u64, + /// Address that triggered the cleanup. + #[wasm_bindgen(js_name = cleanedBy)] + pub cleaned_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmRevokedCapabilitiesCleanedUp { + fn from(value: RevokedCapabilitiesCleanedUp) -> Self { + Self { + trail_id: value.trail_id.to_string(), + cleaned_count: value.cleaned_count, + cleaned_by: value.cleaned_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a role is created. +#[wasm_bindgen(js_name = RoleCreated, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRoleCreated { + /// Trail object ID that owns the role. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Role name. + pub role: String, + /// Permissions granted by the new role. + pub permissions: WasmPermissionSet, + /// Optional record-tag restrictions stored as role data. + #[wasm_bindgen(js_name = roleTags)] + pub role_tags: Option, + /// Address that created the role. + #[wasm_bindgen(js_name = createdBy)] + pub created_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmRoleCreated { + fn from(value: RoleCreated) -> Self { + Self { + trail_id: value.trail_id.to_string(), + role: value.role, + permissions: value.permissions.into(), + role_tags: value.data.map(Into::into), + created_by: value.created_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a role is updated. +#[wasm_bindgen(js_name = RoleUpdated, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRoleUpdated { + /// Trail object ID that owns the role. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Role name. + pub role: String, + /// Updated permissions for the role. + pub permissions: WasmPermissionSet, + /// Updated record-tag restrictions, if any. + #[wasm_bindgen(js_name = roleTags)] + pub role_tags: Option, + /// Address that updated the role. + #[wasm_bindgen(js_name = updatedBy)] + pub updated_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmRoleUpdated { + fn from(value: RoleUpdated) -> Self { + Self { + trail_id: value.trail_id.to_string(), + role: value.role, + permissions: value.permissions.into(), + role_tags: value.data.map(Into::into), + updated_by: value.updated_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Event payload emitted when a role is deleted. +#[wasm_bindgen(js_name = RoleDeleted, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRoleDeleted { + /// Trail object ID that owned the role. + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + /// Role name. + pub role: String, + /// Address that deleted the role. + #[wasm_bindgen(js_name = deletedBy)] + pub deleted_by: WasmIotaAddress, + /// Millisecond event timestamp. + pub timestamp: u64, +} + +impl From for WasmRoleDeleted { + fn from(value: RoleDeleted) -> Self { + Self { + trail_id: value.trail_id.to_string(), + role: value.role, + deleted_by: value.deleted_by.to_string(), + timestamp: value.timestamp, + } + } +} + +/// Discriminant for the shape stored inside {@link TimeLock}. +#[wasm_bindgen(js_name = TimeLockType)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WasmTimeLockType { + /// The time lock is disabled. + None, + /// The lock unlocks at a Unix timestamp in seconds. + UnlockAt, + /// The lock unlocks at a Unix timestamp in milliseconds. + UnlockAtMs, + /// The lock stays active until the protected object is explicitly destroyed. + /// + /// Not supported as the trail-delete lock. + UntilDestroyed, + /// The lock is always active. + Infinite, +} + +/// Time-based lock used in the trail's {@link LockingConfig}. +/// +/// @remarks +/// {@link TimeLock.withUntilDestroyed} is rejected by the audit-trail package when used as the +/// trail-delete lock; pass it only for the write lock. +#[wasm_bindgen(js_name = TimeLock, inspectable)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WasmTimeLock(pub(crate) TimeLock); + +#[wasm_bindgen(js_class = TimeLock)] +impl WasmTimeLock { + /// Creates a lock that unlocks at a Unix timestamp in seconds. + /// + /// @param timeSec - Unlock time in seconds since the Unix epoch. + /// + /// @returns A lock that unlocks once the on-chain clock reaches `timeSec`. + #[wasm_bindgen(js_name = withUnlockAt)] + pub fn with_unlock_at(time_sec: u32) -> Self { + Self(TimeLock::UnlockAt(time_sec)) + } + + /// Creates a lock that unlocks at a Unix timestamp in milliseconds. + /// + /// @param timeMs - Unlock time in milliseconds since the Unix epoch. + /// + /// @returns A lock that unlocks once the on-chain clock reaches `timeMs`. + #[wasm_bindgen(js_name = withUnlockAtMs)] + pub fn with_unlock_at_ms(time_ms: u64) -> Self { + Self(TimeLock::UnlockAtMs(time_ms)) + } + + /// Creates a lock that stays active until the protected object is destroyed. + /// + /// @returns A lock that remains active until the protected object is destroyed. + #[wasm_bindgen(js_name = withUntilDestroyed)] + pub fn with_until_destroyed() -> Self { + Self(TimeLock::UntilDestroyed) + } + + /// Creates a lock that never unlocks. + /// + /// @returns A lock that is always active. + #[wasm_bindgen(js_name = withInfinite)] + pub fn with_infinite() -> Self { + Self(TimeLock::Infinite) + } + + /// Creates a disabled lock. + /// + /// @returns A lock that does not gate the protected operation. + #[wasm_bindgen(js_name = withNone)] + pub fn with_none() -> Self { + Self(TimeLock::None) + } + + /// Returns the lock variant. + /// + /// @returns The {@link TimeLockType} discriminant for this lock. + #[wasm_bindgen(js_name = "type", getter)] + pub fn lock_type(&self) -> WasmTimeLockType { + match self.0 { + TimeLock::None => WasmTimeLockType::None, + TimeLock::UnlockAt(_) => WasmTimeLockType::UnlockAt, + TimeLock::UnlockAtMs(_) => WasmTimeLockType::UnlockAtMs, + TimeLock::UntilDestroyed => WasmTimeLockType::UntilDestroyed, + TimeLock::Infinite => WasmTimeLockType::Infinite, + } + } + + /// Returns the lock argument for parameterized variants. + /// + /// @returns The numeric argument for `UnlockAt`/`UnlockAtMs` variants, or `undefined` + /// otherwise. + #[wasm_bindgen(js_name = "args", getter)] + pub fn args(&self) -> JsValue { + match self.0 { + TimeLock::UnlockAt(value) => JsValue::from(value), + TimeLock::UnlockAtMs(value) => JsValue::from(value), + _ => JsValue::UNDEFINED, + } + } +} + +impl From for WasmTimeLock { + fn from(value: TimeLock) -> Self { + Self(value) + } +} + +impl From for TimeLock { + fn from(value: WasmTimeLock) -> Self { + value.0 + } +} + +/// Discriminant for the shape stored inside {@link LockingWindow}. +#[wasm_bindgen(js_name = LockingWindowType)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WasmLockingWindowType { + /// No delete window is enforced; records may be deleted at any time. + None, + /// The window locks records while their age is below a configured number of seconds. + TimeBased, + /// The window locks records while they are among the most recent N records. + CountBased, +} + +/// Delete-window definition used in the trail's {@link LockingConfig}. +/// +/// @remarks +/// A window describes the period during which a record stays *locked against deletion*: time-based +/// windows lock a record while its age is below the configured number of seconds; count-based +/// windows lock the last `count` records currently present in trail order. Records outside the +/// window may be deleted, subject to remaining permission and tag checks. +#[wasm_bindgen(js_name = LockingWindow, inspectable)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WasmLockingWindow(pub(crate) LockingWindow); + +#[wasm_bindgen(js_class = LockingWindow)] +impl WasmLockingWindow { + /// Creates a disabled delete window. + /// + /// @returns A window that does not lock records against deletion. + #[wasm_bindgen(js_name = withNone)] + pub fn with_none() -> Self { + Self(LockingWindow::None) + } + + /// Creates a time-based delete window. + /// + /// @param seconds - Maximum record age, in seconds, for which the record stays locked against + /// deletion. + /// + /// @returns A window that locks records younger than `seconds`. + #[wasm_bindgen(js_name = withTimeBased)] + pub fn with_time_based(seconds: u64) -> Self { + Self(LockingWindow::TimeBased { seconds }) + } + + /// Creates a count-based delete window. + /// + /// @param count - Number of most recent records that stay locked against deletion. Must be + /// greater than zero; use {@link LockingWindow.withNone} for no deletion lock. + /// + /// @returns A window that protects the last `count` records currently present in trail order. + #[wasm_bindgen(js_name = withCountBased)] + pub fn with_count_based(count: u64) -> Self { + Self(LockingWindow::CountBased { count }) + } + + /// Returns the window variant. + /// + /// @returns The {@link LockingWindowType} discriminant for this window. + #[wasm_bindgen(js_name = "type", getter)] + pub fn window_type(&self) -> WasmLockingWindowType { + match self.0 { + LockingWindow::None => WasmLockingWindowType::None, + LockingWindow::TimeBased { .. } => WasmLockingWindowType::TimeBased, + LockingWindow::CountBased { .. } => WasmLockingWindowType::CountBased, + } + } + + /// Returns the window argument for parameterized variants. + /// + /// @returns The numeric argument for `TimeBased`/`CountBased` variants, or `undefined` + /// otherwise. + #[wasm_bindgen(js_name = "args", getter)] + pub fn args(&self) -> JsValue { + match self.0 { + LockingWindow::TimeBased { seconds } => JsValue::from(seconds), + LockingWindow::CountBased { count } => JsValue::from(count), + LockingWindow::None => JsValue::UNDEFINED, + } + } +} + +impl From for WasmLockingWindow { + fn from(value: LockingWindow) -> Self { + Self(value) + } +} + +impl From for LockingWindow { + fn from(value: WasmLockingWindow) -> Self { + value.0 + } +} + +/// Full locking configuration. +/// +/// @remarks +/// Combines three independent rules: a per-record delete window, a trail-delete time lock, and a +/// write-time lock. The trail-delete lock must not be {@link TimeLock.withUntilDestroyed}, and a +/// count-based delete-record window must use `count > 0`; trail creation and locking updates that +/// violate either invariant are rejected (client-side where possible, on-chain otherwise). +#[wasm_bindgen(js_name = LockingConfig, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmLockingConfig { + /// Delete-window policy applied to individual records. + /// + /// Records inside the window are locked against deletion. + #[wasm_bindgen(js_name = deleteRecordWindow)] + pub delete_record_window: WasmLockingWindow, + /// Time lock that gates deletion of the entire trail. + /// + /// Must not be {@link TimeLock.withUntilDestroyed}; trail creation and locking updates that + /// violate this invariant abort on-chain. + #[wasm_bindgen(js_name = deleteTrailLock)] + pub delete_trail_lock: WasmTimeLock, + /// Time lock that gates record writes (`addRecord`). + #[wasm_bindgen(js_name = writeLock)] + pub write_lock: WasmTimeLock, +} + +#[wasm_bindgen(js_class = LockingConfig)] +impl WasmLockingConfig { + /// Creates a locking configuration. + /// + /// @param deleteRecordWindow - {@link LockingWindow} that controls when individual records may + /// be deleted. + /// @param deleteTrailLock - {@link TimeLock} that controls when the trail itself may be + /// deleted. + /// @param writeLock - {@link TimeLock} that controls when records may be appended. + #[wasm_bindgen(constructor)] + pub fn new( + delete_record_window: WasmLockingWindow, + delete_trail_lock: WasmTimeLock, + write_lock: WasmTimeLock, + ) -> Self { + Self { + delete_record_window, + delete_trail_lock, + write_lock, + } + } +} + +impl From for WasmLockingConfig { + fn from(value: LockingConfig) -> Self { + Self { + delete_record_window: value.delete_record_window.into(), + delete_trail_lock: value.delete_trail_lock.into(), + write_lock: value.write_lock.into(), + } + } +} + +impl From for LockingConfig { + fn from(value: WasmLockingConfig) -> Self { + Self { + delete_record_window: value.delete_record_window.into(), + delete_trail_lock: value.delete_trail_lock.into(), + write_lock: value.write_lock.into(), + } + } +} + +/// Immutable trail metadata. +/// +/// @remarks +/// Stored once on the trail object at creation and exposed read-only thereafter. Use +/// {@link OnChainAuditTrail.updatableMetadata} for the mutable counterpart. +#[wasm_bindgen(js_name = ImmutableMetadata, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmImmutableMetadata { + /// Human-readable trail name. + pub name: String, + /// Optional human-readable description. + pub description: Option, +} + +impl From for WasmImmutableMetadata { + fn from(value: ImmutableMetadata) -> Self { + Self { + name: value.name, + description: value.description, + } + } +} + +impl From for ImmutableMetadata { + fn from(value: WasmImmutableMetadata) -> Self { + ImmutableMetadata { + name: value.name, + description: value.description, + } + } +} + +/// Correction metadata attached to a record. +/// +/// @remarks +/// {@link RecordCorrection.replaces} is fixed at record creation and lists the sequence numbers +/// this record supersedes; {@link RecordCorrection.isReplacedBy} is a back-pointer the trail sets +/// later when this record itself is corrected. +#[wasm_bindgen(js_name = RecordCorrection, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmRecordCorrection { + /// Sorted sequence numbers that this record supersedes. + pub replaces: Vec, + /// Sequence number of the record that supersedes this one, if any. + #[wasm_bindgen(js_name = isReplacedBy)] + pub is_replaced_by: Option, +} + +impl From for WasmRecordCorrection { + fn from(value: RecordCorrection) -> Self { + let mut replaces: Vec = value.replaces.into_iter().collect(); + replaces.sort_unstable(); + Self { + replaces, + is_replaced_by: value.is_replaced_by, + } + } +} + +impl From for RecordCorrection { + fn from(value: WasmRecordCorrection) -> Self { + Self { + replaces: value.replaces.into_iter().collect::>(), + is_replaced_by: value.is_replaced_by, + } + } +} + +/// Single audit-trail record. +/// +/// @remarks +/// Records form a tamper-evident, sequential chain: each record has a monotonically increasing +/// sequence number that is never reused, even after the record is deleted. +#[wasm_bindgen(js_name = Record, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRecord { + /// Record payload stored on-chain. + pub data: WasmData, + /// Optional application-defined metadata. + pub metadata: Option, + /// Optional trail-owned tag attached to the record. + pub tag: Option, + /// Monotonic record sequence number inside the trail. + #[wasm_bindgen(js_name = sequenceNumber)] + pub sequence_number: u64, + /// Address that added the record. + #[wasm_bindgen(js_name = addedBy)] + pub added_by: WasmIotaAddress, + /// Millisecond timestamp at which the record was added. + #[wasm_bindgen(js_name = addedAt)] + pub added_at: u64, + /// Correction relationships for this record. + pub correction: WasmRecordCorrection, +} + +impl From> for WasmRecord { + fn from(value: Record) -> Self { + Self { + data: value.data.into(), + metadata: value.metadata, + tag: value.tag, + sequence_number: value.sequence_number, + added_by: value.added_by.to_string(), + added_at: value.added_at, + correction: value.correction.into(), + } + } +} + +/// One page of records returned by {@link TrailRecords.listPage}. +#[wasm_bindgen(js_name = PaginatedRecord, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmPaginatedRecord { + /// Records included in the current page, ordered by sequence number. + pub records: Vec, + /// Cursor to pass to the next {@link TrailRecords.listPage} call. + #[wasm_bindgen(js_name = nextCursor)] + pub next_cursor: Option, + /// Indicates whether another page may be available. + #[wasm_bindgen(js_name = hasNextPage)] + pub has_next_page: bool, +} + +impl From> for WasmPaginatedRecord { + fn from(value: PaginatedRecord) -> Self { + Self { + records: value.records.into_values().map(Into::into).collect(), + next_cursor: value.next_cursor, + has_next_page: value.has_next_page, + } + } +} diff --git a/bindings/wasm/audit_trail_wasm/tsconfig.json b/bindings/wasm/audit_trail_wasm/tsconfig.json new file mode 100644 index 00000000..a741de44 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@iota/audit-trails/*": [ + "./*" + ] + } + } +} diff --git a/bindings/wasm/audit_trail_wasm/tsconfig.node.json b/bindings/wasm/audit_trail_wasm/tsconfig.node.json new file mode 100644 index 00000000..c09b2e5a --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/tsconfig.node.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "esModuleInterop": true, + "module": "commonjs" + } +} diff --git a/bindings/wasm/audit_trail_wasm/tsconfig.typedoc.json b/bindings/wasm/audit_trail_wasm/tsconfig.typedoc.json new file mode 100644 index 00000000..bfc43be9 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/tsconfig.typedoc.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.node.json", + "include": [ + "node/**/*" + ] +} diff --git a/bindings/wasm/audit_trail_wasm/typedoc.json b/bindings/wasm/audit_trail_wasm/typedoc.json new file mode 100644 index 00000000..57f0ba99 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/typedoc.json @@ -0,0 +1,11 @@ +{ + "name": "@iota/audit-trails API documentation", + "extends": [ + "../typedoc.json" + ], + "entryPoints": [ + "./node/" + ], + "tsconfig": "./tsconfig.typedoc.json", + "out": "./docs/wasm" +} diff --git a/bindings/wasm/notarization_wasm/CLAUDE.md b/bindings/wasm/notarization_wasm/CLAUDE.md new file mode 100644 index 00000000..a38ee7d7 --- /dev/null +++ b/bindings/wasm/notarization_wasm/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md — Guidelines for `notarization_wasm` + +## Documentation Style Guide + +Follow the guidelines in `../DOC-STYLEGUIDE.md`. + +## No Capability based access control + +Ignore the rules stated in the `Capability gating` section because +`notarization_wasm` only uses access control through Move object ownership. + +## Notarization-product-specific terminology and rules + +### Notarization Methods + +`Dynamic` and `Locked` are the **Notarization Methods**. Always refer to them +as Notarization Methods (or, where unambiguous, simply `method`). Do **not** +use synonyms such as "variant", "kind", "type", "behavioural variant", +"flavour", "mode", "sort", or similar. When mentioning a specific method, use +the bare enum name in backticks (`` `Dynamic` ``, `` `Locked` ``) — the full +path `NotarizationMethod::Dynamic` is not needed in prose. The compound terms +"Dynamic-Notarization" and "Locked-Notarization" refer to a `Notarization` +configured with the corresponding method. + +Use generic Notarization Method based descriptions if suitable. Do not reduce +the usage of Notarization Methods to the currently available variants +(like `... is dynamic or locked`) because in future versions there may be more +Notarization Method variants. Only explain a behavior using specific Notarization +Method variants if a function (or other item) is explicitly focussed (or limited) +to one or more specific Notarization Methods. + +### Method-dependent behavior must be a bullet list + +Whenever the behavior of a documented entity (function, struct, field, enum, +event, constant) differs between Notarization Methods, document the +differences as a Markdown bullet list with one bullet per method, in this +fixed order: + +```move +/// ... +/// Behaviour depends on the Notarization Method: +/// * `Dynamic`: . +/// * `Locked`: . +``` + +This format must be kept even when the rule for one method is trivial +("Always returns `false`.") so that future Notarization Methods can be added +as additional bullets without restructuring the surrounding prose. Never +collapse the per-method behavior into a single sentence such as "mutable +only for the dynamic method". diff --git a/bindings/wasm/notarization_wasm/Cargo.toml b/bindings/wasm/notarization_wasm/Cargo.toml index 6a852fc4..bbc330de 100644 --- a/bindings/wasm/notarization_wasm/Cargo.toml +++ b/bindings/wasm/notarization_wasm/Cargo.toml @@ -21,8 +21,8 @@ async-trait = { version = "0.1", default-features = false } bcs = "0.1.6" console_error_panic_hook = { version = "0.1" } fastcrypto = { git = "https://github.com/MystenLabs/fastcrypto", rev = "69d496c71fb37e3d22fe85e5bbfd4256d61422b9", package = "fastcrypto" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.18", package = "iota_interaction", default-features = false } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.18", package = "iota_interaction_ts" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.19", package = "iota_interaction", default-features = false } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.19", package = "iota_interaction_ts" } js-sys = { version = "=0.3.85" } prefix-hex = { version = "0.7", default-features = false } serde = { version = "1.0", features = ["derive"] } @@ -37,9 +37,16 @@ wasm-bindgen-futures = { version = "0.4", default-features = false } [dependencies.product_common] git = "https://github.com/iotaledger/product-core.git" -tag = "v0.8.18" +tag = "v0.8.19" package = "product_common" -features = ["core-client", "transaction", "bindings", "binding-utils", "gas-station", "default-http-client"] +features = [ + "core-client", + "transaction", + "bindings", + "binding-utils", + "gas-station", + "default-http-client", +] [dependencies.notarization] path = "../../../notarization-rs" diff --git a/bindings/wasm/notarization_wasm/examples/src/gas-costs/README.md b/bindings/wasm/notarization_wasm/examples/src/gas-costs/README.md index 5449be1f..1122d238 100644 --- a/bindings/wasm/notarization_wasm/examples/src/gas-costs/README.md +++ b/bindings/wasm/notarization_wasm/examples/src/gas-costs/README.md @@ -31,7 +31,7 @@ Where: | `FlexDataSize` | Sum of the byte sizes of State Data, State Metadata, Updatable Metadata and Immutable Metadata. The value must be reduced by 1 as the `MinimumStorageCost` uses 1 byte of State Data. | | `FlexDataByteCost` | A constant value of 0.0000076 IOTA/Byte
This value denotes (`StorageCost` - `MinimumStorageCost`) divided by `FlexDataSize`. | | `MinimumStorageCost` | A constant value of 0.00295 IOTA.
This value denotes the `StorageCost` for a Notarization with 1 Byte of `FlexDataSize` meaning a Notarization with 1 Byte of State Data, no meta data and no optional locks. | -| `ComputationCost` | A constant value of 0.001 IOTA.
Given the Gas Price is 1000 nano, the `ComputationCost` will always be 0.001 IOTA as creating Notarizations always consume 1000 Computation Units. | +| `ComputationCost` | A constant value of 0.001 IOTA.
Given the Gas Price is 1000 nano, the `ComputationCost` will always be 0.001 IOTA as creating notarizations always consumes 1000 Computation Units. | | `TotalCost` | The amount of IOTA that would need to be paid for gas when Storage Rebate is not taken into account. The real gas cost will be lower, due to Storage Rebate, which is usually -0.0009804 IOTA when a Notarization object is created. | Examples: diff --git a/bindings/wasm/notarization_wasm/src/lib.rs b/bindings/wasm/notarization_wasm/src/lib.rs index fe13f1b8..81c5ae82 100644 --- a/bindings/wasm/notarization_wasm/src/lib.rs +++ b/bindings/wasm/notarization_wasm/src/lib.rs @@ -23,7 +23,10 @@ pub(crate) mod wasm_types; // Export all product_common's bindings (e.g. Transaction, CoreClient, gas-station stuff, etc). pub use product_common::bindings::*; -/// Initializes the console error panic hook for better error messages +/// Module entry point — installs an error hook so that internal panics +/// surface as readable error messages in the browser console. Invoked +/// automatically when the module is loaded; not intended to be called from +/// user code. #[wasm_bindgen(start)] pub fn start() -> Result<(), JsValue> { console_error_panic_hook::set_once(); diff --git a/bindings/wasm/notarization_wasm/src/wasm_notarization.rs b/bindings/wasm/notarization_wasm/src/wasm_notarization.rs index 267fec8a..6c57e6bd 100644 --- a/bindings/wasm/notarization_wasm/src/wasm_notarization.rs +++ b/bindings/wasm/notarization_wasm/src/wasm_notarization.rs @@ -22,131 +22,118 @@ use wasm_bindgen::prelude::*; use crate::wasm_notarization_builder::{WasmNotarizationBuilderDynamic, WasmNotarizationBuilderLocked}; use crate::wasm_types::{WasmEmpty, WasmImmutableMetadata, WasmNotarizationMethod, WasmState}; -/// Represents an on-chain notarization object. +/// The on-chain representation of a notarization. /// -/// Provides access to various properties of the notarization, such as its ID, state, metadata -/// and method. +/// @remarks +/// Stores user-defined data together with immutable provenance, optional +/// updatable metadata, and lock metadata that governs whether the object can +/// be updated, transferred, or destroyed. The selected +/// {@link NotarizationMethod} determines which mutations are allowed after +/// creation. +/// +/// Returned by {@link NotarizationClientReadOnly.getNotarizationById} and by +/// the executed transaction. Exposes the notarization's identity, current +/// state, immutable and updatable metadata, the {@link NotarizationMethod}, +/// and the current owner. #[wasm_bindgen(js_name = OnChainNotarization, inspectable)] #[derive(Clone)] pub struct WasmOnChainNotarization(pub(crate) OnChainNotarization); #[wasm_bindgen(js_class = OnChainNotarization)] impl WasmOnChainNotarization { - // Creates a new `OnChainNotarization` instance. - // - // # Arguments - // * `notarization` - The `OnChainNotarization` object to wrap. pub(crate) fn new(notarization: OnChainNotarization) -> Self { Self(notarization) } - /// Retrieves the ID of the notarization. - /// - /// # Returns - /// A hexadecimal string representing the notarization ID. + /// The notarization's object ID, as a hexadecimal string. #[wasm_bindgen(getter)] pub fn id(&self) -> String { self.0.id.id.bytes.to_hex() } - /// Retrieves the current `state` of the notarization. - /// - /// The `state` of a notarization contains the notarized data and metadata associated with - /// the current version of the `state`. + /// The current {@link State} of the notarization. /// - /// `state` can be updated depending on the used `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after notarization creation - /// - Locked: Immutable after notarization creation + /// @remarks + /// The `state` of a notarization contains the notarized data and metadata + /// associated with the current version of the `state`. /// - /// Use `NotarizationClient::updateState()` for `state` updates. - /// - /// # Returns - /// A `State` object representing the notarization state. + /// Mutability depends on the Notarization Method: + /// * `Dynamic`: the state can be replaced after creation via {@link NotarizationClient.updateState}. + /// * `Locked`: the state is fixed at creation and cannot be replaced. #[wasm_bindgen(getter)] pub fn state(&self) -> WasmState { WasmState(self.0.state.clone()) } - /// Retrieves the immutable metadata of the notarization. - /// - /// NOTE: - /// - provides immutable information, assertions and guaranties for third parties - /// - `immutableMetadata` are automatically created at creation time and cannot be updated thereafter + /// The fixed-at-creation {@link ImmutableMetadata}. /// - /// # Returns - /// An `ImmutableMetadata` object containing the metadata. + /// @remarks + /// Provides immutable information, assertions, and guarantees for third + /// parties: it is created automatically at notarization creation and + /// cannot be changed afterwards. #[wasm_bindgen(js_name = immutableMetadata, getter)] pub fn immutable_metadata(&self) -> WasmImmutableMetadata { WasmImmutableMetadata(self.0.immutable_metadata.clone()) } - /// Retrieves the updatable metadata of the notarization. - /// - /// Provides context or additional information for third parties + /// The current updatable metadata, if any. /// - /// `updatableMetadata` can be updated depending on the used `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after notarization creation - /// - Locked: Immutable after notarization creation + /// @remarks + /// Provides context or additional information for third parties. /// - /// NOTE: - /// - `updatableMetadata` can be updated independently of `state` - /// - Updating `updatableMetadata` does not increase the `stateVersionCount` - /// - Updating `updatableMetadata` does not change the `lastStateChangeAt` timestamp - /// - Use `NotarizationClient::updateMetadata()` for `updatableMetadata` updates. + /// Mutability depends on the Notarization Method: + /// * `Dynamic`: the updatable metadata can be replaced after creation via {@link + /// NotarizationClient.updateMetadata}. + /// * `Locked`: the value is fixed at creation and cannot be replaced. /// - /// # Returns - /// An optional string containing the metadata. + /// `updatableMetadata` is independent of {@link OnChainNotarization.state}: + /// replacing it does not increment the `stateVersionCount` and does not + /// update the `lastStateChangeAt` timestamp. #[wasm_bindgen(js_name = updatableMetadata, getter)] pub fn updatable_metadata(&self) -> Option { self.0.updatable_metadata.clone() } - /// Retrieves the timestamp of the last state change. - /// - /// # Returns - /// A `number` value representing the timestamp, - /// the time in milliseconds since UNIX epoch. + /// The timestamp of the most recent state change, in milliseconds since + /// the Unix epoch. #[wasm_bindgen(js_name = lastStateChangeAt, getter)] pub fn last_state_change_at(&self) -> u64 { self.0.last_state_change_at } - /// Retrieves the count of state versions. - /// - /// # Returns - /// A `number` value representing the number of state versions. + /// The number of state versions the notarization has gone through. + /// `0` means the state has not been updated since creation. #[wasm_bindgen(js_name = stateVersionCount, getter)] pub fn state_version_count(&self) -> u64 { self.0.state_version_count } - /// Retrieves the notarization method. - /// - /// # Returns - /// A `NotarizationMethod` object representing the method. + /// The {@link NotarizationMethod} the notarization was created with. #[wasm_bindgen(getter)] pub fn method(&self) -> WasmNotarizationMethod { self.0.method.clone().into() } - /// Retrieves the owner address of the notarization. - /// - /// # Returns - /// An `IotaAddress` object representing the owner address. + /// The current owner's IOTA address. #[wasm_bindgen(getter)] pub fn owner(&self) -> WasmIotaAddress { WasmIotaAddress::from_str(&self.0.owner.to_string()) .expect("Invalid address stored on-chain, this should never happen") } - /// Returns an IOTA Resource Locator (IRL) to the data stored within - /// this notarization object. + /// Creates an IOTA Resource Locator (IRL) builder rooted at this + /// notarization. + /// + /// @remarks + /// The returned builder produces IRLs of the form + /// `iota://state/data` + /// and similar paths for related fields. + /// + /// @param network - The IOTA network identifier (e.g. `"mainnet"`). /// - /// The returned IRL will be in the form: - /// `iota://state/data`. - /// # Errors - /// Throws an error if the given `network` string is not a valid IOTA - /// network identifier. + /// @returns A {@link NotarizationResourceBuilder} for this notarization. + /// + /// @throws When `network` is not a valid IOTA network identifier. #[wasm_bindgen(js_name = iotaResourceLocatorBuilder)] pub fn iota_resource_locator_builder( &self, @@ -159,87 +146,101 @@ impl WasmOnChainNotarization { } } -// Converts an `OnChainNotarization` into a `WasmOnChainNotarization`. impl From for WasmOnChainNotarization { fn from(notarization: OnChainNotarization) -> Self { WasmOnChainNotarization::new(notarization) } } -/// A builder for creating IOTA Resource Locators (IRLs) pointing within a notarization. +/// Builder for IOTA Resource Locators (IRLs) pointing at fields of an +/// {@link OnChainNotarization}. #[wasm_bindgen(js_name = NotarizationResourceBuilder)] pub struct WasmNotarizationResourceBuilder(NotarizationResourceBuilder); #[wasm_bindgen(js_class = NotarizationResourceBuilder)] impl WasmNotarizationResourceBuilder { - /// Returns an IRL referencing this {@link OnChainNotarization} state's data. + /// An IRL pointing at the notarization's current state payload. pub fn data(&self) -> String { self.0.data().to_string() } - /// Returns an IRL referencing this {@link OnChainNotarization}'s immutable metadata. + /// An IRL pointing at the notarization's immutable metadata. #[wasm_bindgen(js_name = immutableMetadata)] pub fn immutable_metadata(&self) -> String { self.0.immutable_metadata().to_string() } - /// Returns an IRL referencing this {@link OnChainNotarization} state's metadata. + /// An IRL pointing at the notarization's current state metadata. #[wasm_bindgen(js_name = stateMetadata)] pub fn state_metadata(&self) -> String { self.0.state_metadata().to_string() } - /// Returns an IRL referencing this {@link OnChainNotarization}'s updatable metadata. + /// An IRL pointing at the notarization's updatable metadata. #[wasm_bindgen(js_name = updatableMetadata)] pub fn updatable_metadata(&self) -> String { self.0.updatable_metadata().to_string() } - /// Returns an IRL referencing this {@link OnChainNotarization}'s owner. + /// An IRL pointing at the notarization's owner. pub fn owner(&self) -> String { self.0.owner().to_string() } } -/// Represents a transaction for creating locked notarization's. +/// Transaction that creates a Locked-Notarization. +/// +/// @remarks +/// A Locked-Notarization is immutable after creation: its state and +/// updatable metadata are fixed for the lifetime of the object. On +/// success the new notarization object is transferred to the transaction +/// sender. /// -/// Locked notarization's cannot be modified after creation, ensuring data permanence. +/// Emits a `LockedNotarizationCreated` event on success. #[wasm_bindgen(js_name = CreateNotarizationLocked, inspectable)] pub struct WasmCreateNotarizationLocked(pub(crate) CreateNotarization); #[wasm_bindgen(js_class = CreateNotarizationLocked)] impl WasmCreateNotarizationLocked { + /// Constructs the transaction from a configured Locked-Notarization + /// builder. + /// + /// @param builder - A finalized {@link NotarizationBuilderLocked}. #[wasm_bindgen(constructor)] pub fn new(builder: WasmNotarizationBuilderLocked) -> Self { WasmCreateNotarizationLocked(CreateNotarization::::new(builder.0)) } - #[wasm_bindgen(js_name = buildProgrammableTransaction)] - /// Builds and returns a programmable transaction for creating a locked notarization. + /// Builds the programmable transaction bytes. + /// + /// @param client - A read-only client connected to the target network. /// - /// # Returns - /// The binary BCS serialization of the programmable transaction. - /// This transaction can be submitted to the network to create a new locked notarization. + /// @returns The BCS-serialized programmable transaction, ready to be + /// signed and submitted. /// - /// # Errors - /// Returns an error if the transaction cannot be built due to invalid parameters - /// or other constraints. + /// @throws When the transaction cannot be built — e.g. when the + /// configured state, metadata, or lock combination is rejected. + #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { build_programmable_transaction(&self.0, client).await } - /// Applies transaction effects and events to this notarization creation operation. + /// Reads the on-chain effects and events of the submitted transaction + /// and returns the resulting {@link OnChainNotarization}. /// - /// This method is called automatically by Transaction::build_programmable_transaction() - /// and Transaction::apply() methods after the transaction has been successfully submitted - /// to process the results from the ledger. + /// @remarks + /// Invoked automatically by the {@link TransactionBuilder} machinery + /// after the transaction has been submitted; calling it directly is + /// normally not necessary. /// - /// # Arguments - /// * `effects` - The transaction block effects to apply. - /// * `events` - The transaction block events to apply. + /// @param effects - The transaction block effects produced on-chain. + /// @param events - The transaction block events produced on-chain. + /// @param client - A read-only client connected to the target network. /// - /// # Returns - /// The created notarization ID if successful. + /// @returns The created {@link OnChainNotarization}. + /// + /// @throws When the effects/events are inconsistent with this transaction + /// or the result cannot be reconstructed. #[wasm_bindgen(js_name = applyWithEvents)] pub async fn apply_with_events( self, @@ -251,47 +252,57 @@ impl WasmCreateNotarizationLocked { } } -/// Represents a transaction for creating dynamic notarization's. +/// Transaction that creates a Dynamic-Notarization. +/// +/// @remarks +/// A Dynamic-Notarization can be updated after creation. On success the new +/// notarization object is transferred to the transaction sender. /// -/// Dynamic notarization's can be updated after creation, with modification capabilities -/// for both state and metadata. +/// Emits a `DynamicNotarizationCreated` event on success. #[wasm_bindgen(js_name = CreateNotarizationDynamic, inspectable)] pub struct WasmCreateNotarizationDynamic(pub(crate) CreateNotarization); #[wasm_bindgen(js_class = CreateNotarizationDynamic)] impl WasmCreateNotarizationDynamic { + /// Constructs the transaction from a configured Dynamic-Notarization + /// builder. + /// + /// @param builder - A finalized {@link NotarizationBuilderDynamic}. #[wasm_bindgen(constructor)] pub fn new(builder: WasmNotarizationBuilderDynamic) -> Self { WasmCreateNotarizationDynamic(CreateNotarization::::new(builder.0)) } - /// Builds and returns a programmable transaction for creating a dynamic notarization. + /// Builds the programmable transaction bytes. /// - /// # Returns - /// The binary BCS serialization of the programmable transaction. - /// This transaction can be submitted to the network to create a new dynamic notarization. + /// @param client - A read-only client connected to the target network. /// - /// # Errors - /// Returns an error if the transaction cannot be built due to invalid parameters - /// or other constraints. + /// @returns The BCS-serialized programmable transaction, ready to be + /// signed and submitted. + /// + /// @throws When the transaction cannot be built — e.g. when the + /// configured state, metadata, or lock combination is rejected. #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { build_programmable_transaction(&self.0, client).await } - /// Applies transaction effects and events to this notarization creation operation. + /// Reads the on-chain effects and events of the submitted transaction + /// and returns the resulting {@link OnChainNotarization}. /// + /// @remarks + /// Invoked automatically by the {@link TransactionBuilder} machinery + /// after the transaction has been submitted; calling it directly is + /// normally not necessary. /// - /// This method is called automatically by Transaction::build_programmable_transaction() - /// and Transaction::apply() methods after the transaction has been successfully submitted - /// to process the results from the ledger. + /// @param effects - The transaction block effects produced on-chain. + /// @param events - The transaction block events produced on-chain. + /// @param client - A read-only client connected to the target network. /// - /// # Arguments - /// * `effects` - The transaction block effects to apply. - /// * `events` - The transaction block events to apply. + /// @returns The created {@link OnChainNotarization}. /// - /// # Returns - /// The created notarization ID if successful. + /// @throws When the effects/events are inconsistent with this transaction + /// or the result cannot be reconstructed. #[wasm_bindgen(js_name = applyWithEvents)] pub async fn apply_with_events( self, @@ -303,43 +314,60 @@ impl WasmCreateNotarizationDynamic { } } -/// Represents a transaction for updating the state of a dynamic notarization. +/// Transaction that replaces the state of a notarization. +/// +/// @remarks +/// On success the transaction increments `stateVersionCount` by one and +/// refreshes `lastStateChangeAt` to the on-chain clock timestamp. /// -/// This is only available for dynamic notarization's +/// Behavior depends on the Notarization Method: +/// * `Dynamic`: always permitted — the underlying `updateLock` is fixed to {@link TimeLockType.None}. +/// * `Locked`: always aborts on-chain, because the underlying `updateLock` is pinned to {@link +/// TimeLockType.UntilDestroyed}. +/// +/// Emits a `NotarizationUpdated` event on success. #[wasm_bindgen(js_name = UpdateState, inspectable)] pub struct WasmUpdateState(pub(crate) UpdateState); #[wasm_bindgen(js_class = UpdateState)] impl WasmUpdateState { + /// Constructs the transaction. + /// + /// @param state - The replacement {@link State}. + /// @param objectId - The notarization object's ID. + /// + /// @throws When the ID is malformed. #[wasm_bindgen(constructor)] pub fn new(state: WasmState, object_id: WasmObjectID) -> Result { let obj_id = parse_wasm_object_id(&object_id)?; Ok(WasmUpdateState(UpdateState::new(state.0, obj_id))) } - /// Builds and returns a programmable transaction for updating the state of a notarization. + /// Builds the programmable transaction bytes. /// - /// # Returns - /// The binary BCS serialization of the programmable transaction. - /// This transaction can be submitted to the network to updating the state of a notarization. + /// @param client - A read-only client connected to the target network. /// - /// # Errors - /// Returns an error if the transaction cannot be built due to invalid parameters - /// or other constraints. + /// @returns The BCS-serialized programmable transaction, ready to be + /// signed and submitted. + /// + /// @throws When the transaction cannot be built. #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { build_programmable_transaction(&self.0, client).await } - /// Applies transaction effects and events to this state update operation. + /// Reads the on-chain effects and events of the submitted transaction. + /// + /// @remarks + /// Invoked automatically by the {@link TransactionBuilder} machinery + /// after the transaction has been submitted; calling it directly is + /// normally not necessary. /// - /// This method is called automatically by Transaction::build_programmable_transaction() - /// and Transaction::apply() methods after the transaction has been successfully submitted - /// to process the results from the ledger. + /// @param effects - The transaction block effects produced on-chain. + /// @param events - The transaction block events produced on-chain. + /// @param client - A read-only client connected to the target network. /// - /// # Arguments - /// * `effects` - The transaction block effects to apply. - /// * `events` - The transaction block events to apply. + /// @throws When the effects/events are inconsistent with this transaction. #[wasm_bindgen(js_name = applyWithEvents)] pub async fn apply_with_events( self, @@ -351,41 +379,58 @@ impl WasmUpdateState { } } -/// Represents a transaction for updating the metadata of a notarization. +/// Transaction that replaces the updatable metadata of a notarization. +/// +/// @remarks +/// Does not affect the state, the `stateVersionCount`, the +/// `lastStateChangeAt` timestamp, or the immutable description. +/// +/// Behavior depends on the Notarization Method: +/// * `Dynamic`: always permitted — the underlying `updateLock` is fixed to {@link TimeLockType.None}. +/// * `Locked`: always aborts on-chain, because the underlying `updateLock` is pinned to {@link +/// TimeLockType.UntilDestroyed}. #[wasm_bindgen(js_name = UpdateMetadata, inspectable)] pub struct WasmUpdateMetadata(pub(crate) UpdateMetadata); #[wasm_bindgen(js_class = UpdateMetadata)] impl WasmUpdateMetadata { + /// Constructs the transaction. + /// + /// @param metadata - The replacement metadata, or `null` to clear it. + /// @param objectId - The notarization object's ID. + /// + /// @throws When the ID is malformed. #[wasm_bindgen(constructor)] pub fn new(metadata: Option, object_id: WasmObjectID) -> Result { let obj_id = parse_wasm_object_id(&object_id)?; Ok(WasmUpdateMetadata(UpdateMetadata::new(metadata, obj_id))) } - /// Builds and returns a programmable transaction for updating the metadata of a notarization. + /// Builds the programmable transaction bytes. /// - /// # Returns - /// The binary BCS serialization of the programmable transaction. - /// This transaction can be submitted to the network to update the metadata of a notarization. + /// @param client - A read-only client connected to the target network. /// - /// # Errors - /// Returns an error if the transaction cannot be built due to invalid parameters - /// or other constraints. + /// @returns The BCS-serialized programmable transaction, ready to be + /// signed and submitted. + /// + /// @throws When the transaction cannot be built. #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { build_programmable_transaction(&self.0, client).await } - /// Applies transaction effects and events to this metadata update operation. + /// Reads the on-chain effects and events of the submitted transaction. + /// + /// @remarks + /// Invoked automatically by the {@link TransactionBuilder} machinery + /// after the transaction has been submitted; calling it directly is + /// normally not necessary. /// - /// This method is called automatically by Transaction::build_programmable_transaction() - /// and Transaction::apply() methods after the transaction has been successfully submitted - /// to process the results from the ledger. + /// @param effects - The transaction block effects produced on-chain. + /// @param events - The transaction block events produced on-chain. + /// @param client - A read-only client connected to the target network. /// - /// # Arguments - /// * `effects` - The transaction block effects to apply. - /// * `events` - The transaction block events to apply. + /// @throws When the effects/events are inconsistent with this transaction. #[wasm_bindgen(js_name = applyWithEvents)] pub async fn apply_with_events( self, @@ -397,41 +442,56 @@ impl WasmUpdateMetadata { } } -/// Represents a transaction for deleting a notarization. +/// Transaction that destroys a notarization and releases its object ID. +/// +/// @remarks +/// The notarization must currently be destroy-allowed (see +/// {@link NotarizationClientReadOnly.isDestroyAllowed}); otherwise the +/// on-chain transaction aborts. All package-local {@link TimeLock}s of the +/// attached {@link LockMetadata} are destroyed in the process. +/// +/// Emits a `NotarizationDestroyed` event on success. #[wasm_bindgen(js_name = DestroyNotarization, inspectable)] pub struct WasmDestroyNotarization(pub(crate) DestroyNotarization); #[wasm_bindgen(js_class = DestroyNotarization)] impl WasmDestroyNotarization { + /// Constructs the transaction. + /// + /// @param objectId - The notarization object's ID. + /// + /// @throws When the ID is malformed. #[wasm_bindgen(constructor)] pub fn new(object_id: WasmObjectID) -> Result { let obj_id = parse_wasm_object_id(&object_id)?; Ok(WasmDestroyNotarization(DestroyNotarization::new(obj_id))) } - /// Builds and returns a programmable transaction for deleting a notarization. + /// Builds the programmable transaction bytes. + /// + /// @param client - A read-only client connected to the target network. /// - /// # Returns - /// The binary BCS serialization of the programmable transaction. - /// This transaction can be submitted to the network to delete a notarization. + /// @returns The BCS-serialized programmable transaction, ready to be + /// signed and submitted. /// - /// # Errors - /// Returns an error if the transaction cannot be built due to invalid parameters - /// or other constraints. + /// @throws When the transaction cannot be built. #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { build_programmable_transaction(&self.0, client).await } - /// Applies transaction effects and events to this notarization delete operation. + /// Reads the on-chain effects and events of the submitted transaction. + /// + /// @remarks + /// Invoked automatically by the {@link TransactionBuilder} machinery + /// after the transaction has been submitted; calling it directly is + /// normally not necessary. /// - /// This method is called automatically by Transaction::build_programmable_transaction() - /// and Transaction::apply() methods after the transaction has been successfully submitted - /// to process the results from the ledger. + /// @param effects - The transaction block effects produced on-chain. + /// @param events - The transaction block events produced on-chain. + /// @param client - A read-only client connected to the target network. /// - /// # Arguments - /// * `effects` - The transaction block effects to apply. - /// * `events` - The transaction block events to apply. + /// @throws When the effects/events are inconsistent with this transaction. #[wasm_bindgen(js_name = applyWithEvents)] pub async fn apply_with_events( self, @@ -443,14 +503,25 @@ impl WasmDestroyNotarization { } } -/// Represents a transaction for transferring a dynamic notarization to a new owner. +/// Transaction that transfers ownership of a Dynamic-Notarization. /// -/// This is only available for dynamic notarization's +/// @remarks +/// Permitted only when the notarization has no {@link LockMetadata} or when +/// its `transferLock` is not currently active. Submitting against a +/// Locked-Notarization or while the transfer lock is engaged aborts on-chain. +/// +/// Emits a `DynamicNotarizationTransferred` event on success. #[wasm_bindgen(js_name = TransferNotarization, inspectable)] pub struct WasmTransferNotarization(pub(crate) TransferNotarization); #[wasm_bindgen(js_class = TransferNotarization)] impl WasmTransferNotarization { + /// Constructs the transaction. + /// + /// @param recipient - The new owner's IOTA address. + /// @param objectId - The notarization object's ID. + /// + /// @throws When the ID or address is malformed. #[wasm_bindgen(constructor)] pub fn new(recipient: WasmIotaAddress, object_id: WasmObjectID) -> Result { let obj_id = parse_wasm_object_id(&object_id)?; @@ -461,29 +532,31 @@ impl WasmTransferNotarization { ))) } - /// Builds and returns a programmable transaction for transferring a notarization. + /// Builds the programmable transaction bytes. /// - /// # Returns - /// The binary BCS serialization of the programmable transaction. - /// This transaction can be submitted to the network to transfer a notarization. + /// @param client - A read-only client connected to the target network. /// - /// # Errors - /// Returns an error if the transaction cannot be built due to invalid parameters - /// or other constraints. + /// @returns The BCS-serialized programmable transaction, ready to be + /// signed and submitted. + /// + /// @throws When the transaction cannot be built. #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { build_programmable_transaction(&self.0, client).await } - /// Applies transaction effects and events to this transfer operation. + /// Reads the on-chain effects and events of the submitted transaction. + /// + /// @remarks + /// Invoked automatically by the {@link TransactionBuilder} machinery + /// after the transaction has been submitted; calling it directly is + /// normally not necessary. /// - /// This method is called automatically by Transaction::build_programmable_transaction() - /// and Transaction::apply() methods after the transaction has been successfully submitted - /// to process the results from the ledger. + /// @param effects - The transaction block effects produced on-chain. + /// @param events - The transaction block events produced on-chain. + /// @param client - A read-only client connected to the target network. /// - /// # Arguments - /// * `effects` - The transaction block effects to apply. - /// * `events` - The transaction block events to apply. + /// @throws When the effects/events are inconsistent with this transaction. #[wasm_bindgen(js_name = applyWithEvents)] pub async fn apply_with_events( self, diff --git a/bindings/wasm/notarization_wasm/src/wasm_notarization_builder.rs b/bindings/wasm/notarization_wasm/src/wasm_notarization_builder.rs index 1db4d0af..4608fae2 100644 --- a/bindings/wasm/notarization_wasm/src/wasm_notarization_builder.rs +++ b/bindings/wasm/notarization_wasm/src/wasm_notarization_builder.rs @@ -10,47 +10,58 @@ use wasm_bindgen::prelude::*; use crate::wasm_notarization::{WasmCreateNotarizationDynamic, WasmCreateNotarizationLocked}; use crate::wasm_time_lock::WasmTimeLock; -/// Represents a builder for constructing locked notarization transactions. +/// Builder for a "create Locked-Notarization" transaction. /// -/// Locked notarizations are immutable records (the notarization state) that cannot be modified -/// after creation. +/// @remarks +/// A Locked-Notarization is immutable after creation: its state and +/// updatable metadata are fixed for the lifetime of the object. Use this +/// builder to configure the initial state, immutable description, updatable +/// metadata, and optional `deleteLock`, then call +/// {@link NotarizationBuilderLocked.finish} to obtain a transaction builder. +/// +/// On execution the transaction transfers the new notarization object to +/// the sender. +/// +/// Emits a `LockedNotarizationCreated` event on success. #[wasm_bindgen(js_name = NotarizationBuilderLocked, inspectable)] pub struct WasmNotarizationBuilderLocked(pub(crate) NotarizationBuilder); -/// Converts a `NotarizationBuilder` into a `WasmNotarizationBuilderLocked`. impl From> for WasmNotarizationBuilderLocked { fn from(val: NotarizationBuilder) -> Self { WasmNotarizationBuilderLocked(val) } } -/// Provides methods for building locked notarization transactions. #[wasm_bindgen(js_class = NotarizationBuilderLocked)] impl WasmNotarizationBuilderLocked { - /// Adds a state to the notarization using binary data. + /// Sets the initial state from a binary payload. + /// + /// @param data - The bytes to notarize. + /// @param metadata - Optional metadata associated with this initial state. /// - /// # Arguments - /// * `data` - Binary data representing the state. - /// * `metadata` - Optional metadata associated with the state. + /// @returns The same builder, with the initial state configured. #[wasm_bindgen(js_name = withBytesState)] pub fn with_bytes_state(self, data: Uint8Array, metadata: Option) -> Self { self.0.with_bytes_state(data.to_vec(), metadata).into() } - /// Adds a state to the notarization using a string. + /// Sets the initial state from a text payload. /// - /// # Arguments - /// * `data` - String data representing the state. - /// * `metadata` - Optional metadata associated with the state. + /// @param data - The string to notarize. + /// @param metadata - Optional metadata associated with this initial state. + /// + /// @returns The same builder, with the initial state configured. #[wasm_bindgen(js_name = withStringState)] pub fn with_string_state(self, data: String, metadata: Option) -> Self { self.0.with_string_state(data, metadata).into() } - /// Adds an immutable description to the notarization. + /// Sets the immutable description. + /// + /// @param description - Human-readable description fixed at creation. Pass + /// `null` or `undefined` to leave the description unset. /// - /// # Arguments - /// * `description` - A string describing the notarization, or null to skip. + /// @returns The same builder, with the description configured. #[wasm_bindgen(js_name = withImmutableDescription)] pub fn with_immutable_description(self, description: Option) -> Self { match description { @@ -59,10 +70,17 @@ impl WasmNotarizationBuilderLocked { } } - /// Adds updatable metadata to the notarization. + /// Sets the updatable metadata. + /// + /// @remarks + /// On a Locked-Notarization the updatable metadata is fixed at creation + /// just like the state — there is no client method that can change it + /// afterwards. /// - /// # Arguments - /// * `metadata` - A string representing the metadata, or null to skip. + /// @param metadata - Updatable metadata string. Pass `null` or + /// `undefined` to leave it unset. + /// + /// @returns The same builder, with the updatable metadata configured. #[wasm_bindgen(js_name = withUpdatableMetadata)] pub fn with_updatable_metadata(self, metadata: Option) -> Self { match metadata { @@ -71,26 +89,36 @@ impl WasmNotarizationBuilderLocked { } } - /// Creates a new locked notarization builder. + /// Returns a fresh, unconfigured Locked-Notarization builder. + /// + /// @returns An empty {@link NotarizationBuilderLocked}. #[wasm_bindgen()] pub fn locked() -> Self { NotarizationBuilder::::locked().into() } - /// Adds a delete lock to the notarization. + /// Sets the delete lock for the notarization. + /// + /// @remarks + /// `deleteLock` cannot be {@link TimeLockType.UntilDestroyed} — submitting + /// such a configuration aborts on-chain. /// - /// # Arguments - /// * `lock` - A `TimeLock` specifying the delete lock. + /// @param lock - The {@link TimeLock} controlling when destruction is + /// permitted. + /// + /// @returns The same builder, with the delete lock configured. #[wasm_bindgen(js_name = withDeleteLock)] pub fn with_delete_lock(self, lock: WasmTimeLock) -> Self { self.0.with_delete_lock(lock.0).into() } - /// Finalizes the notarization builder and returns a transaction builder - /// that can be used to build and execute the final transaction on the ledger. + /// Finalizes the configuration and produces the transaction builder. + /// + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link CreateNotarizationLocked} transaction. /// - /// # Returns - /// A `TransactionBuilder` to build and execute the transaction. + /// @throws When the configured state, metadata, or lock combination is + /// invalid for a Locked-Notarization. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn finish(self) -> Result { let js_value: JsValue = WasmCreateNotarizationLocked::new(self).into(); @@ -98,9 +126,19 @@ impl WasmNotarizationBuilderLocked { } } -/// Represents a builder for constructing dynamic notarization transactions. +/// Builder for a "create Dynamic-Notarization" transaction. /// -/// Dynamic notarizations are updatable records that can evolve over time. +/// @remarks +/// A Dynamic-Notarization can be updated after creation: state and updatable +/// metadata can be replaced via {@link NotarizationClient.updateState} and +/// {@link NotarizationClient.updateMetadata}, and ownership can be +/// transferred via {@link NotarizationClient.transferNotarization} when the +/// configured `transferLock` permits it. +/// +/// On execution the transaction transfers the new notarization object to +/// the sender. +/// +/// Emits a `DynamicNotarizationCreated` event on success. #[wasm_bindgen(js_name = NotarizationBuilderDynamic)] pub struct WasmNotarizationBuilderDynamic(pub(crate) NotarizationBuilder); @@ -110,33 +148,36 @@ impl From> for WasmNotarizationBuilderDynamic { } } -/// Provides methods for building dynamic notarization transactions. #[wasm_bindgen(js_class = NotarizationBuilderDynamic)] impl WasmNotarizationBuilderDynamic { - /// Adds a state to the notarization using binary data. + /// Sets the initial state from a binary payload. + /// + /// @param data - The bytes to notarize. + /// @param metadata - Optional metadata associated with this initial state. /// - /// # Arguments - /// * `data` - Binary data representing the state. - /// * `metadata` - Optional metadata associated with the state. + /// @returns The same builder, with the initial state configured. #[wasm_bindgen(js_name = withBytesState)] pub fn with_bytes_state(self, data: Uint8Array, metadata: Option) -> Self { self.0.with_bytes_state(data.to_vec(), metadata).into() } - /// Adds a state to the notarization using a string. + /// Sets the initial state from a text payload. /// - /// # Arguments - /// * `data` - String data representing the state. - /// * `metadata` - Optional metadata associated with the state. + /// @param data - The string to notarize. + /// @param metadata - Optional metadata associated with this initial state. + /// + /// @returns The same builder, with the initial state configured. #[wasm_bindgen(js_name = withStringState)] pub fn with_string_state(self, data: String, metadata: Option) -> Self { self.0.with_string_state(data, metadata).into() } - /// Adds an immutable description to the notarization. + /// Sets the immutable description. + /// + /// @param description - Human-readable description fixed at creation. Pass + /// `null` or `undefined` to leave the description unset. /// - /// # Arguments - /// * `description` - A string describing the notarization, or null to skip. + /// @returns The same builder, with the description configured. #[wasm_bindgen(js_name = withImmutableDescription)] pub fn with_immutable_description(self, description: Option) -> Self { match description { @@ -145,10 +186,13 @@ impl WasmNotarizationBuilderDynamic { } } - /// Adds updatable metadata to the notarization. + /// Sets the initial updatable metadata. /// - /// # Arguments - /// * `metadata` - A string representing the metadata, or null to skip. + /// @param metadata - Updatable metadata string. Pass `null` or + /// `undefined` to leave it unset; it can still be updated later via + /// {@link NotarizationClient.updateMetadata}. + /// + /// @returns The same builder, with the updatable metadata configured. #[wasm_bindgen(js_name = withUpdatableMetadata)] pub fn with_updatable_metadata(self, metadata: Option) -> Self { match metadata { @@ -157,26 +201,38 @@ impl WasmNotarizationBuilderDynamic { } } - /// Creates a new dynamic notarization builder. + /// Returns a fresh, unconfigured Dynamic-Notarization builder. + /// + /// @returns An empty {@link NotarizationBuilderDynamic}. #[wasm_bindgen()] pub fn dynamic() -> Self { NotarizationBuilder::::dynamic().into() } - /// Adds a transfer lock to the notarization. + /// Sets the transfer lock for the notarization. + /// + /// @remarks + /// While the transfer lock is active, + /// {@link NotarizationClient.transferNotarization} aborts on-chain. When + /// the lock is {@link TimeLockType.None}, the resulting notarization + /// carries no {@link LockMetadata} and is freely transferable. /// - /// # Arguments - /// * `lock` - A `TimeLock` specifying the transfer lock. + /// @param lock - The {@link TimeLock} controlling when ownership can be + /// transferred. + /// + /// @returns The same builder, with the transfer lock configured. #[wasm_bindgen(js_name = withTransferLock)] pub fn with_transfer_lock(self, lock: WasmTimeLock) -> Self { self.0.with_transfer_lock(lock.0).into() } - /// Finalizes the notarization builder and returns a transaction builder - /// that can be used to build and execute the final transaction on the ledger. + /// Finalizes the configuration and produces the transaction builder. + /// + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link CreateNotarizationDynamic} transaction. /// - /// # Returns - /// A `TransactionBuilder` to build and execute the transaction. + /// @throws When the configured state, metadata, or lock combination is + /// invalid for a Dynamic-Notarization. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn finish(self) -> Result { let js_value: JsValue = WasmCreateNotarizationDynamic::new(self).into(); diff --git a/bindings/wasm/notarization_wasm/src/wasm_notarization_client.rs b/bindings/wasm/notarization_wasm/src/wasm_notarization_client.rs index 20efd4a2..2b93d826 100644 --- a/bindings/wasm/notarization_wasm/src/wasm_notarization_client.rs +++ b/bindings/wasm/notarization_wasm/src/wasm_notarization_client.rs @@ -17,25 +17,35 @@ use crate::wasm_notarization_builder::{WasmNotarizationBuilderDynamic, WasmNotar use crate::wasm_notarization_client_read_only::WasmNotarizationClientReadOnly; use crate::wasm_types::WasmState; -/// A client to interact with Notarization objects on the IOTA ledger. +/// Read-write client for creating and modifying notarizations on the IOTA +/// ledger. /// -/// This client is used for read and write operations. For read-only capabilities, -/// you can use {@link NotarizationClientReadOnly}, which does not require an account or signing capabilities. +/// @remarks +/// Wraps a {@link NotarizationClientReadOnly} together with a transaction +/// signer. Use the builder methods ({@link NotarizationClient.createDynamic}, +/// {@link NotarizationClient.createLocked}) to create new notarizations and +/// the mutation methods ({@link NotarizationClient.updateState}, +/// {@link NotarizationClient.updateMetadata}, +/// {@link NotarizationClient.destroy}, +/// {@link NotarizationClient.transferNotarization}) to operate on existing +/// ones. For pure read access, prefer {@link NotarizationClientReadOnly}. #[derive(Clone)] #[wasm_bindgen(js_name = NotarizationClient)] pub struct WasmNotarizationClient(pub(crate) NotarizationClient); -// builder related functions #[wasm_bindgen(js_class = NotarizationClient)] impl WasmNotarizationClient { - /// Creates a new notarization client. + /// Constructs a read-write client by attaching a signer to a read-only + /// client. /// - /// # Arguments - /// * `client` - A read-only notarization client. - /// * `signer` - A transaction signer for signing operations. + /// @param client - A {@link NotarizationClientReadOnly} connected to the + /// target network. + /// @param signer - A {@link TransactionSigner} responsible for signing + /// outgoing transactions. /// - /// # Returns - /// A `TransactionBuilder` to build and execute the transaction. + /// @returns A connected {@link NotarizationClient}. + /// + /// @throws When the signer's public key cannot be retrieved. #[wasm_bindgen(js_name = create)] pub async fn new( client: WasmNotarizationClientReadOnly, @@ -45,46 +55,34 @@ impl WasmNotarizationClient { Ok(WasmNotarizationClient(inner_client)) } - /// Retrieves the sender's public key. + /// The signer's public key. /// - /// # Returns - /// The sender's public key as `PublicKey`. + /// @throws When the signer fails to provide its public key. #[wasm_bindgen(js_name = senderPublicKey)] pub fn sender_public_key(&self) -> Result { self.0.sender_public_key().try_into() } - /// Retrieves the sender's address. - /// - /// # Returns - /// The sender's address as an `IotaAddress`. + /// The IOTA address transactions will be sent from. #[wasm_bindgen(js_name = senderAddress)] pub fn sender_address(&self) -> WasmIotaAddress { self.0.sender_address().to_string() } - /// Retrieves the network identifier. - /// - /// # Returns - /// The network identifier as a `string`. + /// The network identifier this client is connected to. #[wasm_bindgen(js_name = network)] pub fn network(&self) -> String { self.0.network().to_string() } - /// Retrieves the package ID. - /// - /// # Returns - /// The package ID as a `string`. + /// The notarization package ID this client is using. #[wasm_bindgen(js_name = packageId)] pub fn package_id(&self) -> String { self.0.package_id().to_string() } - /// Retrieves the package history. - /// - /// # Returns - /// An `Array` containing the package history. + /// The full history of notarization package IDs known on this network, + /// most recent first. #[wasm_bindgen(js_name = packageHistory)] pub fn package_history(&self) -> Vec { self.0 @@ -94,70 +92,86 @@ impl WasmNotarizationClient { .collect() } - /// Retrieves the IOTA client instance. + /// The TF-Components package ID for product_common compatibility. /// - /// # Returns - /// The `IotaClient` instance. + /// Notarization uses the package-local `timelock` module, so this is + /// always `undefined`. + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> Option { + None + } + + /// The underlying IOTA client used for ledger queries. #[wasm_bindgen(js_name = iotaClient)] pub fn iota_client(&self) -> WasmIotaClient { (**self.0).clone().into_inner() } - /// Retrieves the transaction signer. - /// - /// # Returns - /// The `TransactionSigner` instance. + /// The transaction signer attached to this client. #[wasm_bindgen] pub fn signer(&self) -> WasmTransactionSigner { self.0.signer().clone() } - /// Retrieves a read-only version of the notarization client. + /// Returns a read-only view of this client. /// - /// # Returns - /// A `NotarizationClientReadOnly` instance. + /// @returns A {@link NotarizationClientReadOnly} sharing the same network + /// connection. #[wasm_bindgen(js_name = readOnly)] pub fn read_only(&self) -> WasmNotarizationClientReadOnly { WasmNotarizationClientReadOnly((*self.0).clone()) } - /// Creates a notarization builder which can be used to create a dynamic notarization. + /// Starts building a Dynamic-Notarization. + /// + /// @remarks + /// On execution the resulting transaction transfers the new notarization + /// object to the sender. /// - /// # Returns - /// A `NotarizationBuilderDynamic` instance. + /// @returns A fresh {@link NotarizationBuilderDynamic}. + /// + /// Emits a `DynamicNotarizationCreated` event on success. #[wasm_bindgen(js_name = createDynamic)] pub fn create_dynamic(&self) -> WasmNotarizationBuilderDynamic { WasmNotarizationBuilderDynamic(self.0.create_dynamic_notarization()) } - /// Creates a notarization builder which can be used to create a locked notarization. + /// Starts building a Locked-Notarization. + /// + /// @remarks + /// On execution the resulting transaction transfers the new notarization + /// object to the sender. + /// + /// @returns A fresh {@link NotarizationBuilderLocked}. /// - /// # Returns - /// A `NotarizationBuilderLocked` instance. + /// Emits a `LockedNotarizationCreated` event on success. #[wasm_bindgen(js_name = createLocked)] pub fn create_locked(&self) -> WasmNotarizationBuilderLocked { WasmNotarizationBuilderLocked(self.0.create_locked_notarization()) } - /// Creates a transaction to update the `state` of a notarization. + /// Builds a transaction that replaces the state of a notarization. /// - /// **Important**: The `state` can only be updated depending on the used `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after notarization creation - /// - Locked: Immutable after notarization creation + /// @remarks + /// On success the on-chain transaction replaces `state` with `newState`, + /// increments `stateVersionCount` by `1`, and refreshes + /// `lastStateChangeAt` to the on-chain clock (in milliseconds since the + /// Unix epoch). /// - /// Using this function will: - /// - set the `state` to the `new_state` - /// - increase the `stateVersionCount` by 1 - /// - set the `lastStateChangeAt` timestamp to the current clock timestamp in milliseconds - /// - emits a `NotarizationUpdated` Move event in case of success - /// - fail if the notarization uses `NotarizationMethod` `Locked` + /// Behaviour depends on the Notarization Method: + /// * `Dynamic`: always permitted — the underlying `updateLock` is fixed to {@link TimeLockType.None}. + /// * `Locked`: always aborts on-chain, because the underlying `updateLock` is pinned to {@link + /// TimeLockType.UntilDestroyed}. /// - /// # Arguments - /// * `new_state` - The new state to replace the current one. - /// * `notarization_id` - The ID of the notarization object. + /// @param newState - The replacement {@link State}. + /// @param notarizationId - The notarization object's ID. /// - /// # Returns - /// A `TransactionBuilder` to build and execute the transaction. + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link UpdateState} transaction. + /// + /// @throws When the ID is malformed. + /// + /// Emits a `NotarizationUpdated` event on success. #[wasm_bindgen(js_name = updateState)] pub fn update_state(&self, new_state: WasmState, notarization_id: WasmObjectID) -> Result { let notarization_id = parse_wasm_object_id(¬arization_id)?; @@ -165,23 +179,25 @@ impl WasmNotarizationClient { Ok(into_transaction_builder(WasmUpdateState(tx))) } - /// Creates a transaction to update the metadata of a notarization. + /// Builds a transaction that replaces the updatable metadata of a + /// notarization. + /// + /// @remarks + /// Does not affect the `state`, `stateVersionCount`, + /// `lastStateChangeAt`, or the immutable description. /// - /// **Important**: The `updatableMetadata` can only be updated depending on the used - /// `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after notarization creation - /// - Locked: Immutable after notarization creation + /// Behaviour depends on the Notarization Method: + /// * `Dynamic`: always permitted — the underlying `updateLock` is fixed to {@link TimeLockType.None}. + /// * `Locked`: always aborts on-chain, because the underlying `updateLock` is pinned to {@link + /// TimeLockType.UntilDestroyed}. /// - /// NOTE: - /// - does not affect the `stateVersionCount` or the `lastStateChangeAt` timestamp - /// - will fail if the notarization uses the `NotarizationMethod::Locked` - /// - Only the `updatableMetadata` can be changed; the `immutableMetadata::description` remains fixed - /// # Arguments - /// * `metadata` - The new metadata to update (optional). - /// * `notarization_id` - The ID of the notarization object. + /// @param metadata - The replacement metadata, or `null` to clear it. + /// @param notarizationId - The notarization object's ID. /// - /// # Returns - /// A `TransactionBuilder` to build and execute the transaction. + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link UpdateMetadata} transaction. + /// + /// @throws When the ID is malformed. #[wasm_bindgen(js_name = updateMetadata)] pub fn update_metadata( &self, @@ -193,13 +209,24 @@ impl WasmNotarizationClient { Ok(into_transaction_builder(WasmUpdateMetadata(tx))) } - /// Creates a transaction to destroy a notarization object on the ledger. + /// Builds a transaction that destroys a notarization permanently and + /// releases its object ID. + /// + /// @remarks + /// All package-local {@link TimeLock}s of the attached {@link LockMetadata} + /// are destroyed in the process. The notarization must currently be + /// destroy-allowed (see + /// {@link NotarizationClientReadOnly.isDestroyAllowed}); otherwise the + /// on-chain transaction aborts. + /// + /// @param notarizationId - The notarization object's ID. /// - /// # Arguments - /// * `notarization_id` - The ID of the notarization object to destroy. + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link DestroyNotarization} transaction. /// - /// # Returns - /// A `TransactionBuilder` to build and execute the transaction. + /// @throws When the ID is malformed. + /// + /// Emits a `NotarizationDestroyed` event on success. #[wasm_bindgen(js_name = destroy)] pub fn destroy_notarization(&self, notarization_id: WasmObjectID) -> Result { let notarization_id = parse_wasm_object_id(¬arization_id)?; @@ -207,14 +234,28 @@ impl WasmNotarizationClient { Ok(into_transaction_builder(WasmDestroyNotarization(tx))) } - /// Creates a transaction to transfer a notarization object to a new owner. + /// Builds a transaction that transfers ownership of a notarization to + /// another address. + /// + /// @remarks + /// Permitted only when the notarization has no {@link LockMetadata} or + /// when its `transferLock` is not currently active. + /// + /// Behaviour depends on the Notarization Method: + /// * `Dynamic`: on success the notarization is transferred to `recipient`. Submitting while the configured + /// `transferLock` is currently engaged aborts on-chain. + /// * `Locked`: always aborts on-chain — Locked-Notarizations have their `transferLock` pinned to {@link + /// TimeLockType.UntilDestroyed} and are therefore non-transferable. + /// + /// @param notarizationId - The notarization object's ID. + /// @param recipient - The new owner's IOTA address. + /// + /// @returns A {@link TransactionBuilder} wrapping the + /// {@link TransferNotarization} transaction. /// - /// # Arguments - /// * `notarization_id` - The ID of the notarization object to transfer. - /// * `recipient` - The recipient's IOTA address. + /// @throws When the ID or address is malformed. /// - /// # Returns - /// A `TransactionBuilder` to build and execute the transaction. + /// Emits a `DynamicNotarizationTransferred` event on success. #[wasm_bindgen(js_name = transferNotarization)] pub fn transfer_notarization( &self, diff --git a/bindings/wasm/notarization_wasm/src/wasm_notarization_client_read_only.rs b/bindings/wasm/notarization_wasm/src/wasm_notarization_client_read_only.rs index dd6f838a..3b896b78 100644 --- a/bindings/wasm/notarization_wasm/src/wasm_notarization_client_read_only.rs +++ b/bindings/wasm/notarization_wasm/src/wasm_notarization_client_read_only.rs @@ -16,38 +16,48 @@ use wasm_bindgen::prelude::*; use crate::wasm_notarization::WasmOnChainNotarization; use crate::wasm_types::{WasmLockMetadata, WasmNotarizationMethod, WasmState}; -/// A client to interact with Notarization objects on the IOTA ledger. +/// Read-only client for inspecting notarization objects on the IOTA ledger. /// -/// This client is used for read-only operations, meaning it does not require an account -/// or signing capabilities. For write operations, use {@link NotarizationClient}. +/// @remarks +/// This client never signs or submits transactions; use {@link NotarizationClient} +/// for write operations. All accessor methods take a notarization object ID +/// and return the corresponding on-chain value. #[derive(Clone)] #[wasm_bindgen(js_name = NotarizationClientReadOnly)] pub struct WasmNotarizationClientReadOnly(pub(crate) NotarizationClientReadOnly); -// Builder-related functions #[wasm_bindgen(js_class = NotarizationClientReadOnly)] impl WasmNotarizationClientReadOnly { - /// Creates a new instance of `otarizationClientReadOnly`. + /// Constructs a read-only client and resolves the notarization package + /// for the network the given IOTA client is connected to. /// - /// # Arguments - /// * `iota_client` - The IOTA client used for interacting with the ledger. + /// @param iotaClient - An IOTA client connected to the target network. /// - /// # Returns - /// A new `NotarizationClientReadOnly` instance. + /// @returns A connected {@link NotarizationClientReadOnly}. + /// + /// @throws When the network cannot be queried or no notarization package + /// is available for it. #[wasm_bindgen(js_name = create)] pub async fn new(iota_client: WasmIotaClient) -> Result { let inner_client = NotarizationClientReadOnly::new(iota_client).await.map_err(wasm_error)?; Ok(WasmNotarizationClientReadOnly(inner_client)) } - /// Creates a new instance of `NotarizationClientReadOnly` using a specific package ID. + /// Constructs a read-only client pinned to a specific notarization + /// package ID. + /// + /// @remarks + /// Use this when you need to interact with a particular package version, + /// e.g. for replaying historical state, instead of letting the client + /// resolve the latest package on the network. + /// + /// @param iotaClient - An IOTA client connected to the target network. + /// @param iotaNotarizationPkgId - The notarization package ID to pin to. /// - /// # Arguments - /// * `iota_client` - The IOTA client used for interacting with the ledger. - /// * `iota_notarization_pkg_id` - The notarization package ID. + /// @returns A connected {@link NotarizationClientReadOnly}. /// - /// # Returns - /// A new `NotarizationClientReadOnly` instance. + /// @throws When the package ID cannot be parsed or the network cannot be + /// queried. #[wasm_bindgen(js_name = createWithPkgId)] pub async fn new_new_with_pkg_id( iota_client: WasmIotaClient, @@ -64,19 +74,14 @@ impl WasmNotarizationClientReadOnly { Ok(WasmNotarizationClientReadOnly(inner_client)) } - /// Retrieves the package ID of the used notarization package. - /// - /// # Returns - /// A string representing the package ID. + /// The notarization package ID this client is using. #[wasm_bindgen(js_name = packageId)] pub fn package_id(&self) -> String { self.0.package_id().to_string() } - /// Retrieves the history of notarization package IDs. - /// - /// # Returns - /// An array of strings representing the package history. + /// The full history of notarization package IDs known on this network, + /// most recent first. #[wasm_bindgen(js_name = packageHistory)] pub fn package_history(&self) -> Vec { self.0 @@ -86,42 +91,42 @@ impl WasmNotarizationClientReadOnly { .collect() } - /// Retrieves the underlying IOTA client used by this client. + /// The TF-Components package ID for product_common compatibility. /// - /// # Returns - /// The `IotaClient` instance. + /// Notarization uses the package-local `timelock` module, so this is + /// always `undefined`. + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> Option { + None + } + + /// The underlying IOTA client used for ledger queries. #[wasm_bindgen(js_name = iotaClient)] pub fn iota_client(&self) -> WasmIotaClient { (*self.0).clone().into_inner() } - /// Retrieves the network identifier associated with this client. - /// - /// # Returns - /// A string representing the network identifier. + /// The network identifier (e.g. `"mainnet"`, `"testnet"`) this client is + /// connected to. #[wasm_bindgen] pub fn network(&self) -> String { self.0.network().to_string() } - /// Retrieves the chain ID associated with this client. - /// - /// # Returns - /// A string representing the chain ID. + /// The chain ID this client is connected to. #[wasm_bindgen(js_name = chainId)] pub fn chain_id(&self) -> String { self.0.chain_id().to_string() } - /// Retrieves the [`OnChainNotarization`] of a notarized object. + /// Fetches the on-chain representation of a notarization. /// - /// This method returns the on-chain notarization object for the given object ID. + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Arguments - /// * `notarized_object_id` - The ID of the notarization object. + /// @returns The {@link OnChainNotarization} for the given ID. /// - /// # Returns - /// The [`OnChainNotarization`] object for the given object ID. + /// @throws When the ID is malformed or no notarization with that ID + /// exists on the connected network. #[wasm_bindgen(js_name = getNotarizationById)] pub async fn get_notarization_by_id(&self, notarized_object_id: WasmObjectID) -> Result { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -133,13 +138,13 @@ impl WasmNotarizationClientReadOnly { .map(Into::into) } - /// Retrieves the timestamp of the last state change for a notarization. + /// Fetches the timestamp of the most recent state change. /// - /// # Arguments - /// * `notarized_object_id` - The ID of the notarization object. + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Returns - /// The timestamp as `number` value representing the seconds since the Unix epoch. + /// @returns Milliseconds since the Unix epoch. + /// + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen(js_name = lastStateChangeTs)] pub async fn last_state_change_ts(&self, notarized_object_id: WasmObjectID) -> Result { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -150,13 +155,13 @@ impl WasmNotarizationClientReadOnly { .wasm_result() } - /// Retrieves the creation timestamp for a notarization. + /// Fetches the creation timestamp. + /// + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Arguments - /// * `notarized_object_id` - The ID of the notarization object. + /// @returns Milliseconds since the Unix epoch. /// - /// # Returns - /// The timestamp as `number` value representing the seconds since the Unix epoch. + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen(js_name = createdAtTs)] pub async fn created_at_ts(&self, notarized_object_id: WasmObjectID) -> Result { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -167,13 +172,14 @@ impl WasmNotarizationClientReadOnly { .wasm_result() } - /// Retrieves the count of state versions for a notarization. + /// Fetches the number of state versions a notarization has gone through. + /// + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Arguments - /// * `notarized_object_id` - The ID of a notarization object. + /// @returns The count, where `0` means the state has not been updated + /// since creation. /// - /// # Returns - /// Count as `number` value. + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen(js_name = stateVersionCount)] pub async fn state_version_count(&self, notarized_object_id: WasmObjectID) -> Result { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -184,13 +190,13 @@ impl WasmNotarizationClientReadOnly { .wasm_result() } - /// Retrieves the description of a notarization. + /// Fetches the immutable description set at creation, if any. /// - /// # Arguments - /// * `notarized_object_id` - The ID of a notarization object. + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Returns - /// A description string, if existing. + /// @returns The description string, or `null` when none was set. + /// + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen] pub async fn description(&self, notarized_object_id: WasmObjectID) -> Result> { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -201,13 +207,13 @@ impl WasmNotarizationClientReadOnly { .wasm_result() } - /// Retrieves the updatable metadata of a notarization. + /// Fetches the current updatable metadata, if any. + /// + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Arguments - /// * `notarized_object_id` - The ID of a notarization object. + /// @returns The metadata string, or `null` when none is set. /// - /// # Returns - /// A metadata string, if existing. + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen(js_name = updatableMetadata)] pub async fn updatable_metadata(&self, notarized_object_id: WasmObjectID) -> Result> { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -218,13 +224,13 @@ impl WasmNotarizationClientReadOnly { .wasm_result() } - /// Retrieves the notarization method of a notarization. + /// Fetches the {@link NotarizationMethod} of a notarization. + /// + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Arguments - /// * `notarized_object_id` - The ID of a notarization object. + /// @returns The notarization's {@link NotarizationMethod}. /// - /// # Returns - /// The `NotarizationMethod`. + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen(js_name = notarizationMethod)] pub async fn notarization_method(&self, notarized_object_id: WasmObjectID) -> Result { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -237,13 +243,13 @@ impl WasmNotarizationClientReadOnly { Ok(notarization_method) } - /// Retrieves the lock metadata of a notarization. + /// Fetches the {@link LockMetadata} attached at creation, if any. /// - /// # Arguments - /// * `notarized_object_id` - The ID of a notarization object. + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Returns - /// The `LockMetadata`, if existing. + /// @returns The {@link LockMetadata}, or `null` when none is attached. + /// + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen(js_name = lockMetadata)] pub async fn lock_metadata(&self, notarized_object_id: WasmObjectID) -> Result> { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -256,13 +262,13 @@ impl WasmNotarizationClientReadOnly { Ok(lock_metadata) } - /// Retrieves the state of a notarization. + /// Fetches the current {@link State} of a notarization. + /// + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Arguments - /// * `notarized_object_id` - The ID of a notarization object. + /// @returns The current {@link State}. /// - /// # Returns - /// The notarization `State`. + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen] pub async fn state(&self, notarized_object_id: WasmObjectID) -> Result { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -270,14 +276,19 @@ impl WasmNotarizationClientReadOnly { Ok(state) } - /// Checks if updates are locked for a notarization object. + /// Checks whether state updates are currently locked. + /// + /// @remarks + /// Result depends on the Notarization Method: + /// * `Dynamic`: always `false`. + /// * `Locked`: `true` while the configured `updateLock` is engaged. /// - /// # Arguments - /// * `notarized_object_id` - The ID of a notarization object. + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Returns - /// A boolean indicating whether updates are locked. - /// False means that updates are allowed. + /// @returns `true` if state updates are currently rejected, `false` + /// otherwise. + /// + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen(js_name = isUpdateLocked)] pub async fn is_update_locked(&self, notarized_object_id: WasmObjectID) -> Result { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -288,14 +299,21 @@ impl WasmNotarizationClientReadOnly { .wasm_result() } - /// Checks if destruction is allowed for a notarization object. + /// Checks whether the notarization can currently be destroyed. + /// + /// @remarks + /// Behaviour depends on the Notarization Method: + /// * `Dynamic`: destruction is gated only on the `transferLock`. The notarization is destroy-allowed unless + /// `transferLock` is currently `UnlockAt`-locked. + /// * `Locked`: destruction is gated on `updateLock`, `deleteLock`, and `transferLock`. The notarization is + /// destroy-allowed only when none of them is currently `UnlockAt`-locked. + /// + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Arguments - /// * `notarized_object_id` - The ID of a notarization object. + /// @returns `true` if {@link NotarizationClient.destroy} would currently + /// succeed, `false` otherwise. /// - /// # Returns - /// A boolean indicating whether destruction is allowed. - /// False means that destroying is not allowed. + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen(js_name = isDestroyAllowed)] pub async fn is_destroy_allowed(&self, notarized_object_id: WasmObjectID) -> Result { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; @@ -306,14 +324,19 @@ impl WasmNotarizationClientReadOnly { .wasm_result() } - /// Checks if transferring a notarization object is allowed. + /// Checks whether ownership transfers are currently locked. + /// + /// @remarks + /// Result depends on the Notarization Method: + /// * `Dynamic`: `true` when the configured `transferLock` is engaged. + /// * `Locked`: always `true` — Locked-Notarizations are non-transferable by design. + /// + /// @param notarizedObjectId - The notarization object's ID. /// - /// # Arguments - /// * `notarized_object_id` - The ID of a notarization object. + /// @returns `true` if {@link NotarizationClient.transferNotarization} + /// would currently abort, `false` otherwise. /// - /// # Returns - /// A boolean indicating whether transfers are locked. - /// False means that transferring is allowed. + /// @throws When the ID is malformed or the object cannot be fetched. #[wasm_bindgen(js_name = isTransferLocked)] pub async fn is_transfer_locked(&self, notarized_object_id: WasmObjectID) -> Result { let notarized_object_id = parse_wasm_object_id(¬arized_object_id)?; diff --git a/bindings/wasm/notarization_wasm/src/wasm_time_lock.rs b/bindings/wasm/notarization_wasm/src/wasm_time_lock.rs index 9455bd90..9cc85df9 100644 --- a/bindings/wasm/notarization_wasm/src/wasm_time_lock.rs +++ b/bindings/wasm/notarization_wasm/src/wasm_time_lock.rs @@ -5,63 +5,67 @@ use notarization::core::types::TimeLock; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; -/// Represents the type of a time lock. +/// Discriminator for the variants of {@link TimeLock}. /// -/// This enum defines the possible types of time locks that can be applied to a notarization object. -/// - `None`: No time lock is applied. -/// - `UnlockAt`: The object will unlock at a specific timestamp. -/// - `UntilDestroyed`: The object remains locked until it is destroyed. Can not be used for `delete_lock`. +/// @remarks +/// Returned by the {@link TimeLock.type} getter so callers can branch on the +/// kind of lock without inspecting its arguments. #[wasm_bindgen(js_name = TimeLockType)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WasmTimeLockType { + /// No lock is applied. None = "None", + /// Unlocks at a specific timestamp expressed in seconds since the Unix epoch. UnlockAt = "UnlockAt", + /// Stays locked until the notarization is destroyed. + /// Cannot be used for the `deleteLock` field of {@link LockMetadata}. UntilDestroyed = "UntilDestroyed", } -/// Represents a time lock configuration. +/// A time-based lock applied to one of the lock fields of a notarization. /// -/// It allows the creation and inspection of time lock configurations for notarization objects. +/// @remarks +/// Construct one with the static factory methods ({@link TimeLock.withUnlockAt}, +/// {@link TimeLock.withUntilDestroyed}, {@link TimeLock.withNone}) and inspect it via +/// the {@link TimeLock.type} and {@link TimeLock.args} getters. #[wasm_bindgen(js_name = TimeLock, inspectable)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WasmTimeLock(pub(crate) TimeLock); #[wasm_bindgen(js_class = TimeLock)] impl WasmTimeLock { - /// Creates a time lock that unlocks at a specific timestamp. + /// Creates a lock that releases at a specific timestamp in seconds. /// - /// # Arguments - /// * `time` - The timestamp in seconds since the Unix epoch at which the object will unlock. + /// @param timeSec - Unlock time, in seconds since the Unix epoch. /// - /// # Returns - /// A new `TimeLock` instance configured to unlock at the specified timestamp. + /// @returns A {@link TimeLock} of type {@link TimeLockType.UnlockAt}. #[wasm_bindgen(js_name = withUnlockAt)] - pub fn with_unlock_at(time: u32) -> Self { - Self(TimeLock::UnlockAt(time)) + pub fn with_unlock_at(time_sec: u32) -> Self { + Self(TimeLock::UnlockAt(time_sec)) } - /// Creates a time lock that remains locked until the object is destroyed. + /// Creates a lock that stays engaged until the notarization is destroyed. /// - /// # Returns - /// A new `TimeLock` instance configured to remain locked until destruction. + /// @remarks + /// This variant is not valid for the `deleteLock` field of + /// {@link LockMetadata} — using it there causes the on-chain transaction + /// to abort. + /// + /// @returns A {@link TimeLock} of type {@link TimeLockType.UntilDestroyed}. #[wasm_bindgen(js_name = withUntilDestroyed)] pub fn with_until_destroyed() -> Self { Self(TimeLock::UntilDestroyed) } - /// Creates a time lock with no restrictions. + /// Creates an absent lock — semantically "no restriction". /// - /// # Returns - /// A new `TimeLock` instance with no time lock applied. + /// @returns A {@link TimeLock} of type {@link TimeLockType.None}. #[wasm_bindgen(js_name = withNone)] pub fn with_none() -> Self { Self(TimeLock::None) } - /// Retrieves the type of the time lock. - /// - /// # Returns - /// The `TimeLockType` representing the type of the time lock. + /// The discriminator for which kind of lock this is. #[wasm_bindgen(js_name = "type", getter)] pub fn lock_type(&self) -> WasmTimeLockType { match &self.0 { @@ -71,12 +75,10 @@ impl WasmTimeLock { } } - /// Retrieves the arguments associated with the time lock. + /// The argument carried by the lock variant, if any. /// - /// # Returns - /// An `any` value containing the arguments for the time lock: - /// - For `UnlockAt`, the timestamp is returned. - /// - For other types, `undefined` is returned. + /// @returns The unlock timestamp (`number`) for `UnlockAt` (seconds); + /// `undefined` for `None` and `UntilDestroyed`. #[wasm_bindgen(js_name = "args", getter)] pub fn args(&self) -> JsValue { match &self.0 { diff --git a/bindings/wasm/notarization_wasm/src/wasm_types.rs b/bindings/wasm/notarization_wasm/src/wasm_types.rs index d1cda34f..f9b2bd5b 100644 --- a/bindings/wasm/notarization_wasm/src/wasm_types.rs +++ b/bindings/wasm/notarization_wasm/src/wasm_types.rs @@ -8,6 +8,8 @@ use wasm_bindgen::prelude::*; use crate::wasm_time_lock::WasmTimeLock; +/// An empty placeholder value returned by transaction-apply methods that have +/// no observable result. #[wasm_bindgen(js_name = Empty, inspectable)] pub struct WasmEmpty; @@ -17,16 +19,19 @@ impl From<()> for WasmEmpty { } } -/// Represents the different types of data that can be notarized. +/// A typed payload that can be notarized — either binary or text. +/// +/// @remarks +/// `Data` is the inner payload of a {@link State}. Inspect its kind via +/// {@link Data.valueType} and pull the value out as bytes or text via +/// {@link Data.toBytes} or {@link Data.toString}. #[wasm_bindgen(js_name = Data, inspectable)] pub struct WasmData(pub(crate) Data); #[wasm_bindgen(js_class = Data)] impl WasmData { - /// Retrieves the value of the data as a `any`. - /// - /// # Returns - /// A `any` containing the data, either as bytes or text. + /// The raw value as either a `Uint8Array` (for binary payloads) or a + /// `string` (for text payloads). #[wasm_bindgen(getter)] pub fn value(&self) -> JsValue { match &self.0 { @@ -35,11 +40,8 @@ impl WasmData { } } - /// Retrieves the type of the value as `String`. - /// - /// # Returns: - /// * `Uint8Array` for binary values - /// * `String` for string values + /// The runtime type of {@link Data.value} as a string: `"Uint8Array"` for + /// binary payloads, `"String"` for text payloads. #[wasm_bindgen(getter, js_name = valueType)] pub fn value_type(&self) -> String { match &self.0 { @@ -48,11 +50,11 @@ impl WasmData { } } - /// Retrieves the byte size of the value. + /// The size of the payload in bytes. /// - /// # Returns: - /// * For `Uint8Array` values: The number of bytes in the Uint8Array - /// * For `String` values: The length of the String, in bytes + /// @remarks + /// For binary payloads this is the length of the underlying `Uint8Array`; + /// for text payloads this is the UTF-8 byte length of the string. #[wasm_bindgen(getter, js_name = valueByteSize)] pub fn value_byte_size(&self) -> usize { match &self.0 { @@ -61,10 +63,11 @@ impl WasmData { } } - /// Converts the data to a string representation. + /// Returns the payload as a string. /// - /// # Returns - /// A `String` containing the text representation of the data. + /// @remarks + /// For binary payloads the bytes are interpreted as UTF-8; invalid byte + /// sequences are replaced with the Unicode replacement character. #[wasm_bindgen(js_name = toString)] pub fn to_string(&self) -> String { match &self.0 { @@ -73,10 +76,10 @@ impl WasmData { } } - /// Converts the data to a byte array. + /// Returns the payload as a byte array. /// - /// # Returns - /// A `Uint8Array` containing the byte representation of the data. + /// @remarks + /// For text payloads the string is encoded as UTF-8. #[wasm_bindgen(js_name = toBytes)] pub fn to_bytes(&self) -> Vec { match &self.0 { @@ -98,67 +101,55 @@ impl From for Data { } } -/// Represents the state of a notarization. +/// The mutable content of a notarization — payload plus optional metadata. /// -/// State encapsulates the data being notarized along with optional metadata. -/// It serves as the primary content container for both locked and dynamic -/// notarizations. +/// @remarks +/// `State` pairs the notarized {@link Data} with an optional metadata string. +/// It is the primary content container of every notarization regardless of +/// the configured {@link NotarizationMethod}. /// -/// The notarization `State` can be updated by the owner depending on the used `NotarizationMethod`: -/// - Dynamic: `data` and `metadata` of the `State` can be updated anytime after creation -/// - Locked: The `State` is immutable after notarization creation +/// Mutability depends on the Notarization Method: +/// * `Dynamic`: `data` and `metadata` can be replaced after creation via {@link NotarizationClient.updateState}. +/// * `Locked`: the state is fixed at creation and cannot be replaced. /// -/// `State` `data` and `metadata` can only be updated at once, using method `NotarizationClient::updateState()` -/// which will increase the `stateVersionCount` and update the `lastStateChangeAt` -/// timestamp of the notarization even if only the `metadata` are altered. +/// `data` and `metadata` can only be replaced together, in a single +/// {@link NotarizationClient.updateState} call. Every such replacement +/// increments the underlying notarization's `stateVersionCount` and updates +/// its `lastStateChangeAt` timestamp, even when only the `metadata` changes. #[wasm_bindgen(js_name = State, inspectable)] pub struct WasmState(pub(crate) State); #[wasm_bindgen(js_class = State)] impl WasmState { - /// Retrieves the data associated with the state. - /// - /// # Returns - /// A `Data` instance containing the state data. + /// The notarized payload. #[wasm_bindgen(getter)] pub fn data(&self) -> WasmData { self.0.data.clone().into() } - /// Retrieves the metadata associated with the state. - /// - /// # Returns - /// A `string` containing the metadata, if existing. + /// The optional metadata associated with the current state version. #[wasm_bindgen(getter)] pub fn metadata(&self) -> Option { self.0.metadata.clone() } - /// Creates a new state from a string. - /// - /// Use this for text data like documents, JSON, or configuration. + /// Builds a state from a text payload. /// - /// # Arguments - /// * `data` - The string data for the state. - /// * `metadata` - Optional metadata for the state. + /// @param data - The string payload to notarize. + /// @param metadata - Optional metadata associated with this state version. /// - /// # Returns - /// A new `State` instance. + /// @returns A {@link State} carrying the given text payload. #[wasm_bindgen(js_name = fromString)] pub fn from_string(data: String, metadata: Option) -> Self { WasmState(State::from_string(data, metadata)) } - /// Creates a new state from raw bytes. + /// Builds a state from a binary payload. /// - /// Use this for binary data like files, images, or serialized content. + /// @param data - The bytes to notarize. + /// @param metadata - Optional metadata associated with this state version. /// - /// # Arguments - /// * `data` - The byte array data for the state. - /// * `metadata` - Optional metadata for the state. - /// - /// # Returns - /// A new `State` instance. + /// @returns A {@link State} carrying the given binary payload. #[wasm_bindgen(js_name = fromBytes)] pub fn from_bytes(data: Uint8Array, metadata: Option) -> Self { WasmState(State::from_bytes(data.to_vec(), metadata)) @@ -177,17 +168,38 @@ impl From for State { } } -/// Represents the lock metadata of a notarization. +/// Time-based access restrictions attached to a notarization at creation. +/// +/// @remarks +/// `deleteLock` cannot be {@link TimeLockType.UntilDestroyed}, and its +/// unlock time must be no earlier than the unlock times of `updateLock` and +/// `transferLock` — on-chain creation aborts otherwise. +/// +/// Permitted lock configurations depend on the {@link NotarizationMethod}: +/// * `Dynamic`: `updateLock` is fixed to {@link TimeLockType.None}; `transferLock` may carry any {@link TimeLock} +/// variant. +/// * `Locked`: both `updateLock` and `transferLock` are fixed to {@link TimeLockType.UntilDestroyed} — +/// Locked-Notarizations are non-transferable and their state is immutable. #[wasm_bindgen(js_name = LockMetadata, getter_with_clone, inspectable)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WasmLockMetadata { - /// The update lock configuration. + /// Lock gating state and metadata updates. + /// + /// Value depends on the Notarization Method: + /// * `Dynamic`: fixed to {@link TimeLockType.None} — state and updatable metadata are always replaceable via + /// {@link NotarizationClient.updateState} and {@link NotarizationClient.updateMetadata}. + /// * `Locked`: fixed to {@link TimeLockType.UntilDestroyed}. #[wasm_bindgen(js_name = updateLock)] pub update_lock: WasmTimeLock, - /// The delete lock configuration. + /// Lock gating destruction. Cannot be {@link TimeLockType.UntilDestroyed}; + /// its unlock time must be ≥ both other locks' unlock times. #[wasm_bindgen(js_name = deleteLock)] pub delete_lock: WasmTimeLock, - /// The transfer lock configuration. + /// Lock gating ownership transfer. + /// + /// Value depends on the Notarization Method: + /// * `Dynamic`: any {@link TimeLock} variant — controls when ownership transfer is permitted. + /// * `Locked`: fixed to {@link TimeLockType.UntilDestroyed} — Locked-Notarizations are non-transferable. #[wasm_bindgen(js_name = transferLock)] pub transfer_lock: WasmTimeLock, } @@ -208,49 +220,61 @@ impl From for LockMetadata { } } -/// Represents immutable metadata of a notarization. +/// The fixed-at-creation metadata of a notarization. +/// +/// @remarks +/// Captures the values that cannot change after the notarization exists: +/// creation timestamp, optional human-readable description, and the optional +/// {@link LockMetadata}. #[wasm_bindgen(js_name = ImmutableMetadata, inspectable)] pub struct WasmImmutableMetadata(pub(crate) ImmutableMetadata); #[wasm_bindgen(js_class = ImmutableMetadata)] impl WasmImmutableMetadata { - /// Retrieves the timestamp when the notarization was created. - /// - /// # Returns - /// The timestamp as `number` value representing the seconds since the Unix epoch. + /// The creation timestamp, in milliseconds since the Unix epoch. #[wasm_bindgen(js_name = createdAt, getter)] pub fn created_at(&self) -> u64 { self.0.created_at } - /// Retrieves the description of the notarization. - /// - /// # Returns - /// A description `string`, if existing. + /// The optional human-readable description set at creation, if any. #[wasm_bindgen(getter)] pub fn description(&self) -> Option { self.0.description.clone() } - /// Retrieves the optional lock metadata for the notarization. + /// The optional {@link LockMetadata} attached at creation. /// - /// # Returns - /// A `LockMetadata` instance, if existing. + /// @remarks + /// Presence depends on the Notarization Method: + /// * `Dynamic`: absent when the Dynamic-Notarization carries no transfer lock; present otherwise. + /// * `Locked`: always present. + /// + /// @returns The {@link LockMetadata}, or `null` when none is attached. #[wasm_bindgen(getter)] pub fn locking(&self) -> Option { self.0.locking.clone().map(|l| l.into()) } } -/// Represents the notarization method of a notarization object. +/// Identifies the Notarization Method a notarization was created with. +/// +/// @remarks +/// Returned by {@link OnChainNotarization.method} and +/// {@link NotarizationClientReadOnly.notarizationMethod}. The Notarization +/// Method is fixed at creation and determines which operations are permitted +/// on the notarization afterwards. /// -/// This enum defines the possible methods for a notarization: -/// - `Dynamic`: Dynamic notarization. -/// - `Locked`: Locked notarization. +/// The set of Notarization Methods is closed in the current version of the +/// package but may be extended in future versions. #[wasm_bindgen(js_name = NotarizationMethod)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WasmNotarizationMethod { + /// Method whose state and updatable metadata can be updated after + /// creation and which may optionally be transfer-locked. Dynamic = "Dynamic", + /// Method whose state and updatable metadata are immutable after + /// creation and whose destruction is gated by a `deleteLock`. Locked = "Locked", } diff --git a/bindings/wasm/typedoc.json b/bindings/wasm/typedoc.json index fb75ecc3..ea045629 100644 --- a/bindings/wasm/typedoc.json +++ b/bindings/wasm/typedoc.json @@ -5,6 +5,11 @@ "excludeInternal": true, "excludeNotDocumented": true, "excludeExternals": true, + "externalSymbolLinkMappings": { + "@iota/iota-interaction-ts": { + "TransactionBuilder": "#" + } + }, "entryPointStrategy": "expand", "plugin": [ "typedoc-plugin-markdown" diff --git a/dprint.json b/dprint.json index e5444f58..5eee3a23 100644 --- a/dprint.json +++ b/dprint.json @@ -14,7 +14,8 @@ "excludes": [ "**/*-lock.json", "**/{node_modules, target}", - "bindings/wasm/notarization_wasm/{node,web}/**/*.{js,ts}" + "bindings/wasm/notarization_wasm/{node,web}/**/*.{js,ts}", + "bindings/wasm/audit_trail_wasm/{node,web}/**/*.{js,ts}" ], "plugins": [ "https://plugins.dprint.dev/markdown-0.18.0.wasm", diff --git a/examples/01_create_locked_notarization.rs b/examples/01_create_locked_notarization.rs index e3280550..ab028978 100644 --- a/examples/01_create_locked_notarization.rs +++ b/examples/01_create_locked_notarization.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{NotarizationMethod, OnChainNotarization, State, TimeLock}; use product_common::transaction::TransactionOutput; @@ -13,7 +13,7 @@ async fn main() -> Result<()> { println!("Creating a locked notarization example"); // Create a notarization client - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Calculate unlock time (24 hours from now) let now_ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); diff --git a/examples/02_create_dynamic_notarization.rs b/examples/02_create_dynamic_notarization.rs index 9bb608df..2c03fab2 100644 --- a/examples/02_create_dynamic_notarization.rs +++ b/examples/02_create_dynamic_notarization.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{NotarizationMethod, OnChainNotarization, State, TimeLock}; use product_common::transaction::TransactionOutput; @@ -13,7 +13,7 @@ async fn main() -> Result<()> { println!("Creating a dynamic notarization example"); // Create a notarization client - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; println!("Creating a simple dynamic notarization without locks..."); diff --git a/examples/03_update_dynamic_notarization.rs b/examples/03_update_dynamic_notarization.rs index f7b9dc1d..47fe2792 100644 --- a/examples/03_update_dynamic_notarization.rs +++ b/examples/03_update_dynamic_notarization.rs @@ -2,14 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::State; #[tokio::main] async fn main() -> Result<()> { println!("Demonstrating update on dynamic notarization"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; println!("Creating a dynamic notarization..."); diff --git a/examples/04_destroy_notarization.rs b/examples/04_destroy_notarization.rs index db404144..d023e63e 100644 --- a/examples/04_destroy_notarization.rs +++ b/examples/04_destroy_notarization.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{State, TimeLock}; #[tokio::main] @@ -12,7 +12,7 @@ async fn main() -> Result<()> { println!("Demonstrating notarization destruction scenarios"); // Create a notarization client - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Scenario 1: Destroy an unlocked dynamic notarization (should succeed) println!("📝 Scenario 1: Creating and destroying an unlocked dynamic notarization..."); diff --git a/examples/05_update_state.rs b/examples/05_update_state.rs index 406eb5fd..731df426 100644 --- a/examples/05_update_state.rs +++ b/examples/05_update_state.rs @@ -2,14 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::State; #[tokio::main] async fn main() -> Result<()> { println!("Demonstrating state updates on dynamic notarization"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; println!("Creating a dynamic notarization for state updates..."); diff --git a/examples/06_update_metadata.rs b/examples/06_update_metadata.rs index 8bbf47cc..1ed5984b 100644 --- a/examples/06_update_metadata.rs +++ b/examples/06_update_metadata.rs @@ -2,14 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::State; #[tokio::main] async fn main() -> Result<()> { println!("Demonstrating metadata updates on dynamic notarization"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; println!("Creating a dynamic notarization for metadata updates..."); diff --git a/examples/07_transfer_dynamic_notarization.rs b/examples/07_transfer_dynamic_notarization.rs index 444290f2..7c7ed2cb 100644 --- a/examples/07_transfer_dynamic_notarization.rs +++ b/examples/07_transfer_dynamic_notarization.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use iota_sdk::types::base_types::IotaAddress; use notarization::core::types::{State, TimeLock}; @@ -12,7 +12,7 @@ use notarization::core::types::{State, TimeLock}; async fn main() -> Result<()> { println!("Demonstrating notarization transfer scenarios"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Generate random addresses for transfer recipients let alice = IotaAddress::random(); diff --git a/examples/08_access_read_only_methods.rs b/examples/08_access_read_only_methods.rs index f42ad05c..a2d89a85 100644 --- a/examples/08_access_read_only_methods.rs +++ b/examples/08_access_read_only_methods.rs @@ -4,14 +4,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{State, TimeLock}; #[tokio::main] async fn main() -> Result<()> { println!("Demonstrating read-only methods for notarization inspection"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Create a comprehensive dynamic notarization for testing println!("Creating a dynamic notarization with comprehensive metadata..."); diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 14dbf6ed..cf805490 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -48,8 +48,65 @@ path = "real-world/01_iot_weather_station.rs" name = "02_legal_contract" path = "real-world/02_legal_contract.rs" +[[example]] +name = "01_create_audit_trail" +path = "audit-trail/01_create_audit_trail.rs" + +[[example]] +name = "02_add_and_read_records" +path = "audit-trail/02_add_and_read_records.rs" + +[[example]] +name = "03_update_metadata" +path = "audit-trail/03_update_metadata.rs" + +[[example]] +name = "04_configure_locking" +path = "audit-trail/04_configure_locking.rs" + +[[example]] +name = "05_manage_access" +path = "audit-trail/05_manage_access.rs" + +[[example]] +name = "06_delete_records" +path = "audit-trail/06_delete_records.rs" + +[[example]] +name = "07_access_read_only_methods" +path = "audit-trail/07_access_read_only_methods.rs" + +[[example]] +name = "08_delete_audit_trail" +path = "audit-trail/08_delete_audit_trail.rs" + +[[example]] +name = "09_tagged_records" +path = "audit-trail/advanced/09_tagged_records.rs" + +[[example]] +name = "10_capability_constraints" +path = "audit-trail/advanced/10_capability_constraints.rs" + +[[example]] +name = "11_manage_record_tags" +path = "audit-trail/advanced/11_manage_record_tags.rs" + +[[example]] +name = "01_customs_clearance" +path = "audit-trail/real-world/01_customs_clearance.rs" + +[[example]] +name = "02_clinical_trial" +path = "audit-trail/real-world/02_clinical_trial.rs" + +[[example]] +name = "03_digital_product_passport" +path = "audit-trail/real-world/03_digital_product_passport.rs" + [dependencies] anyhow.workspace = true +audit_trails = { path = "../audit-trail-rs" } chrono = { workspace = true } iota-sdk = { workspace = true } notarization = { path = "../notarization-rs" } diff --git a/examples/README.md b/examples/README.md index 783c5af1..bc5eba1b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,8 @@ -# IOTA Notarization Examples +# IOTA Single Notarization Examples -The following code examples demonstrate how to use IOTA Notarization for creating, managing, and interacting with notarized documents on the IOTA network. +The following code examples demonstrate how to use IOTA Single Notarization for creating, managing, and interacting with notarized documents on the IOTA network. + +The folder [audit-trail](./audit-trail) contains examples for IOTA Audit Trails. See there if you want to experiment with Audit Trails examples. ## Prerequisites @@ -42,25 +44,25 @@ IOTA_NOTARIZATION_PKG_ID=0x... cargo run --release --example 01_create_locked_no The following basic CRUD (Create, Read, Update, Delete) examples are available: -| Name | Information | -| :------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------- | -| [01_create_locked_notarization](https://github.com/iotaledger/notarization/tree/main/examples/01_create_locked_notarization.rs) | Demonstrates how to create a locked notarization with delete locks. | -| [02_create_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/02_create_dynamic_notarization.rs) | Demonstrates how to create dynamic notarizations with and without transfer locks. | -| [03_update_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/03_update_dynamic_notarization.rs) | Demonstrates that dynamic notarizations can be updated | -| [04_destroy_notarization](https://github.com/iotaledger/notarization/tree/main/examples/04_destroy_notarization.rs) | Demonstrates notarization destruction scenarios based on lock types. | -| [05_update_state](https://github.com/iotaledger/notarization/tree/main/examples/05_update_state.rs) | Demonstrates state updates on dynamic notarizations including binary data. | -| [06_update_metadata](https://github.com/iotaledger/notarization/tree/main/examples/06_update_metadata.rs) | Demonstrates metadata updates and their behavior vs state updates. | -| [07_transfer_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/07_transfer_dynamic_notarization.rs) | Demonstrates transfer scenarios for different notarization types and lock states. | -| [08_access_read_only_methods](https://github.com/iotaledger/notarization/tree/main/examples/08_access_read_only_methods.rs) | Comprehensive demonstration of all read-only inspection methods. | +| Name | Information | +| :------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | +| [01_create_locked_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/01_create_locked_notarization.rs) | Demonstrates how to create a locked notarization with delete locks. | +| [02_create_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/02_create_dynamic_notarization.rs) | Demonstrates how to create dynamic notarizations with and without transfer locks. | +| [03_update_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/03_update_dynamic_notarization.rs) | Demonstrates that dynamic notarizations can be updated | +| [04_destroy_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/04_destroy_notarization.rs) | Demonstrates notarization destruction scenarios based on lock types. | +| [05_update_state](https://github.com/iotaledger/notarization/tree/main/examples/notarization/05_update_state.rs) | Demonstrates state updates on dynamic notarizations including binary data. | +| [06_update_metadata](https://github.com/iotaledger/notarization/tree/main/examples/notarization/06_update_metadata.rs) | Demonstrates metadata updates and their behavior vs state updates. | +| [07_transfer_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/07_transfer_dynamic_notarization.rs) | Demonstrates transfer scenarios for different notarization types and lock states. | +| [08_access_read_only_methods](https://github.com/iotaledger/notarization/tree/main/examples/notarization/08_access_read_only_methods.rs) | Comprehensive demonstration of all read-only inspection methods. | ## Real-World Examples The following examples demonstrate practical use cases with proper field usage: -| Name | Information | -| :--------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | -| [iot_weather_station](https://github.com/iotaledger/notarization/tree/main/examples/real-world/iot_weather_station.rs) | IoT weather station using dynamic notarization for continuous sensor data updates. | -| [legal_contract](https://github.com/iotaledger/notarization/tree/main/examples/real-world/legal_contract.rs) | Legal contract using locked notarization for immutable document hash attestation. | +| Name | Information | +| :---------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | +| [iot_weather_station](https://github.com/iotaledger/notarization/tree/main/examples/notarization/real-world/iot_weather_station.rs) | IoT weather station using dynamic notarization for continuous sensor data updates. | +| [legal_contract](https://github.com/iotaledger/notarization/tree/main/examples/notarization/real-world/legal_contract.rs) | Legal contract using locked notarization for immutable document hash attestation. | ## Notarization Types @@ -156,4 +158,4 @@ The examples demonstrate proper error handling for common scenarios: - Transfer locks prevent unauthorized ownership changes - Delete locks ensure data retention requirements -For more detailed information about IOTA Notarization concepts and advanced usage, refer to the official IOTA documentation. +For more detailed information about IOTA Single Notarization concepts and advanced usage, refer to the official IOTA documentation. diff --git a/examples/audit-trail/01_create_audit_trail.rs b/examples/audit-trail/01_create_audit_trail.rs new file mode 100644 index 00000000..1df16669 --- /dev/null +++ b/examples/audit-trail/01_create_audit_trail.rs @@ -0,0 +1,124 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail and holds the built-in Admin capability minted on creation. +//! - **Record admin client**: Receives a RecordAdmin capability bound to their address so it can write records. + +use anyhow::Result; +use audit_trails::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create an audit trail with an initial record and metadata. +/// 2. Inspect the built-in Admin role that is automatically granted to the creator. +/// 3. Use the Admin capability to define a `RecordAdmin` role. +/// 4. Issue a capability for the `RecordAdmin` role to a specific address. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Create Trail & Define Roles ===\n"); + + // Use separate clients to show that admin rights and record-writing rights can belong to different addresses. + let admin_client = get_funded_audit_trail_client().await?; + let record_admin_client = get_funded_audit_trail_client().await?; + + println!("Admin client address: {}", admin_client.sender_address()); + println!( + "Record admin client address: {}\n", + record_admin_client.sender_address() + ); + + // ------------------------------------------------------------------------- + // Step 1: Create an audit trail + // ------------------------------------------------------------------------- + // The builder supports optional immutable metadata (name + description), + // mutable updatable metadata, an initial record, record tag registry, and + // locking configuration. + // + // On success, the transaction engine automatically mints an Admin capability + // object and transfers it to the sender's address. This capability grants + // full administrative control over the trail (role management, capability + // issuance, tag management, etc.). + let created_trail = admin_client + .create_trail() + .with_trail_metadata(ImmutableMetadata::new( + "Product Shipment Audit Trail".to_string(), + Some("Immutable audit log for product lifecycle events".to_string()), + )) + .with_updatable_metadata("Status: Active") + .with_initial_record(InitialRecord::new( + Data::text("Shipment #SHP-20260401-001 created at warehouse A"), + Some("event:shipment_created;location:warehouse-a".to_string()), + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + println!( + "Trail created!\n Trail ID: {}\n Creator: {}\n Timestamp: {} ms\n", + created_trail.trail_id, created_trail.creator, created_trail.timestamp + ); + + // Fetch the trail to inspect the role map that was initialized during creation. + let on_chain_trail = admin_client.trail(created_trail.trail_id).get().await?; + let admin_role_name = &on_chain_trail.roles.initial_admin_role_name; + let admin_permissions = &on_chain_trail.roles.roles[admin_role_name].permissions; + println!( + "Built-in admin role: \"{admin_role_name}\" ({} permissions)\n", + admin_permissions.len() + ); + + // ------------------------------------------------------------------------- + // Step 2: Define a RecordAdmin role + // ------------------------------------------------------------------------- + // The Admin capability in `admin_client`'s wallet authorizes this role-management transaction. + // This permission set is the standard bundle for adding, deleting, and correcting records. + let record_admin_role = "RecordAdmin"; + let created_role = admin_client + .trail(created_trail.trail_id) + .access() + .for_role(record_admin_role) + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&admin_client) + .await? + .output; + + println!( + "Role \"{}\" defined with permissions:\n {:?}\n", + created_role.role, created_role.permissions.permissions + ); + + // ------------------------------------------------------------------------- + // Step 3: Issue a capability for the RecordAdmin role + // ------------------------------------------------------------------------- + // Issuing the capability delegates this role to `record_admin_client`; the Admin capability stays with + // `admin_client`. + let record_admin_capability = admin_client + .trail(created_trail.trail_id) + .access() + .for_role(record_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await? + .output; + + println!( + "Capability issued!\n Capability ID: {}\n Trail ID: {}\n Role: {}\n Issued to: {}", + record_admin_capability.capability_id, + record_admin_capability.target_key, + record_admin_capability.role, + record_admin_capability + .issued_to + .map_or_else(|| "any holder (no address restriction)".to_string(), |a| a.to_string()) + ); + + Ok(()) +} diff --git a/examples/audit-trail/02_add_and_read_records.rs b/examples/audit-trail/02_add_and_read_records.rs new file mode 100644 index 00000000..c9f4fe85 --- /dev/null +++ b/examples/audit-trail/02_add_and_read_records.rs @@ -0,0 +1,165 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability. +//! - **Record admin client**: Holds the capability and writes records. Reads are also done through this client to keep +//! the example focused on one trail handle after delegation. + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create an audit trail with an initial record. +/// 2. Define a `RecordAdmin` role and issue a capability for it. +/// 3. Add follow-up records to the trail. +/// 4. Read records back individually and through paginated traversal. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Add & Read Records ===\n"); + + // Use separate clients to make the permission handoff explicit. + let admin_client = get_funded_audit_trail_client().await?; + let record_admin_client = get_funded_audit_trail_client().await?; + + println!("Admin client address: {}", admin_client.sender_address()); + println!( + "Record admin client address: {}\n", + record_admin_client.sender_address() + ); + + // ------------------------------------------------------------------------- + // Step 1: Create a trail with one initial record + // ------------------------------------------------------------------------- + // Creating the trail automatically gives `admin_client` the built-in Admin capability. + let created_trail = admin_client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("Trail opened"), + Some("event:trail_created".to_string()), + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + println!("Trail created: {trail_id}\n"); + + // ------------------------------------------------------------------------- + // Step 2: Create a RecordAdmin role and issue a capability for it + // ------------------------------------------------------------------------- + // The role defines what record operations are allowed. + admin_client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + + // The capability grants that role to `record_admin_client`'s address. + let record_admin_capability = admin_client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await? + .output; + + println!( + "Issued capability {} for role {}\n", + record_admin_capability.capability_id, record_admin_capability.role + ); + + // ------------------------------------------------------------------------- + // Step 3: Append follow-up records + // ------------------------------------------------------------------------- + // The record API automatically selects the matching capability from `record_admin_client`'s wallet. + let records = record_admin_client.trail(trail_id).records(); + + let first_added = records + .add( + Data::text("Shipment received at warehouse A"), + Some("event:received".to_string()), + None, + ) + .build_and_execute(&record_admin_client) + .await? + .output; + + let second_added = records + .add( + Data::text("Shipment dispatched to retailer"), + Some("event:dispatched".to_string()), + None, + ) + .build_and_execute(&record_admin_client) + .await? + .output; + + println!( + "Added records at sequence numbers {} and {}\n", + first_added.sequence_number, second_added.sequence_number + ); + + // ------------------------------------------------------------------------- + // Step 4: Read records back by sequence number + // ------------------------------------------------------------------------- + // Sequence numbers start at 0, so the initial record is still addressable after appending more records. + let initial = records.get(0).await?; + let first = records.get(first_added.sequence_number).await?; + let second = records.get(second_added.sequence_number).await?; + + println!("Initial record: {:?}", initial.data); + println!("First added record: {:?}", first.data); + println!("Second added record: {:?}\n", second.data); + + ensure!(matches!(initial.data, Data::Text(ref text) if text == "Trail opened")); + ensure!(matches!( + first.data, + Data::Text(ref text) if text == "Shipment received at warehouse A" + )); + ensure!(matches!( + second.data, + Data::Text(ref text) if text == "Shipment dispatched to retailer" + )); + + // ------------------------------------------------------------------------- + // Step 5: Inspect record count and page through the linked table + // ------------------------------------------------------------------------- + // Pagination keeps reads bounded for trails that grow over time. + let count = records.record_count().await?; + println!("Current record count: {count}"); + ensure!(count == 3, "expected 3 records, got {count}"); + + let first_page = records.list_page(None, 2).await?; + println!( + "First page contains {} records; has_next_page = {}", + first_page.records.len(), + first_page.has_next_page + ); + + let second_page = records.list_page(first_page.next_cursor, 2).await?; + println!( + "Second page contains {} records; has_next_page = {}", + second_page.records.len(), + second_page.has_next_page + ); + + ensure!(first_page.records.len() == 2, "expected first page size 2"); + ensure!(second_page.records.len() == 1, "expected second page size 1"); + + println!("\nRecord flow completed successfully."); + + Ok(()) +} diff --git a/examples/audit-trail/03_update_metadata.rs b/examples/audit-trail/03_update_metadata.rs new file mode 100644 index 00000000..ddb8d754 --- /dev/null +++ b/examples/audit-trail/03_update_metadata.rs @@ -0,0 +1,108 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail and sets up the MetadataAdmin role. +//! - **Metadata admin client**: Holds the MetadataAdmin capability and updates the trail's mutable status field. Has no +//! record-write permissions. + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create a trail with immutable and updatable metadata. +/// 2. Delegate metadata updates through a dedicated `MetadataAdmin` role. +/// 3. Change and clear the trail's updatable metadata. +/// 4. Verify that immutable metadata never changes. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Update Metadata ===\n"); + + // Use separate clients so metadata updates are clearly delegated away from the creator. + let admin_client = get_funded_audit_trail_client().await?; + let metadata_admin_client = get_funded_audit_trail_client().await?; + + let immutable_metadata = ImmutableMetadata::new( + "Shipment Processing".to_string(), + Some("Tracks the lifecycle of a warehouse shipment".to_string()), + ); + + let created_trail = admin_client + .create_trail() + .with_trail_metadata(immutable_metadata.clone()) + .with_updatable_metadata("Status: Draft") + .with_initial_record(InitialRecord::new( + Data::text("Shipment created"), + Some("event:created".to_string()), + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let metadata_admin_role = "MetadataAdmin"; + + // The Admin capability in `admin_client`'s wallet authorizes role definition and capability issuance. + admin_client + .trail(trail_id) + .access() + .for_role(metadata_admin_role) + .create(PermissionSet::metadata_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + + admin_client + .trail(trail_id) + .access() + .for_role(metadata_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(metadata_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + let trail_before_update = admin_client.trail(trail_id).get().await?; + println!( + "Before update:\n immutable = {:?}\n updatable = {:?}\n", + trail_before_update.immutable_metadata, trail_before_update.updatable_metadata + ); + + metadata_admin_client + .trail(trail_id) + .update_metadata(Some("Status: In Review".to_string())) + .build_and_execute(&metadata_admin_client) + .await?; + + let trail_after_update = admin_client.trail(trail_id).get().await?; + println!( + "After update:\n immutable = {:?}\n updatable = {:?}\n", + trail_after_update.immutable_metadata, trail_after_update.updatable_metadata + ); + + ensure!(trail_after_update.immutable_metadata == Some(immutable_metadata.clone())); + ensure!(trail_after_update.updatable_metadata.as_deref() == Some("Status: In Review")); + + metadata_admin_client + .trail(trail_id) + .update_metadata(None) + .build_and_execute(&metadata_admin_client) + .await?; + + let trail_after_clear = admin_client.trail(trail_id).get().await?; + println!( + "After clear:\n immutable = {:?}\n updatable = {:?}", + trail_after_clear.immutable_metadata, trail_after_clear.updatable_metadata + ); + + ensure!(trail_after_clear.immutable_metadata == Some(immutable_metadata)); + ensure!(trail_after_clear.updatable_metadata.is_none()); + + Ok(()) +} diff --git a/examples/audit-trail/04_configure_locking.rs b/examples/audit-trail/04_configure_locking.rs new file mode 100644 index 00000000..6a6645ea --- /dev/null +++ b/examples/audit-trail/04_configure_locking.rs @@ -0,0 +1,153 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail and sets up the LockingAdmin and RecordAdmin roles. +//! - **Locking admin client**: Controls write and delete locks. Holds the LockingAdmin capability. +//! - **Record admin client**: Writes records. Used to demonstrate that the write lock is enforced per-sender, not just +//! checked by the admin. + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, LockingWindow, PermissionSet, TimeLock}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Delegate locking updates through a `LockingAdmin` role. +/// 2. Freeze record creation with a write lock. +/// 3. Restore writes and add a new record. +/// 4. Update the delete-record window and delete-trail lock. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Configure Locking ===\n"); + + // Use separate clients to show that locking and record-writing permissions can be delegated independently. + let admin_client = get_funded_audit_trail_client().await?; + let locking_admin_client = get_funded_audit_trail_client().await?; + let record_admin_client = get_funded_audit_trail_client().await?; + + let created_trail = admin_client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("Trail opened"), + Some("event:created".to_string()), + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let locking_admin_role = "LockingAdmin"; + let record_admin_role = "RecordAdmin"; + + // The Admin capability authorizes defining roles and issuing the delegated capabilities. + admin_client + .trail(trail_id) + .access() + .for_role(locking_admin_role) + .create(PermissionSet::locking_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + admin_client + .trail(trail_id) + .access() + .for_role(locking_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(locking_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + admin_client + .trail(trail_id) + .access() + .for_role(record_admin_role) + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + admin_client + .trail(trail_id) + .access() + .for_role(record_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + locking_admin_client + .trail(trail_id) + .locking() + .update_write_lock(TimeLock::Infinite) + .build_and_execute(&locking_admin_client) + .await?; + + let locked_trail = admin_client.trail(trail_id).get().await?; + println!( + "Write lock after update: {:?}\n", + locked_trail.locking_config.write_lock + ); + ensure!(locked_trail.locking_config.write_lock == TimeLock::Infinite); + + let blocked_add = record_admin_client + .trail(trail_id) + .records() + .add(Data::text("This write should fail"), None, None) + .build_and_execute(&record_admin_client) + .await; + ensure!(blocked_add.is_err(), "write lock should block adding records"); + + locking_admin_client + .trail(trail_id) + .locking() + .update_write_lock(TimeLock::None) + .build_and_execute(&locking_admin_client) + .await?; + + let added_record = record_admin_client + .trail(trail_id) + .records() + .add(Data::text("Write lock lifted"), Some("event:resumed".to_string()), None) + .build_and_execute(&record_admin_client) + .await? + .output; + + println!( + "Added record {} after clearing the write lock.\n", + added_record.sequence_number + ); + + locking_admin_client + .trail(trail_id) + .locking() + .update_delete_record_window(LockingWindow::CountBased { count: 2 })? + .build_and_execute(&locking_admin_client) + .await?; + locking_admin_client + .trail(trail_id) + .locking() + .update_delete_trail_lock(TimeLock::Infinite)? + .build_and_execute(&locking_admin_client) + .await?; + + let final_trail = admin_client.trail(trail_id).get().await?; + println!( + "Final locking config:\n delete_record_window = {:?}\n delete_trail_lock = {:?}\n write_lock = {:?}", + final_trail.locking_config.delete_record_window, + final_trail.locking_config.delete_trail_lock, + final_trail.locking_config.write_lock + ); + + ensure!(final_trail.locking_config.delete_record_window == LockingWindow::CountBased { count: 2 }); + ensure!(final_trail.locking_config.delete_trail_lock == TimeLock::Infinite); + ensure!(final_trail.locking_config.write_lock == TimeLock::None); + + Ok(()) +} diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs new file mode 100644 index 00000000..34145ec5 --- /dev/null +++ b/examples/audit-trail/05_manage_access.rs @@ -0,0 +1,163 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates and updates roles, issues capabilities, revokes and destroys them, and finally deletes +//! the role once it is no longer needed. +//! - **Operations user client**: The subject of all capability issuance. Capabilities are bound to this address to +//! demonstrate that revocation immediately blocks their access. + +use std::collections::HashSet; + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create and update a custom role. +/// 2. Issue a constrained capability for that role. +/// 3. Revoke one capability and destroy another. +/// 4. Remove the role after its capability lifecycle is complete. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Manage Access ===\n"); + + // Use a separate operations client so capability ownership and revocation are visible. + let admin_client = get_funded_audit_trail_client().await?; + let operations_user_client = get_funded_audit_trail_client().await?; + + let created_trail = admin_client + .create_trail() + .with_initial_record(audit_trails::core::types::InitialRecord::new( + Data::text("Trail created"), + None, + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let operations_role = "Operations"; + + // The Admin capability authorizes the custom role definition. + let created_operations_role = admin_client + .trail(trail_id) + .access() + .for_role(operations_role) + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&admin_client) + .await? + .output; + println!("Created role: {}\n", created_operations_role.role); + + let updated_permissions = PermissionSet { + permissions: HashSet::from([ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::DeleteAllRecords, + ]), + }; + + let updated_operations_role = admin_client + .trail(trail_id) + .access() + .for_role(operations_role) + .update_permissions(updated_permissions.clone(), None) + .build_and_execute(&admin_client) + .await? + .output; + println!( + "Updated role permissions: {:?}\n", + updated_operations_role.permissions.permissions + ); + + let operations_capability = admin_client + .trail(trail_id) + .access() + .for_role(operations_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(operations_user_client.sender_address()), + valid_from_ms: None, + valid_until_ms: Some(4_102_444_800_000), + }) + .build_and_execute(&admin_client) + .await? + .output; + + println!( + "Issued constrained capability:\n id = {}\n issued_to = {:?}\n valid_until = {:?}\n", + operations_capability.capability_id, operations_capability.issued_to, operations_capability.valid_until + ); + + let on_chain_trail = admin_client.trail(trail_id).get().await?; + let operations_role_definition = on_chain_trail + .roles + .roles + .get(operations_role) + .expect("role must exist"); + ensure!(operations_role_definition.permissions == updated_permissions.permissions); + + admin_client + .trail(trail_id) + .access() + .revoke_capability(operations_capability.capability_id, operations_capability.valid_until) + .build_and_execute(&admin_client) + .await?; + println!("Revoked capability {}\n", operations_capability.capability_id); + + // destroy_capability consumes the capability object, so the signer must own it. + // This disposable capability is issued back to `admin_client` so it can be destroyed directly. + let disposable_operations_capability = admin_client + .trail(trail_id) + .access() + .for_role(operations_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await? + .output; + + admin_client + .trail(trail_id) + .access() + .destroy_capability(disposable_operations_capability.capability_id) + .build_and_execute(&admin_client) + .await?; + println!( + "Destroyed capability {}\n", + disposable_operations_capability.capability_id + ); + + admin_client + .trail(trail_id) + .access() + .cleanup_revoked_capabilities() + .build_and_execute(&admin_client) + .await?; + println!("Cleaned up revoked capability registry entries.\n"); + + admin_client + .trail(trail_id) + .access() + .for_role(operations_role) + .delete() + .build_and_execute(&admin_client) + .await?; + + let trail_after_role_delete = admin_client.trail(trail_id).get().await?; + ensure!( + !trail_after_role_delete.roles.roles.contains_key(operations_role), + "role should be removed from the trail" + ); + + println!("Removed the custom role after its capability lifecycle completed."); + + Ok(()) +} diff --git a/examples/audit-trail/06_delete_records.rs b/examples/audit-trail/06_delete_records.rs new file mode 100644 index 00000000..3f0a421c --- /dev/null +++ b/examples/audit-trail/06_delete_records.rs @@ -0,0 +1,120 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail and sets up the RecordMaintenance role. +//! - **Maintenance admin client**: Holds the RecordMaintenance capability. Adds records and then deletes them +//! individually and in batch. + +use std::collections::HashSet; + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create records using a delegated record-maintenance role. +/// 2. Delete a single record by sequence number. +/// 3. Delete the remaining records in one batch. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Delete Records ===\n"); + + // Use a maintenance client to show deletes happening through a delegated capability. + let admin_client = get_funded_audit_trail_client().await?; + let maintenance_admin_client = get_funded_audit_trail_client().await?; + + let created_trail = admin_client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("Initial record"), + Some("event:created".to_string()), + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let maintenance_admin_role = "RecordMaintenance"; + let admin_trail = admin_client.trail(trail_id); + + // This role grants both single-record and batch-delete permissions. + admin_trail + .access() + .for_role(maintenance_admin_role) + .create( + PermissionSet { + permissions: HashSet::from([ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::DeleteAllRecords, + ]), + }, + None, + ) + .build_and_execute(&admin_client) + .await?; + + admin_trail + .access() + .for_role(maintenance_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(maintenance_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + let maintenance_records = maintenance_admin_client.trail(trail_id).records(); + + let first_added_record = maintenance_records + .add(Data::text("Second record"), Some("event:received".to_string()), None) + .build_and_execute(&maintenance_admin_client) + .await? + .output; + let second_added_record = maintenance_records + .add(Data::text("Third record"), Some("event:dispatched".to_string()), None) + .build_and_execute(&maintenance_admin_client) + .await? + .output; + + println!( + "Trail has records at sequence numbers 0, {}, {}\n", + first_added_record.sequence_number, second_added_record.sequence_number + ); + ensure!(maintenance_records.record_count().await? == 3); + + let deleted_record = maintenance_records + .delete(first_added_record.sequence_number) + .build_and_execute(&maintenance_admin_client) + .await? + .output; + println!("Deleted record {}\n", deleted_record.sequence_number); + + ensure!(maintenance_records.record_count().await? == 2); + ensure!( + maintenance_records + .get(first_added_record.sequence_number) + .await + .is_err(), + "deleted record should no longer be readable" + ); + + // Batch delete skips locked records and returns the deleted sequence numbers. + let deleted_remaining = maintenance_records + .delete_records_batch(10) + .build_and_execute(&maintenance_admin_client) + .await? + .output; + + println!("Batch deleted the remaining sequence numbers: {deleted_remaining:?}"); + ensure!(deleted_remaining == vec![0, second_added_record.sequence_number]); + ensure!(maintenance_records.record_count().await? == 0); + + Ok(()) +} diff --git a/examples/audit-trail/07_access_read_only_methods.rs b/examples/audit-trail/07_access_read_only_methods.rs new file mode 100644 index 00000000..ce5f5d88 --- /dev/null +++ b/examples/audit-trail/07_access_read_only_methods.rs @@ -0,0 +1,119 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail and sets up the RecordAdmin role. +//! - **Record admin client**: Adds one follow-up record. All subsequent operations are read-only and can be performed +//! by any address — no capability required. + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, + TimeLock, +}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Load the full on-chain trail object. +/// 2. Inspect metadata, roles, and locking configuration. +/// 3. Read records individually and through pagination. +/// 4. Query the record-count and lock-status helpers. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Read-Only Inspection ===\n"); + + // Use separate clients to keep write delegation distinct from read-only inspection. + let admin_client = get_funded_audit_trail_client().await?; + let record_admin_client = get_funded_audit_trail_client().await?; + + let created_trail = admin_client + .create_trail() + .with_trail_metadata(ImmutableMetadata::new( + "Operations Trail".to_string(), + Some("Used to inspect read-only accessors".to_string()), + )) + .with_updatable_metadata("Status: Active") + .with_locking_config(LockingConfig { + delete_record_window: LockingWindow::CountBased { count: 2 }, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + }) + .with_initial_record(InitialRecord::new( + Data::text("Initial record"), + Some("event:created".to_string()), + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let record_admin_role = "RecordAdmin"; + + admin_client + .trail(trail_id) + .access() + .for_role(record_admin_role) + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + admin_client + .trail(trail_id) + .access() + .for_role(record_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + record_admin_client + .trail(trail_id) + .records() + .add(Data::text("Follow-up record"), Some("event:updated".to_string()), None) + .build_and_execute(&record_admin_client) + .await?; + + let on_chain_trail = admin_client.trail(trail_id).get().await?; + println!( + "Trail summary:\n id = {}\n creator = {}\n created_at = {}\n sequence_number = {}\n immutable_metadata = {:?}\n updatable_metadata = {:?}\n", + on_chain_trail.id.object_id(), + on_chain_trail.creator, + on_chain_trail.created_at, + on_chain_trail.sequence_number, + on_chain_trail.immutable_metadata, + on_chain_trail.updatable_metadata + ); + + println!( + "Roles: {:?}\nLocking config: {:?}\n", + on_chain_trail.roles.roles.keys().collect::>(), + on_chain_trail.locking_config + ); + + let read_only_trail = admin_client.trail(trail_id); + let record_count = read_only_trail.records().record_count().await?; + let initial_record = read_only_trail.records().get(0).await?; + let first_page = read_only_trail.records().list_page(None, 10).await?; + let record_zero_locked = read_only_trail.locking().is_record_locked(0).await?; + + println!("Record count: {record_count}"); + println!("Record #0: {:?}", initial_record); + println!( + "First page size: {} (has_next_page = {})", + first_page.records.len(), + first_page.has_next_page + ); + println!("Is record #0 locked? {record_zero_locked}"); + + ensure!(record_count == 2); + ensure!(matches!(initial_record.data, Data::Text(ref text) if text == "Initial record")); + ensure!(first_page.records.len() == 2); + + Ok(()) +} diff --git a/examples/audit-trail/08_delete_audit_trail.rs b/examples/audit-trail/08_delete_audit_trail.rs new file mode 100644 index 00000000..d84582cb --- /dev/null +++ b/examples/audit-trail/08_delete_audit_trail.rs @@ -0,0 +1,104 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail and sets up the MaintenanceAdmin role. +//! - **Maintenance admin client**: Holds delete permissions. Attempts (and fails) to delete the non-empty trail, then +//! batch-deletes all records before removing the trail itself. + +use std::collections::HashSet; + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Show that a non-empty trail cannot be deleted. +/// 2. Empty the trail with `delete_records_batch`. +/// 3. Delete the trail once its records are gone. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Delete Trail ===\n"); + + // Use a maintenance client to keep deletion permissions separate from trail creation. + let admin_client = get_funded_audit_trail_client().await?; + let maintenance_admin_client = get_funded_audit_trail_client().await?; + + let created_trail = admin_client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("Initial record"), + Some("event:created".to_string()), + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let maintenance_admin_role = "MaintenanceAdmin"; + let admin_trail = admin_client.trail(trail_id); + + // The Admin capability authorizes the maintenance role and capability delegation. + admin_trail + .access() + .for_role(maintenance_admin_role) + .create( + PermissionSet { + permissions: HashSet::from([Permission::DeleteAllRecords, Permission::DeleteAuditTrail]), + }, + None, + ) + .build_and_execute(&admin_client) + .await?; + admin_trail + .access() + .for_role(maintenance_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(maintenance_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + let maintenance_trail = maintenance_admin_client.trail(trail_id); + + let delete_while_non_empty = maintenance_trail + .delete_audit_trail() + .build_and_execute(&maintenance_admin_client) + .await; + ensure!(delete_while_non_empty.is_err(), "a trail must be empty before deletion"); + println!("Deleting the non-empty trail failed as expected.\n"); + + // Batch delete skips locked records and returns the deleted sequence numbers before trail deletion. + let deleted_records = maintenance_trail + .records() + .delete_records_batch(10) + .build_and_execute(&maintenance_admin_client) + .await? + .output; + println!("Deleted record sequence numbers {deleted_records:?} before trail removal.\n"); + + ensure!(maintenance_trail.records().record_count().await? == 0); + + let deleted_trail = maintenance_trail + .delete_audit_trail() + .build_and_execute(&maintenance_admin_client) + .await? + .output; + println!( + "Trail deleted:\n trail_id = {}\n timestamp = {}", + deleted_trail.trail_id, deleted_trail.timestamp + ); + + ensure!( + maintenance_trail.get().await.is_err(), + "deleted trail should no longer be readable" + ); + + Ok(()) +} diff --git a/examples/audit-trail/README.md b/examples/audit-trail/README.md new file mode 100644 index 00000000..e05495f8 --- /dev/null +++ b/examples/audit-trail/README.md @@ -0,0 +1,172 @@ +# IOTA Audit Trails Examples + +The following code examples demonstrate how to use IOTA Audit Trails for creating structured, role-based audit logs on the IOTA network. + +## Prerequisites + +Examples can be run against: + +- A local IOTA node +- An existing network, e.g., the IOTA testnet + +When setting up a local node, you'll need to publish an Audit Trails Package as described in the IOTA documentation. You'll also need to provide environment variables for your locally deployed Audit Trails Package to run the examples against the local node. + +If running the examples on `testnet`, use the appropriate package IDs for the testnet deployment. + +In case of running the examples against an existing network, this network needs to have a faucet to fund your accounts (the IOTA testnet (`https://api.testnet.iota.cafe`) supports this), and you need to specify this via `API_ENDPOINT`. + +## Environment Variables + +You'll need one or more of the following environment variables depending on your setup: + +| Name | Required for local node | Required for testnet | Required for other node | +| ------------------------- | :---------------------: | :------------------: | :---------------------: | +| IOTA_AUDIT_TRAIL_PKG_ID | x | x | x | +| IOTA_TF_COMPONENTS_PKG_ID | x | | | +| API_ENDPOINT | | x | x | + +> **Note:** On localnet both `IOTA_AUDIT_TRAIL_PKG_ID` and `IOTA_TF_COMPONENTS_PKG_ID` resolve to the same package ID because the TfComponents dependency is published together with the Audit Trails Package. + +## Running Examples + +The publish script prints the required `export` statements, so use `eval` to set the variables in one step: + +```bash +eval $(./audit-trail-move/scripts/publish_package.sh) +``` + +Then run a specific example: + +```bash +cargo run --release --example +``` + +For instance, to run the `01_create_audit_trail` example: + +```bash +eval $(./audit-trail-move/scripts/publish_package.sh) +cargo run --release --example 01_create_audit_trail +``` + +To pass the variables inline instead: + +```bash +IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... cargo run --release --example 01_create_audit_trail +``` + +## Examples + +| Name | Information | +| :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | +| [01_create_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/01_create_audit_trail.rs) | Creates an audit trail, defines a `RecordAdmin` role using the Admin capability, and issues a capability for it. | +| [02_add_and_read_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/02_add_and_read_records.rs) | Adds follow-up records to a trail, then loads them back individually and through paginated reads. | +| [03_update_metadata](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/03_update_metadata.rs) | Updates and clears the trail's mutable metadata while preserving immutable metadata. | +| [04_configure_locking](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/04_configure_locking.rs) | Configures write and delete locks, then shows how those rules affect record creation. | +| [05_manage_access](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/05_manage_access.rs) | Creates and updates a role, then demonstrates constrained capability issuance, revoke and destroy flows, denylist cleanup, and final role removal. | +| [06_delete_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/06_delete_records.rs) | Deletes an individual record and then removes the remaining records in a batch. | +| [07_access_read_only_methods](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/07_access_read_only_methods.rs) | Reads back trail metadata, locking state, record counts, and paginated record data. | +| [08_delete_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/08_delete_audit_trail.rs) | Empties a trail and then deletes it, showing that non-empty trails cannot be removed. | + +## Advanced Examples + +| Name | Information | +| :------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------- | +| [09_tagged_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/09_tagged_records.rs) | Uses role tags and address-bound capabilities to restrict who may add tagged records. | +| [10_capability_constraints](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/10_capability_constraints.rs) | Shows address-bound capability use and how revocation immediately blocks future writes. | +| [11_manage_record_tags](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/11_manage_record_tags.rs) | Delegates record-tag administration and shows that in-use tags cannot be removed. | + +## Real-World Examples + +| Name | Information | +| :------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [01_customs_clearance](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/real-world/01_customs_clearance.rs) | Models customs clearance with role-tag restrictions, delegated capabilities, denied inspection writes, and a final write lock. | +| [02_clinical_trial](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/real-world/02_clinical_trial.rs) | Models a Phase III clinical trial with time-constrained capabilities, mid-study tag additions, deletion-window enforcement, time-locked datasets, and read-only regulator verification. | +| [03_digital_product_passport](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/real-world/03_digital_product_passport.rs) | Models a Digital Product Passport for an e-bike battery with lifecycle-scoped actors, technician access approval, an annual maintenance event, and documented Lifecycle Credit reward evidence. | + +## Key Concepts + +### Audit Trails + +An audit trail is an on-chain object that stores an ordered sequence of records. Each trail has: + +- **Immutable metadata**: Name and description set at creation, never changes +- **Updatable metadata**: A mutable string for operational status or notes +- **Record log**: An append-only sequence of records (text or binary data) +- **Role map**: Named roles with permission sets that control who can do what +- **Locking config**: Optional write, delete-record, and delete-trail locks + +### Role-Based Access Control + +Access to trail operations is controlled via roles and capabilities: + +- **Roles** define a named set of permissions (e.g., a `RecordAdmin` role granting the permissions from `record_admin_permissions()`) +- **Capabilities** are on-chain objects issued for a role and held in a wallet — possession of a capability grants the associated permissions on a specific trail +- The trail creator automatically receives an **Admin** capability granting full administrative control (role management, capability issuance, tag management, etc.) + +### Permission Sets + +`PermissionSet` convenience constructors cover common role configurations. Each constructor's +API documentation lists the exact permissions it grants (the source of truth): + +| Constructor | Intended role | +| :----------------------------- | :------------------------------------------ | +| `admin_permissions()` | `Admin` — full trail administration | +| `record_admin_permissions()` | record management (add, delete, correct) | +| `role_admin_permissions()` | role management (add, update, delete roles) | +| `locking_admin_permissions()` | locking-configuration management | +| `cap_admin_permissions()` | capability management (issue, revoke) | +| `tag_admin_permissions()` | record-tag registry management | +| `metadata_admin_permissions()` | updatable-metadata management | + +### Capability Constraints + +When issuing a capability, `CapabilityIssueOptions` allows restricting its use: + +- **`issued_to`**: Bind the capability to a specific wallet address +- **`valid_from_ms`**: The capability is not valid before this Unix timestamp (ms) +- **`valid_until_ms`**: The capability expires after this Unix timestamp (ms) + +### Locking + +Trails support three independent lock dimensions: + +- **Write lock** (`TimeLock`): Prevents new records from being added +- **Delete-record window** (`LockingWindow`): Time-based window or count-based protection for the last N records currently present in trail order +- **Delete-trail lock** (`TimeLock`): Prevents the trail itself from being destroyed + +`TimeLock` variants: `None`, `UnlockAt(u32)`, `UnlockAtMs(u64)`, `UntilDestroyed`, `Infinite`. + +## Example Scenarios + +### Audit Log Workflow + +1. **Create** a trail with immutable metadata and an initial record +2. **Define roles** (e.g., `RecordAdmin`, `Auditor`) using the Admin capability +3. **Issue capabilities** to operators or auditors +4. **Add records** using a RecordAdmin capability +5. **Query** records and trail state at any time + +### Compliance Use Cases + +- **Locked write windows** to prevent retroactive record insertion +- **Delete-record windows** to allow corrections within a time limit or retain the latest N current records +- **Role separation** to enforce least-privilege access (auditors can read, operators can write) +- **Bound capabilities** to tie a capability to a specific operator address + +## Best Practices + +1. **Separate roles by responsibility**: Use distinct roles for writing records, managing locking, and administering capabilities +2. **Bind capabilities to addresses**: Use `issued_to` to prevent capability sharing +3. **Set validity windows**: Use `valid_from_ms` / `valid_until_ms` to limit capability lifetime +4. **Use record tags**: Define a tag registry on the trail and restrict roles to specific tags for finer-grained access control +5. **Plan locking upfront**: Locking configuration is easier to set at creation than to change later + +## Security Considerations + +- Audit trails and their records are publicly readable on the blockchain +- Private keys control which capabilities a wallet holds +- Bound capabilities (`issued_to`) prevent transfer and unauthorized use +- Delete-trail locks ensure data retention requirements are met +- Revoking a capability adds it to the trail's revoked-capability registry, blocking future use + +For more detailed information about IOTA Audit Trails concepts and advanced usage, refer to the official IOTA documentation. diff --git a/examples/audit-trail/advanced/09_tagged_records.rs b/examples/audit-trail/advanced/09_tagged_records.rs new file mode 100644 index 00000000..b29dca1f --- /dev/null +++ b/examples/audit-trail/advanced/09_tagged_records.rs @@ -0,0 +1,117 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail, defines the FinanceWriter role restricted to the `finance` tag, and issues a +//! capability bound to `finance_writer_client`'s address. +//! - **Finance writer client**: Holds the address-bound capability. Can add `finance`-tagged records but is blocked +//! from writing `legal`-tagged records. + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, RoleTags}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create a trail with a predefined tag registry. +/// 2. Define a role that is restricted to one record tag. +/// 3. Issue a capability bound to a specific wallet address. +/// 4. Show that the holder can add only records matching the allowed tag. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail Advanced: Tagged Records ===\n"); + + let admin_client = get_funded_audit_trail_client().await?; + let finance_writer_client = get_funded_audit_trail_client().await?; + + let created_trail = admin_client + .create_trail() + .with_record_tags(["finance", "legal"]) + .with_initial_record(InitialRecord::new( + Data::text("Trail created"), + Some("event:created".to_string()), + None, + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let finance_writer_role = "FinanceWriter"; + + // The role's tag scope limits writes even when the holder has AddRecord permission. + admin_client + .trail(trail_id) + .access() + .for_role(finance_writer_role) + .create( + audit_trails::core::types::PermissionSet { + permissions: [Permission::AddRecord].into_iter().collect(), + }, + Some(RoleTags::new(["finance"])), + ) + .build_and_execute(&admin_client) + .await?; + + let finance_writer_capability = admin_client + .trail(trail_id) + .access() + .for_role(finance_writer_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(finance_writer_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await? + .output; + + println!( + "Issued FinanceWriter capability {} to {}\n", + finance_writer_capability.capability_id, + finance_writer_client.sender_address() + ); + + // The client automatically scans `finance_writer_client`'s wallet for a capability object that + // targets this trail and carries the required permission. No explicit capability ID is + // needed — the lookup happens in the background on every operation. + let finance_records = finance_writer_client.trail(trail_id).records(); + + let finance_record_added = finance_records + .add( + Data::text("Invoice approved"), + Some("department:finance".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&finance_writer_client) + .await? + .output; + + println!( + "Added tagged record at sequence number {} with tag \"finance\".\n", + finance_record_added.sequence_number + ); + + let wrong_tag_attempt = finance_records + .add( + Data::text("Legal review completed"), + Some("department:legal".to_string()), + Some("legal".to_string()), + ) + .build_and_execute(&finance_writer_client) + .await; + + ensure!( + wrong_tag_attempt.is_err(), + "a finance-scoped role must not add a legal-tagged record" + ); + + let finance_record = finance_records.get(finance_record_added.sequence_number).await?; + println!("Stored tagged record: {:?}", finance_record); + + ensure!(finance_record.tag.as_deref() == Some("finance")); + + Ok(()) +} diff --git a/examples/audit-trail/advanced/10_capability_constraints.rs b/examples/audit-trail/advanced/10_capability_constraints.rs new file mode 100644 index 00000000..86a64fbd --- /dev/null +++ b/examples/audit-trail/advanced/10_capability_constraints.rs @@ -0,0 +1,122 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability bound specifically to +//! `intended_writer_client`'s address. Also performs revocation. +//! - **Intended writer client**: The authorised holder. Writes a record successfully before revocation, then is blocked +//! after the capability is revoked. +//! - **Wrong writer client**: An unauthorised actor who attempts to use the address-bound capability. All write +//! attempts are rejected by the Move contract. + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Bind a capability to a specific wallet address. +/// 2. Show that a different wallet cannot use it. +/// 3. Revoke the capability and confirm the bound holder can no longer use it. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail Advanced: Capability Constraints ===\n"); + + let admin_client = get_funded_audit_trail_client().await?; + let intended_writer_client = get_funded_audit_trail_client().await?; + let wrong_writer_client = get_funded_audit_trail_client().await?; + + let created_trail = admin_client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("Trail created"), None, None)) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let record_admin_role = "RecordAdmin"; + + admin_client + .trail(trail_id) + .access() + .for_role(record_admin_role) + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + + // Address binding means only `intended_writer_client` may use the capability object. + let intended_writer_capability = admin_client + .trail(trail_id) + .access() + .for_role(record_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(intended_writer_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await? + .output; + + println!( + "Issued capability {} to {}\n", + intended_writer_capability.capability_id, + intended_writer_client.sender_address() + ); + + let wrong_writer_attempt = wrong_writer_client + .trail(trail_id) + .records() + .add(Data::text("Wrong writer"), None, None) + .build_and_execute(&wrong_writer_client) + .await; + + ensure!( + wrong_writer_attempt.is_err(), + "a capability bound to another address must not be usable" + ); + + let authorized_record = intended_writer_client + .trail(trail_id) + .records() + .add(Data::text("Authorized writer"), None, None) + .build_and_execute(&intended_writer_client) + .await? + .output; + + println!( + "Bound holder added record {} successfully.\n", + authorized_record.sequence_number + ); + + admin_client + .trail(trail_id) + .access() + .revoke_capability( + intended_writer_capability.capability_id, + intended_writer_capability.valid_until, + ) + .build_and_execute(&admin_client) + .await?; + + let revoked_capability_attempt = intended_writer_client + .trail(trail_id) + .records() + .add(Data::text("Should fail after revoke"), None, None) + .build_and_execute(&intended_writer_client) + .await; + + ensure!( + revoked_capability_attempt.is_err(), + "revoked capabilities must no longer authorize record writes" + ); + + println!( + "Revoked capability {} and verified it can no longer be used.", + intended_writer_capability.capability_id + ); + + Ok(()) +} diff --git a/examples/audit-trail/advanced/11_manage_record_tags.rs b/examples/audit-trail/advanced/11_manage_record_tags.rs new file mode 100644 index 00000000..d2e58810 --- /dev/null +++ b/examples/audit-trail/advanced/11_manage_record_tags.rs @@ -0,0 +1,134 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! ## Actors +//! +//! - **Admin client**: Creates the trail and manages roles. +//! - **Tag admin client**: Holds the TagAdmin capability. Adds and removes entries from the trail's tag registry. +//! - **Finance writer client**: Holds a `finance`-scoped RecordAdmin capability. Writes a `finance`-tagged record that +//! keeps the `finance` tag in use and therefore unremovable. + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet, RoleTags}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Delegate record-tag registry management to a `TagAdmin` role. +/// 2. Add and remove tags from the trail registry. +/// 3. Show that tags still in use by roles or records cannot be removed. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail Advanced: Manage Record Tags ===\n"); + + // Use separate clients for registry management and tag-scoped record writing. + let admin_client = get_funded_audit_trail_client().await?; + let tag_admin_client = get_funded_audit_trail_client().await?; + let finance_writer_client = get_funded_audit_trail_client().await?; + + let created_trail = admin_client + .create_trail() + .with_record_tags(["finance"]) + .with_initial_record(InitialRecord::new(Data::text("Trail created"), None, None)) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + let tag_admin_role = "TagAdmin"; + let finance_writer_role = "FinanceWriter"; + + admin_client + .trail(trail_id) + .access() + .for_role(tag_admin_role) + .create(PermissionSet::tag_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + admin_client + .trail(trail_id) + .access() + .for_role(tag_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(tag_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + tag_admin_client + .trail(trail_id) + .tags() + .add("legal") + .build_and_execute(&tag_admin_client) + .await?; + + let trail_after_tag_add = admin_client.trail(trail_id).get().await?; + println!( + "Registry after adding \"legal\": {:?}\n", + trail_after_tag_add.tags.tag_map + ); + ensure!(trail_after_tag_add.tags.contains_key("finance")); + ensure!(trail_after_tag_add.tags.contains_key("legal")); + + // FinanceWriter is scoped to the `finance` tag, which keeps that tag in use. + admin_client + .trail(trail_id) + .access() + .for_role(finance_writer_role) + .create( + PermissionSet::record_admin_permissions(), + Some(RoleTags::new(["finance"])), + ) + .build_and_execute(&admin_client) + .await?; + admin_client + .trail(trail_id) + .access() + .for_role(finance_writer_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(finance_writer_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + finance_writer_client + .trail(trail_id) + .records() + .add(Data::text("Tagged finance entry"), None, Some("finance".to_string())) + .build_and_execute(&finance_writer_client) + .await?; + + let remove_finance_attempt = tag_admin_client + .trail(trail_id) + .tags() + .remove("finance") + .build_and_execute(&tag_admin_client) + .await; + ensure!( + remove_finance_attempt.is_err(), + "a tag referenced by a role or record must not be removable" + ); + + tag_admin_client + .trail(trail_id) + .tags() + .remove("legal") + .build_and_execute(&tag_admin_client) + .await?; + + let trail_after_tag_remove = admin_client.trail(trail_id).get().await?; + println!( + "Registry after removing \"legal\": {:?}\n", + trail_after_tag_remove.tags.tag_map + ); + + ensure!(trail_after_tag_remove.tags.contains_key("finance")); + ensure!(!trail_after_tag_remove.tags.contains_key("legal")); + + Ok(()) +} diff --git a/examples/audit-trail/real-world/01_customs_clearance.rs b/examples/audit-trail/real-world/01_customs_clearance.rs new file mode 100644 index 00000000..4961bdfc --- /dev/null +++ b/examples/audit-trail/real-world/01_customs_clearance.rs @@ -0,0 +1,346 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! # Customs Clearance Example +//! +//! This example models a customs-clearance process for a single shipment. +//! +//! ## Actors +//! +//! - **Admin client**: Creates the trail and sets up all roles and capabilities. +//! - **Docs operator client**: Handles document submission (invoices, packing lists). Writes only `documents`-tagged +//! records. +//! - **Export broker client**: Files export declarations and records clearance decisions at the origin. Writes only +//! `export`-tagged records. +//! - **Import broker client**: Handles duty assessment and import clearance at the destination. Writes only +//! `import`-tagged records. +//! - **Inspector client**: Records the outcome of a customs physical inspection. Writes only `inspection`-tagged +//! records; the role is created mid-process when an inspection is triggered. +//! - **Supervisor client**: Updates the mutable trail metadata (processing status). No record-write permissions. +//! - **Locking admin client**: Freezes the trail once the shipment is fully cleared. +//! +//! ## How the trail is used +//! +//! - `immutable_metadata`: shipment and declaration identity +//! - `updatable_metadata`: the current customs-processing status +//! - record tags: `documents`, `export`, `import`, and `inspection` +//! - roles and capabilities: each operational role writes only the events it owns +//! - locking: writes are frozen once the shipment is fully cleared + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, + TimeLock, +}; +use examples::{get_funded_audit_trail_client, issue_tagged_record_role}; +use product_common::core_client::CoreClient; +use sha2::{Digest, Sha256}; + +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Customs Clearance ===\n"); + + let admin_client = get_funded_audit_trail_client().await?; + let docs_operator_client = get_funded_audit_trail_client().await?; + let export_broker_client = get_funded_audit_trail_client().await?; + let import_broker_client = get_funded_audit_trail_client().await?; + let supervisor_client = get_funded_audit_trail_client().await?; + let locking_admin_client = get_funded_audit_trail_client().await?; + let inspector_client = get_funded_audit_trail_client().await?; + + // === Create the customs-clearance trail === + + println!("Creating a customs-clearance trail..."); + + let created_trail = admin_client + .create_trail() + .with_record_tags(["documents", "export", "import", "inspection"]) + .with_trail_metadata(ImmutableMetadata::new( + "Shipment SHP-2026-CLEAR-001".to_string(), + Some("Route: Hamburg, Germany -> Nairobi, Kenya | Declaration: DEC-2026-44017".to_string()), + )) + .with_updatable_metadata("Status: Documents Pending") + .with_locking_config(LockingConfig { + delete_record_window: LockingWindow::CountBased { count: 2 }, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + }) + .with_initial_record(InitialRecord::new( + Data::text("Customs clearance case opened for inbound shipment"), + Some("event:case_opened".to_string()), + Some("documents".to_string()), + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + + // === Set up roles and capabilities for each actor === + + // The Admin capability delegates one tag-scoped writer role per operational actor. + issue_tagged_record_role( + &admin_client, + trail_id, + "DocsOperator", + "documents", + docs_operator_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &admin_client, + trail_id, + "ExportBroker", + "export", + export_broker_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &admin_client, + trail_id, + "ImportBroker", + "import", + import_broker_client.sender_address(), + ) + .await?; + + let supervisor_role = "Supervisor"; + admin_client + .trail(trail_id) + .access() + .for_role(supervisor_role) + .create(PermissionSet::metadata_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + admin_client + .trail(trail_id) + .access() + .for_role(supervisor_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(supervisor_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + let locking_admin_role = "LockingAdmin"; + admin_client + .trail(trail_id) + .access() + .for_role(locking_admin_role) + .create(PermissionSet::locking_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + admin_client + .trail(trail_id) + .access() + .for_role(locking_admin_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(locking_admin_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + // === Document submission === + + // Documents are stored off-chain in an access-controlled environment (e.g. a TWIN node). + // Only the SHA-256 fingerprint is committed on-chain for tamper-evidence. + let invoice_hash = Sha256::digest(b"invoice-SHP-2026-CLEAR-001-v1.pdf"); + let docs_uploaded = docs_operator_client + .trail(trail_id) + .records() + .add( + Data::bytes(invoice_hash.to_vec()), + Some("event:documents_uploaded".to_string()), + Some("documents".to_string()), + ) + .build_and_execute(&docs_operator_client) + .await? + .output; + + println!("Docs operator added record #{}.\n", docs_uploaded.sequence_number); + + supervisor_client + .trail(trail_id) + .update_metadata(Some("Status: Awaiting Export Clearance".to_string())) + .build_and_execute(&supervisor_client) + .await?; + + // === Export clearance === + + let export_filed = export_broker_client + .trail(trail_id) + .records() + .add( + Data::text("Export declaration filed with German customs"), + Some("event:export_declaration_filed".to_string()), + Some("export".to_string()), + ) + .build_and_execute(&export_broker_client) + .await? + .output; + + let export_cleared = export_broker_client + .trail(trail_id) + .records() + .add( + Data::text("Export clearance granted by Hamburg customs office"), + Some("event:export_cleared".to_string()), + Some("export".to_string()), + ) + .build_and_execute(&export_broker_client) + .await? + .output; + + println!( + "Export broker added records #{} and #{}.\n", + export_filed.sequence_number, export_cleared.sequence_number + ); + + supervisor_client + .trail(trail_id) + .update_metadata(Some("Status: Awaiting Import Clearance".to_string())) + .build_and_execute(&supervisor_client) + .await?; + + // === Inspection gate === + + // The import broker does not hold an inspection-scoped capability at this point. + // The write attempt must fail to prove that tag-based access control is enforced. + let denied_inspection_attempt = import_broker_client + .trail(trail_id) + .records() + .add( + Data::text("Import broker attempted to record an inspection result"), + Some("event:invalid_inspection_write".to_string()), + Some("inspection".to_string()), + ) + .build_and_execute(&import_broker_client) + .await; + + ensure!( + denied_inspection_attempt.is_err(), + "inspection-tagged writes should fail before an inspection-scoped capability exists" + ); + println!("Inspection write was correctly denied before the inspector role existed.\n"); + + // A customs inspection is triggered; the inspector role is created and issued mid-process. + issue_tagged_record_role( + &admin_client, + trail_id, + "Inspector", + "inspection", + inspector_client.sender_address(), + ) + .await?; + + let inspection_done = inspector_client + .trail(trail_id) + .records() + .add( + Data::text("Customs inspection completed with no discrepancies"), + Some("event:inspection_completed".to_string()), + Some("inspection".to_string()), + ) + .build_and_execute(&inspector_client) + .await? + .output; + + println!("Inspector added record #{}.\n", inspection_done.sequence_number); + + // === Import clearance === + + let duty_assessed = import_broker_client + .trail(trail_id) + .records() + .add( + Data::text("Import duty assessed and paid"), + Some("event:duty_assessed".to_string()), + Some("import".to_string()), + ) + .build_and_execute(&import_broker_client) + .await? + .output; + + let import_cleared = import_broker_client + .trail(trail_id) + .records() + .add( + Data::text("Import clearance granted by Nairobi customs"), + Some("event:import_cleared".to_string()), + Some("import".to_string()), + ) + .build_and_execute(&import_broker_client) + .await? + .output; + + println!( + "Import broker added records #{} and #{}.\n", + duty_assessed.sequence_number, import_cleared.sequence_number + ); + + supervisor_client + .trail(trail_id) + .update_metadata(Some("Status: Cleared".to_string())) + .build_and_execute(&supervisor_client) + .await?; + + // === Final lock and verification === + + locking_admin_client + .trail(trail_id) + .locking() + .update_write_lock(TimeLock::Infinite) + .build_and_execute(&locking_admin_client) + .await?; + + let trail_after_lock = admin_client.trail(trail_id).get().await?; + println!( + "Write lock after clearance: {:?}\n", + trail_after_lock.locking_config.write_lock + ); + + let late_note_attempt = docs_operator_client + .trail(trail_id) + .records() + .add( + Data::text("Late customs note after the case was closed"), + Some("event:late_note".to_string()), + Some("documents".to_string()), + ) + .build_and_execute(&docs_operator_client) + .await; + + ensure!( + late_note_attempt.is_err(), + "cleared customs trail should reject late writes after the final lock" + ); + + let admin_trail = admin_client.trail(trail_id); + let customs_records_page = admin_trail.records().list_page(None, 20).await?; + + println!("Recorded customs events:"); + for (sequence_number, record) in &customs_records_page.records { + println!( + " #{} | {:?} | tag={:?} | {:?}", + sequence_number, record.data, record.tag, record.metadata + ); + } + + ensure!( + customs_records_page.records.len() == 7, + "expected 7 customs records including the initial case-opened record" + ); + ensure!( + admin_trail.get().await?.updatable_metadata.as_deref() == Some("Status: Cleared"), + "customs case should finish in cleared state" + ); + + println!("\nCustoms clearance completed successfully."); + + Ok(()) +} diff --git a/examples/audit-trail/real-world/02_clinical_trial.rs b/examples/audit-trail/real-world/02_clinical_trial.rs new file mode 100644 index 00000000..f32eb808 --- /dev/null +++ b/examples/audit-trail/real-world/02_clinical_trial.rs @@ -0,0 +1,385 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! # Clinical Trial Data-Integrity Example +//! +//! This example models a Phase III clinical trial where an immutable audit trail +//! guarantees data integrity, role-scoped access, and time-constrained oversight. +//! +//! ## Actors +//! +//! - **Admin client**: Creates the trail and sets up all roles and capabilities. +//! - **Enroller client**: Writes enrollment events. Restricted to the `enrollment` tag. +//! - **Safety officer client**: Records adverse events and safety observations. Restricted to `safety`. +//! - **Efficacy reviewer client**: Records treatment outcomes. Restricted to `efficacy`. +//! - **PK analyst client**: Records pharmacokinetic results. Restricted to the `pk` tag that is added mid-study when a +//! PK sub-study is initiated. +//! - **Monitor client**: Updates the mutable study-phase metadata. Access is time-windowed to the active study period +//! (90 days from now). +//! - **Data safety board client**: Controls write and delete locks. Freezes the dataset after review. +//! - **Regulator client**: Read-only verifier. In production this would use `AuditTrailClientReadOnly` (no signing +//! key); here a funded client is used to keep the example self-contained. +//! +//! ## How the trail is used +//! +//! - `immutable_metadata`: protocol identity and study description +//! - `updatable_metadata`: current study phase (updated as the trial progresses) +//! - record tags: `enrollment`, `safety`, `efficacy`, `pk` (added mid-study) +//! - roles and capabilities: each role writes only its designated tag +//! - time-constrained capabilities: Monitor access is windowed to the study period +//! - locking: a deletion window protects recent records; a time-lock freezes the dataset after the Data Safety Board +//! completes its review +//! - read-only verification: a regulator inspects the trail without write access + +use anyhow::{Result, ensure}; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, + TimeLock, +}; +use examples::{get_funded_audit_trail_client, issue_tagged_record_role}; +use product_common::core_client::CoreClient; + +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Clinical Trial Data Integrity ===\n"); + + let admin_client = get_funded_audit_trail_client().await?; + let enroller_client = get_funded_audit_trail_client().await?; + let safety_officer_client = get_funded_audit_trail_client().await?; + let efficacy_reviewer_client = get_funded_audit_trail_client().await?; + let pk_analyst_client = get_funded_audit_trail_client().await?; + let monitor_client = get_funded_audit_trail_client().await?; + let data_safety_board_client = get_funded_audit_trail_client().await?; + let regulator_client = get_funded_audit_trail_client().await?; + + // ----------------------------------------------------------------------- + // 1. Create the trial trail + // ----------------------------------------------------------------------- + println!("Creating the clinical-trial audit trail..."); + + let created_trail = admin_client + .create_trail() + .with_record_tags(["enrollment", "safety", "efficacy"]) + .with_trail_metadata(ImmutableMetadata::new( + "Protocol CTR-2026-03742".to_string(), + Some("Phase III: Efficacy of Drug X vs Placebo in Moderate-to-Severe Asthma".to_string()), + )) + .with_updatable_metadata("Phase: Enrollment") + .with_locking_config(LockingConfig { + delete_record_window: LockingWindow::CountBased { count: 3 }, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + }) + .with_initial_record(InitialRecord::new( + Data::text("Clinical trial CTR-2026-03742 opened for enrollment"), + Some("event:trial_opened".to_string()), + Some("enrollment".to_string()), + )) + .finish()? + .build_and_execute(&admin_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + println!("Trail created with ID {trail_id}\n"); + + // ----------------------------------------------------------------------- + // 2. Define roles with tag-scoped permissions + // ----------------------------------------------------------------------- + println!("Defining study roles..."); + + // The Admin capability delegates one tag-scoped writer role per study function. + issue_tagged_record_role( + &admin_client, + trail_id, + "Enroller", + "enrollment", + enroller_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &admin_client, + trail_id, + "SafetyOfficer", + "safety", + safety_officer_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &admin_client, + trail_id, + "EfficacyReviewer", + "efficacy", + efficacy_reviewer_client.sender_address(), + ) + .await?; + + // Monitor can update metadata (study phase) but only during the study window. + let monitor_role = "Monitor"; + admin_client + .trail(trail_id) + .access() + .for_role(monitor_role) + .create(PermissionSet::metadata_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + // Monitor access is valid for 90 days from now. + let study_end_ms = now_ms + 90 * 24 * 60 * 60 * 1000; + + admin_client + .trail(trail_id) + .access() + .for_role(monitor_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(monitor_client.sender_address()), + valid_from_ms: Some(now_ms), + valid_until_ms: Some(study_end_ms), + }) + .build_and_execute(&admin_client) + .await?; + + println!("Monitor capability issued (valid for 90 days from now, ends at timestamp {study_end_ms})\n"); + + // Data Safety Board can manage locking. + let data_safety_board_role = "DataSafetyBoard"; + admin_client + .trail(trail_id) + .access() + .for_role(data_safety_board_role) + .create(PermissionSet::locking_admin_permissions(), None) + .build_and_execute(&admin_client) + .await?; + admin_client + .trail(trail_id) + .access() + .for_role(data_safety_board_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(data_safety_board_client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin_client) + .await?; + + // ----------------------------------------------------------------------- + // 3. Enrollment phase — add enrollment records + // ----------------------------------------------------------------------- + println!("--- Enrollment Phase ---"); + + let enrolled_record = enroller_client + .trail(trail_id) + .records() + .add( + Data::text("Patient P-101 enrolled at Site Hamburg"), + Some("event:patient_enrolled".to_string()), + Some("enrollment".to_string()), + ) + .build_and_execute(&enroller_client) + .await? + .output; + println!("Enroller added record #{}.\n", enrolled_record.sequence_number); + + // ----------------------------------------------------------------------- + // 4. Add safety and efficacy records + // ----------------------------------------------------------------------- + println!("--- Study Data Collection ---"); + + let safety_event_record = safety_officer_client + .trail(trail_id) + .records() + .add( + Data::text("Adverse event: mild headache reported by Patient P-101"), + Some("event:adverse_event".to_string()), + Some("safety".to_string()), + ) + .build_and_execute(&safety_officer_client) + .await? + .output; + + let efficacy_outcome_record = efficacy_reviewer_client + .trail(trail_id) + .records() + .add( + Data::text("Week 12: FEV1 improvement of 320 mL over baseline for P-101"), + Some("event:efficacy_observed".to_string()), + Some("efficacy".to_string()), + ) + .build_and_execute(&efficacy_reviewer_client) + .await? + .output; + + println!( + "SafetyOfficer added record #{}, EfficacyReviewer added record #{}.\n", + safety_event_record.sequence_number, efficacy_outcome_record.sequence_number + ); + + // ----------------------------------------------------------------------- + // 5. Add a new tag mid-study (pharmacokinetics) + // ----------------------------------------------------------------------- + println!("--- Mid-Study Amendment ---"); + + // Admin adds the new tag and creates a role for the PK analyst. + admin_client + .trail(trail_id) + .tags() + .add("pk") + .build_and_execute(&admin_client) + .await?; + println!("Added tag 'pk' (pharmacokinetics) to the trail."); + + issue_tagged_record_role( + &admin_client, + trail_id, + "PkAnalyst", + "pk", + pk_analyst_client.sender_address(), + ) + .await?; + + let pk_result_record = pk_analyst_client + .trail(trail_id) + .records() + .add( + Data::text("PK analysis: Cmax reached at 2.4 h, half-life 8.7 h"), + Some("event:pk_result".to_string()), + Some("pk".to_string()), + ) + .build_and_execute(&pk_analyst_client) + .await? + .output; + println!("PkAnalyst added record #{}.\n", pk_result_record.sequence_number); + + // ----------------------------------------------------------------------- + // 6. Deletion window protects recent records + // ----------------------------------------------------------------------- + println!("--- Deletion Window Enforcement ---"); + + let protected_delete_attempt = pk_analyst_client + .trail(trail_id) + .records() + .delete(pk_result_record.sequence_number) + .build_and_execute(&pk_analyst_client) + .await; + + ensure!( + protected_delete_attempt.is_err(), + "recent records must be protected by the count-based deletion window" + ); + println!( + "Record #{} is within the deletion window (newest 3) and cannot be deleted.\n", + pk_result_record.sequence_number + ); + + // ----------------------------------------------------------------------- + // 7. Monitor updates study phase metadata + // ----------------------------------------------------------------------- + println!("--- Metadata Update ---"); + + monitor_client + .trail(trail_id) + .update_metadata(Some("Phase: Data Review".to_string())) + .build_and_execute(&monitor_client) + .await?; + + let trail_after_phase_update = admin_client.trail(trail_id).get().await?; + println!( + "Study phase updated to: {:?}\n", + trail_after_phase_update.updatable_metadata + ); + + // ----------------------------------------------------------------------- + // 8. Data Safety Board locks the study dataset + // ----------------------------------------------------------------------- + println!("--- Data Safety Board Lock ---"); + + // Lock writes until a specific future timestamp (e.g. 1 year from now), + // after which the dataset becomes permanently locked. + let lock_until_ms = now_ms + 365 * 24 * 60 * 60 * 1000; // 1 year from now + + data_safety_board_client + .trail(trail_id) + .locking() + .update_write_lock(TimeLock::UnlockAtMs(lock_until_ms)) + .build_and_execute(&data_safety_board_client) + .await?; + + let locked_trail = admin_client.trail(trail_id).get().await?; + println!( + "Write lock set to UnlockAtMs({}) — writes blocked until that timestamp.\n", + lock_until_ms + ); + println!("Current locking config: {:?}\n", locked_trail.locking_config); + + // Also lock the trail from deletion permanently. + data_safety_board_client + .trail(trail_id) + .locking() + .update_delete_trail_lock(TimeLock::Infinite)? + .build_and_execute(&data_safety_board_client) + .await?; + + let final_locking_trail = admin_client.trail(trail_id).get().await?; + println!( + "Delete-trail lock set to {:?} — trail cannot be deleted.\n", + final_locking_trail.locking_config.delete_trail_lock + ); + + // ----------------------------------------------------------------------- + // 9. Regulator read-only verification + // ----------------------------------------------------------------------- + println!("--- Regulator Verification ---"); + + // In production the regulator would use AuditTrailClientReadOnly (no signer). + let regulator_trail = regulator_client.trail(trail_id); + + let on_chain_trail = regulator_trail.get().await?; + println!("Protocol: {:?}", on_chain_trail.immutable_metadata); + println!("Phase: {:?}", on_chain_trail.updatable_metadata); + println!("Roles: {:?}", on_chain_trail.roles.roles.keys().collect::>()); + println!( + "Tags: {:?}", + on_chain_trail.tags.tag_map.keys().collect::>() + ); + + let verified_records_page = regulator_trail.records().list_page(None, 20).await?; + println!("\nVerified records ({} total):", verified_records_page.records.len()); + for (seq, record) in &verified_records_page.records { + println!(" #{} | tag={:?} | {:?}", seq, record.tag, record.metadata); + } + + // ----------------------------------------------------------------------- + // 10. Assertions + // ----------------------------------------------------------------------- + ensure!( + verified_records_page.records.len() == 5, + "expected 5 records (initial + enrolled + safety + efficacy + pk)" + ); + ensure!( + on_chain_trail.tags.tag_map.contains_key("pk"), + "the 'pk' tag must exist after mid-study amendment" + ); + ensure!( + on_chain_trail.locking_config.delete_record_window == LockingWindow::CountBased { count: 3 }, + "deletion window must remain count-based with count 3" + ); + ensure!( + on_chain_trail.locking_config.delete_trail_lock == TimeLock::Infinite, + "delete-trail lock must be Infinite" + ); + ensure!( + matches!(on_chain_trail.locking_config.write_lock, TimeLock::UnlockAtMs(_)), + "write lock must be UnlockAtMs" + ); + ensure!( + on_chain_trail.updatable_metadata.as_deref() == Some("Phase: Data Review"), + "study phase must be 'Data Review'" + ); + + println!("\nClinical trial data-integrity verification completed successfully."); + + Ok(()) +} diff --git a/examples/audit-trail/real-world/03_digital_product_passport.rs b/examples/audit-trail/real-world/03_digital_product_passport.rs new file mode 100644 index 00000000..26e1f138 --- /dev/null +++ b/examples/audit-trail/real-world/03_digital_product_passport.rs @@ -0,0 +1,457 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! # Digital Product Passport Example +//! +//! This example models a Digital Product Passport (DPP) for an e-bike battery, +//! inspired by the public IOTA DPP demo. +//! +//! Scope note: this example stays within the Audit Trail package. The demo's wider +//! IOTA stack (Identity, Hierarchies, Tokenization, and Gas Station) is mapped +//! here onto audit-trail-native concepts: +//! +//! - product identity, bill of materials, reward policy, and service history are captured as immutable audit records +//! - service-network authorization is represented through role-scoped capabilities +//! - Lifecycle Credit (LCC) payouts are documented as reward records rather than executed as token transfers +//! +//! ## Actors +//! +//! - **Manufacturer client**: Creates the DPP, publishes manufacturing data, and administers roles and capabilities. +//! - **Lifecycle manager client**: Updates the mutable lifecycle-stage metadata. +//! - **Distributor client**: Writes logistics and handover records. +//! - **Consumer client**: Writes the commissioning / in-use activation record. +//! - **Service technician client**: Reviews the passport, requests write access, and records the maintenance event once +//! authorized. +//! - **Recycler client**: Prepared for future end-of-life events through a recycling-scoped capability. +//! - **EPRO client**: Records reward policy and the reward-payout evidence for verified maintenance. +//! +//! ## How the trail is used as a DPP +//! +//! - `immutable_metadata`: product identity for the battery passport +//! - `updatable_metadata`: current lifecycle stage +//! - record tags: `manufacturing`, `logistics`, `ownership`, `maintenance`, `recycling`, `rewards` +//! - roles and capabilities: each actor can write only its assigned slice of the lifecycle +//! - access-request flow: the technician is denied maintenance writes until the manufacturer issues the scoped +//! capability +//! - service evidence: the maintenance event mirrors the demo's "Annual Maintenance" / "Health Snapshot" pattern with a +//! 76% health score and a 1-LCC reward record + +use anyhow::{Result, ensure}; +use audit_trails::AuditTrailClient; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet, RoleTags, +}; +use examples::{get_funded_audit_trail_client, issue_tagged_record_role}; +use iota_sdk::types::base_types::{IotaAddress, ObjectID}; +use product_common::core_client::CoreClient; +use product_common::test_utils::InMemSigner; + +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Digital Product Passport ===\n"); + + let manufacturer_client = get_funded_audit_trail_client().await?; + let lifecycle_manager_client = get_funded_audit_trail_client().await?; + let distributor_client = get_funded_audit_trail_client().await?; + let consumer_client = get_funded_audit_trail_client().await?; + let service_technician_client = get_funded_audit_trail_client().await?; + let recycler_client = get_funded_audit_trail_client().await?; + let epro_client = get_funded_audit_trail_client().await?; + + println!("Manufacturer wallet: {}", manufacturer_client.sender_address()); + println!( + "Lifecycle manager wallet: {}", + lifecycle_manager_client.sender_address() + ); + println!("Distributor wallet: {}", distributor_client.sender_address()); + println!("Consumer wallet: {}", consumer_client.sender_address()); + println!( + "Service technician wallet: {}", + service_technician_client.sender_address() + ); + println!("Recycler wallet: {}", recycler_client.sender_address()); + println!("EPRO wallet: {}\n", epro_client.sender_address()); + + // --------------------------------------------------------------------- + // 1. Create the DPP audit trail + // --------------------------------------------------------------------- + println!("Creating the DPP trail for EcoBike's battery..."); + + let created_trail = manufacturer_client + .create_trail() + .with_record_tags([ + "manufacturing", + "logistics", + "ownership", + "maintenance", + "recycling", + "rewards", + ]) + .with_trail_metadata(ImmutableMetadata::new( + "DPP: Pro 48V Battery".to_string(), + Some("Manufacturer: EcoBike | Serial: EB-48V-2024-001337".to_string()), + )) + .with_updatable_metadata("Lifecycle Stage: Manufactured") + .with_initial_record(InitialRecord::new( + Data::text( + "event=dpp_created\nproduct_name=Pro 48V Battery\nserial_number=EB-48V-2024-001337\nmanufacturer=EcoBike", + ), + Some("event:dpp_created".to_string()), + Some("manufacturing".to_string()), + )) + .finish()? + .build_and_execute(&manufacturer_client) + .await? + .output; + + let trail_id = created_trail.trail_id; + println!("Trail created with ID {trail_id}\n"); + + // --------------------------------------------------------------------- + // 2. Define DPP roles and issue capabilities + // --------------------------------------------------------------------- + println!("Configuring DPP actor roles..."); + + // The manufacturer keeps the built-in Admin capability and delegates scoped lifecycle roles. + issue_tagged_record_role( + &manufacturer_client, + trail_id, + "Manufacturer", + "manufacturing", + manufacturer_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &manufacturer_client, + trail_id, + "Distributor", + "logistics", + distributor_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &manufacturer_client, + trail_id, + "Consumer", + "ownership", + consumer_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &manufacturer_client, + trail_id, + "Recycler", + "recycling", + recycler_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &manufacturer_client, + trail_id, + "EPRO", + "rewards", + epro_client.sender_address(), + ) + .await?; + + let service_technician_role = "ServiceTechnician"; + manufacturer_client + .trail(trail_id) + .access() + .for_role(service_technician_role) + .create( + PermissionSet::record_admin_permissions(), + Some(RoleTags::new(["maintenance"])), + ) + .build_and_execute(&manufacturer_client) + .await?; + + issue_metadata_role( + &manufacturer_client, + trail_id, + "LifecycleManager", + lifecycle_manager_client.sender_address(), + ) + .await?; + + // --------------------------------------------------------------------- + // 3. Prepare the passport with lifecycle context from the DPP demo + // --------------------------------------------------------------------- + println!("Publishing product details, service-network context, and reward policy..."); + + manufacturer_client + .trail(trail_id) + .records() + .add( + Data::text( + "event=product_details_published\nproduct_name=Pro 48V Battery\nserial_number=EB-48V-2024-001337\nmanufacturer=EcoBike\nmanufacturer_did=did:iota:testnet:0xdc704ab63984d5763576c12ce5f62fe735766bc1fc9892a5e2a7be777a9af897\nbattery_details=48V removable e-bike battery with smart BMS\nbill_of_materials=cathode:NMC811;anode:graphite;housing:recycled_aluminum;bms:BMS-v3\ncompliance=CE,RoHS,UN38.3\nsustainability=recycled_aluminum_housing:35%\nservice_network=EcoBike certified service network", + ), + Some("event:product_details_published".to_string()), + Some("manufacturing".to_string()), + ) + .build_and_execute(&manufacturer_client) + .await?; + + epro_client + .trail(trail_id) + .records() + .add( + Data::text( + "event=reward_policy_published\nreward_type=LCC\nannual_maintenance_reward=1 LCC\nrecycling_reward=10 LCC\nfinal_owner_reward=10 LCC\nmanufacturer_return_reward=10 LCC\nend_of_life_bundle=30 LCC\nsettlement_operator=EcoCycle EPRO", + ), + Some("event:reward_policy_published".to_string()), + Some("rewards".to_string()), + ) + .build_and_execute(&epro_client) + .await?; + + lifecycle_manager_client + .trail(trail_id) + .update_metadata(Some("Lifecycle Stage: In Distribution".to_string())) + .build_and_execute(&lifecycle_manager_client) + .await?; + + distributor_client + .trail(trail_id) + .records() + .add( + Data::text( + "event=distributed\nshipment_id=SHIP-EB-2026-0042\ntracking_status=Delivered to Nairobi certified service region\ntransport_certification=ADR-compliant battery transport", + ), + Some("event:distributed".to_string()), + Some("logistics".to_string()), + ) + .build_and_execute(&distributor_client) + .await?; + + lifecycle_manager_client + .trail(trail_id) + .update_metadata(Some("Lifecycle Stage: In Use".to_string())) + .build_and_execute(&lifecycle_manager_client) + .await?; + + consumer_client + .trail(trail_id) + .records() + .add( + Data::text( + "event=commissioned\nowner_profile=Urban commuter fleet\nusage_status=Battery commissioned for daily e-bike service\nrepair_options=EcoBike certified annual maintenance available", + ), + Some("event:commissioned".to_string()), + Some("ownership".to_string()), + ) + .build_and_execute(&consumer_client) + .await?; + + // --------------------------------------------------------------------- + // 4. Technician reviews history and requests maintenance access + // --------------------------------------------------------------------- + println!("Technician reviews the current DPP history..."); + + let history_before_service = service_technician_client + .trail(trail_id) + .records() + .list_page(None, 20) + .await?; + println!( + "Technician can already read {} public DPP records.\n", + history_before_service.records.len() + ); + + let denied_before_grant = service_technician_client + .trail(trail_id) + .records() + .add( + Data::text("event=unauthorized_maintenance_attempt"), + Some("event:unauthorized_maintenance_attempt".to_string()), + Some("maintenance".to_string()), + ) + .build_and_execute(&service_technician_client) + .await; + + ensure!( + denied_before_grant.is_err(), + "maintenance writes must fail until the technician is explicitly authorized" + ); + println!("Maintenance write denied before access grant, as expected.\n"); + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let technician_valid_until_ms = now_ms + 30 * 24 * 60 * 60 * 1000; + + // The technician can read the passport before authorization, but needs a maintenance capability to write. + let service_technician_capability = manufacturer_client + .trail(trail_id) + .access() + .for_role(service_technician_role) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(service_technician_client.sender_address()), + valid_from_ms: Some(now_ms), + valid_until_ms: Some(technician_valid_until_ms), + }) + .build_and_execute(&manufacturer_client) + .await? + .output; + + println!( + "Issued ServiceTechnician capability {} (valid until {}).\n", + service_technician_capability.capability_id, technician_valid_until_ms + ); + + lifecycle_manager_client + .trail(trail_id) + .update_metadata(Some("Lifecycle Stage: Maintenance In Progress".to_string())) + .build_and_execute(&lifecycle_manager_client) + .await?; + + // --------------------------------------------------------------------- + // 5. Perform the maintenance event described in the DPP demo + // --------------------------------------------------------------------- + println!("Recording the annual maintenance event..."); + + let maintenance_event_record = service_technician_client + .trail(trail_id) + .records() + .add( + Data::text( + "entry_type=Annual Maintenance\nservice_action=Health Snapshot\nhealth_score=76%\nfindings=Routine maintenance completed successfully\nwork_performed=Battery contacts cleaned; cell balance check passed; firmware diagnostics passed\nnext_service_due=2027-04-20", + ), + Some("event:annual_maintenance".to_string()), + Some("maintenance".to_string()), + ) + .build_and_execute(&service_technician_client) + .await? + .output; + + println!( + "Service technician added maintenance record #{}.\n", + maintenance_event_record.sequence_number + ); + + let reward_event_record = epro_client + .trail(trail_id) + .records() + .add( + Data::text(format!( + "event=lcc_reward_distributed\ntrigger_record={}\nreward_type=LCC\namount=1\nreason=Annual maintenance completed\nbeneficiary={}", + maintenance_event_record.sequence_number, + service_technician_client.sender_address() + )), + Some("event:lcc_reward_distributed".to_string()), + Some("rewards".to_string()), + ) + .build_and_execute(&epro_client) + .await? + .output; + + println!( + "EPRO added reward record #{} for the verified maintenance event.\n", + reward_event_record.sequence_number + ); + + lifecycle_manager_client + .trail(trail_id) + .update_metadata(Some( + "Lifecycle Stage: Maintained and Ready for Continued Use".to_string(), + )) + .build_and_execute(&lifecycle_manager_client) + .await?; + + // --------------------------------------------------------------------- + // 6. Verify the prepared DPP state + // --------------------------------------------------------------------- + println!("Verifying the resulting DPP..."); + + let on_chain_passport = manufacturer_client.trail(trail_id).get().await?; + let passport_records_page = manufacturer_client + .trail(trail_id) + .records() + .list_page(None, 20) + .await?; + + println!("Recorded DPP events:"); + for (sequence_number, record) in &passport_records_page.records { + println!( + " #{} | tag={:?} | metadata={:?}", + sequence_number, record.tag, record.metadata + ); + } + + ensure!( + passport_records_page.records.len() == 7, + "expected 7 DPP records (initial + product details + reward policy + distribution + commissioning + maintenance + reward payout)" + ); + ensure!( + on_chain_passport.tags.tag_map.contains_key("maintenance") + && on_chain_passport.tags.tag_map.contains_key("recycling") + && on_chain_passport.tags.tag_map.contains_key("rewards"), + "expected the DPP tag registry to contain maintenance, recycling, and rewards" + ); + ensure!( + on_chain_passport.roles.roles.contains_key("Manufacturer") + && on_chain_passport.roles.roles.contains_key("Distributor") + && on_chain_passport.roles.roles.contains_key("Consumer") + && on_chain_passport.roles.roles.contains_key("ServiceTechnician") + && on_chain_passport.roles.roles.contains_key("Recycler") + && on_chain_passport.roles.roles.contains_key("EPRO") + && on_chain_passport.roles.roles.contains_key("LifecycleManager"), + "expected all DPP roles to be registered" + ); + ensure!( + on_chain_passport.updatable_metadata.as_deref() + == Some("Lifecycle Stage: Maintained and Ready for Continued Use"), + "expected the DPP lifecycle stage to reflect the completed maintenance event" + ); + + let maintenance_record = passport_records_page + .records + .iter() + .find(|(_, record)| record.metadata.as_deref() == Some("event:annual_maintenance")); + ensure!( + maintenance_record.is_some(), + "expected the maintenance record to be present in the DPP history" + ); + + let reward_record = passport_records_page + .records + .iter() + .find(|(_, record)| record.metadata.as_deref() == Some("event:lcc_reward_distributed")); + ensure!( + reward_record.is_some(), + "expected the reward payout record to be present in the DPP history" + ); + + println!("\nDigital Product Passport scenario completed successfully."); + + Ok(()) +} + +async fn issue_metadata_role( + admin_client: &AuditTrailClient, + trail_id: ObjectID, + role_name: &str, + issued_to: IotaAddress, +) -> Result<()> { + admin_client + .trail(trail_id) + .access() + .for_role(role_name) + .create(PermissionSet::metadata_admin_permissions(), None) + .build_and_execute(admin_client) + .await?; + + admin_client + .trail(trail_id) + .access() + .for_role(role_name) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(issued_to), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(admin_client) + .await?; + + Ok(()) +} diff --git a/examples/audit-trail/run.sh b/examples/audit-trail/run.sh new file mode 100755 index 00000000..8cf9c6cb --- /dev/null +++ b/examples/audit-trail/run.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Script to run all audit trail examples +# Usage: ./run.sh +# Make sure to set IOTA_AUDIT_TRAIL_PKG_ID and IOTA_TF_COMPONENTS_PKG_ID environment variables + +if [[ -z $IOTA_AUDIT_TRAIL_PKG_ID || -z $IOTA_TF_COMPONENTS_PKG_ID ]]; then + echo "Error: IOTA_AUDIT_TRAIL_PKG_ID environment variable is not set" + echo "Usage: IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... ./run.sh" + echo "" + echo "On localnet, you can set both variables using:" + echo " eval \$(./audit-trail-move/scripts/publish_package.sh)" + exit 1 +fi + +echo "Running all audit trail examples..." +echo "AuditTrail Package ID: $IOTA_AUDIT_TRAIL_PKG_ID" +echo "TfComponents Package ID: $IOTA_TF_COMPONENTS_PKG_ID" +echo "================================" + +examples=( + "01_create_audit_trail" + "02_add_and_read_records" + "03_update_metadata" + "04_configure_locking" + "05_manage_access" + "06_delete_records" + "07_access_read_only_methods" + "08_delete_audit_trail" + "09_tagged_records" + "10_capability_constraints" + "11_manage_record_tags" + "01_customs_clearance" + "02_clinical_trial" + "03_digital_product_passport" +) + +for example in "${examples[@]}"; do + echo "" + echo "Running Audit Trail: $example" + echo "------------------------" + cargo run --release --example "$example" + if [ $? -ne 0 ]; then + echo "Error: Failed to run $example" + exit 1 + fi +done + +echo "" +echo "All Audit Trail examples completed successfully!" diff --git a/examples/real-world/01_iot_weather_station.rs b/examples/real-world/01_iot_weather_station.rs index 8930c7ee..f4268019 100644 --- a/examples/real-world/01_iot_weather_station.rs +++ b/examples/real-world/01_iot_weather_station.rs @@ -19,7 +19,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::State; use serde_json::json; @@ -28,7 +28,7 @@ async fn main() -> Result<()> { println!("🌡️ IoT Weather Station - Dynamic Notarization Example"); println!("=====================================================\n"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); diff --git a/examples/real-world/02_legal_contract.rs b/examples/real-world/02_legal_contract.rs index 5678390a..ea7adad0 100644 --- a/examples/real-world/02_legal_contract.rs +++ b/examples/real-world/02_legal_contract.rs @@ -19,7 +19,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{State, TimeLock}; use serde_json::json; use sha2::{Digest, Sha256}; @@ -29,7 +29,7 @@ async fn main() -> Result<()> { println!("⚖️ Legal Contract - Locked Notarization Example"); println!("===============================================\n"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Get current timestamp let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); diff --git a/examples/run.sh b/examples/run.sh index 56ff40ef..4579119f 100755 --- a/examples/run.sh +++ b/examples/run.sh @@ -1,5 +1,22 @@ #!/bin/bash +# Script to run all examples contained in this directory +# Usage: ./run.sh +# Make sure to set the following environment variables: +# - IOTA_NOTARIZATION_PKG_ID: The package ID of the notarization module +# - IOTA_AUDIT_TRAIL_PKG_ID: The package ID of the audit trail module +# - IOTA_TF_COMPONENTS_PKG_ID: The package ID of the tf components module + +./examples/audit-trail/run.sh +printf "\n================================\n" +printf "================================\n\n" + +# At the moment the `examples` folder contains all single notarization (SN) examples. +# After a `notarization` folder has been introduced to contain the SN examples, +# uncomment the following line, update the paths in Cargo.toml and move the bash code below that line into `./examples/notarization/run.sh`. + +# ./examples/notarization/run.sh + # Script to run all notarization examples # Usage: ./run.sh # Make sure to set IOTA_NOTARIZATION_PKG_ID environment variable @@ -10,7 +27,7 @@ if [ -z "$IOTA_NOTARIZATION_PKG_ID" ]; then exit 1 fi -echo "Running all notarization examples..." +echo "Running all Notarization examples..." echo "Package ID: $IOTA_NOTARIZATION_PKG_ID" echo "================================" @@ -29,7 +46,7 @@ examples=( for example in "${examples[@]}"; do echo "" - echo "Running: $example" + echo "Running Notarization Example: $example" echo "------------------------" cargo run --release --example "$example" if [ $? -ne 0 ]; then @@ -39,4 +56,4 @@ for example in "${examples[@]}"; do done echo "" -echo "All examples completed successfully!" +echo "All Notarization examples completed successfully!" diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index 5f918f9c..a24179be 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -1,38 +1,109 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use anyhow::Context; +use audit_trails::core::types::{CapabilityIssueOptions, PermissionSet, RoleTags}; +use audit_trails::{AuditTrailClient, PackageOverrides}; +use iota_sdk::types::base_types::{IotaAddress, ObjectID}; use iota_sdk::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; use notarization::client::{NotarizationClient, NotarizationClientReadOnly}; use product_common::test_utils::{InMemSigner, request_funds}; -pub async fn get_read_only_client() -> anyhow::Result { +async fn get_iota_client() -> anyhow::Result { let api_endpoint = std::env::var("API_ENDPOINT").unwrap_or_else(|_| IOTA_LOCAL_NETWORK_URL.to_string()); - let iota_client = IotaClientBuilder::default() + IotaClientBuilder::default() .build(&api_endpoint) .await - .map_err(|err| anyhow::anyhow!(format!("failed to connect to network; {}", err)))?; + .map_err(|err| anyhow::anyhow!("failed to connect to network; {}", err)) +} - let package_id = std::env::var("IOTA_NOTARIZATION_PKG_ID") - .map_err(|e| { - anyhow::anyhow!("env variable IOTA_NOTARIZATION_PKG_ID must be set in order to run the examples").context(e) - }) - .and_then(|pkg_str| pkg_str.parse().context("invalid package id"))?; +fn get_package_id_from_env(env_var_name: &str) -> anyhow::Result { + let value = std::env::var(env_var_name) + .with_context(|| format!("env variable '{env_var_name}' must be set in order to run the examples"))?; + + value + .parse() + .with_context(|| format!("invalid package id in {env_var_name}")) +} + +pub async fn get_notarization_read_only_client() -> anyhow::Result { + let iota_client = get_iota_client().await?; + + let package_id = get_package_id_from_env("IOTA_NOTARIZATION_PKG_ID")?; NotarizationClientReadOnly::new_with_pkg_id(iota_client, package_id) .await .context("failed to create a read-only NotarizationClient") } -pub async fn get_funded_client() -> Result, anyhow::Error> { +pub async fn get_funded_notarization_client() -> Result, anyhow::Error> { let signer = InMemSigner::new(); let sender_address = signer.get_address().await?; request_funds(&sender_address).await?; - let read_only_client = get_read_only_client().await?; + let read_only_client = get_notarization_read_only_client().await?; let notarization_client: NotarizationClient = NotarizationClient::new(read_only_client, signer).await?; Ok(notarization_client) } + +pub async fn get_funded_audit_trail_client() -> Result, anyhow::Error> { + let iota_client = get_iota_client().await?; + + let audit_trail_pkg_id = get_package_id_from_env("IOTA_AUDIT_TRAIL_PKG_ID")?; + + let tf_components_pkg_id = get_package_id_from_env("IOTA_TF_COMPONENTS_PKG_ID")?; + + let client = AuditTrailClient::from_iota_client( + iota_client, + Some(PackageOverrides { + audit_trail: Some(audit_trail_pkg_id), + tf_component: Some(tf_components_pkg_id), + }), + ) + .await + .map_err(|e| anyhow::anyhow!("failed to create AuditTrailClient: {e}"))?; + + let signer = InMemSigner::new(); + let sender_address = signer.get_address().await?; + request_funds(&sender_address).await?; + + client + .with_signer(signer) + .await + .map_err(|e| anyhow::anyhow!("failed to attach signer to AuditTrailClient: {e}")) +} + +pub async fn issue_tagged_record_role( + client: &AuditTrailClient, + trail_id: ObjectID, + role_name: &str, + tag: &str, + issued_to: IotaAddress, +) -> Result<(), anyhow::Error> { + client + .trail(trail_id) + .access() + .for_role(role_name) + .create(PermissionSet::record_admin_permissions(), Some(RoleTags::new([tag]))) + .build_and_execute(client) + .await + .map_err(|e| anyhow::anyhow!("failed to create role '{role_name}': {e}"))?; + + client + .trail(trail_id) + .access() + .for_role(role_name) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(issued_to), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(client) + .await + .map_err(|e| anyhow::anyhow!("failed to issue capability for role '{role_name}': {e}"))?; + + Ok(()) +} diff --git a/notarization-move/CLAUDE.md b/notarization-move/CLAUDE.md new file mode 100644 index 00000000..ca5fe015 --- /dev/null +++ b/notarization-move/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md — Move guidelines for `notarization-move` + +## Documentation Style Guide + +Follow the guidelines in `../MOVE-DOC-STYLEGUIDE.md` and make sure to +follow all rules stated there. + +## No Capability based access control + +Ignore `Capability gating` related rules in `../MOVE-DOC-STYLEGUIDE.md` because +`notarization_wasm` only uses access control through Move object ownership. + +## Notarization-product-specific terminology and rules + +### Notarization Methods + +`Dynamic` and `Locked` are the **Notarization Methods**. Always refer to them +as Notarization Methods (or, where unambiguous, simply `method`). Do **not** +use synonyms such as "variant", "kind", "type", "behavioural variant", +"flavour", "mode", "sort", or similar. When mentioning a specific method, use +the bare enum name in backticks (`` `Dynamic` ``, `` `Locked` ``) — the full +path `NotarizationMethod::Dynamic` is not needed in prose. The compound terms +"Dynamic-Notarization" and "Locked-Notarization" refer to a `Notarization` +configured with the corresponding method. + +Use generic Notarization Method based descriptions if suitable. Do not reduce +the usage of Notarization Methods to the currently available variants +(like `... is dynamic or locked`) because in future versions there may be more +Notarization Method variants. Only explain a behavior using specific Notarization +Method variants if a function (or other item) is explicitly focussed (or limited) +to one or more specific Notarization Methods. + +### Method-dependent behavior must be a bullet list + +Whenever the behavior of a documented entity (function, struct, field, enum, +event, constant) differs between Notarization Methods, document the +differences as a Markdown bullet list with one bullet per method, in this +fixed order: + +```move +/// ... +/// Behaviour depends on the Notarization Method: +/// * `Dynamic`: . +/// * `Locked`: . +``` + +This format must be kept even when the rule for one method is trivial +("Always returns `false`.") so that future Notarization Methods can be added +as additional bullets without restructuring the surrounding prose. Never +collapse the per-method behavior into a single sentence such as "mutable +only for the dynamic method". diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index b1b9d6aa..66d19291 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -1,18 +1,18 @@ { "aliases": { + "devnet": "daf90477", "mainnet": "6364aad5", - "testnet": "2304aa97", - "devnet": "daf90477" + "testnet": "2304aa97" }, "envs": { - "6364aad5": [ - "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" - ], "2304aa97": [ "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" ], "daf90477": [ "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" + ], + "6364aad5": [ + "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" ] } } \ No newline at end of file diff --git a/notarization-move/Move.lock b/notarization-move/Move.lock index 3b99649c..c4df2d76 100644 --- a/notarization-move/Move.lock +++ b/notarization-move/Move.lock @@ -2,56 +2,35 @@ [move] version = 3 -manifest_digest = "2E3FF0C8C2529AC5F5521920800D3385F4B722FF03E524F5AF757E81CA710024" -deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" +manifest_digest = "F6A54EEF62E080B67E4ACC8AFED64941661C020EFF00A8D68A404001EC85DD35" +deps_digest = "F8BBB0CCB2491CA29A3DF03D6F92277A4F3574266507ACD77214D37ECA3F3082" dependencies = [ { id = "Iota", name = "Iota" }, - { id = "IotaSystem", name = "IotaSystem" }, - { id = "MoveStdlib", name = "MoveStdlib" }, - { id = "Stardust", name = "Stardust" }, ] [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-framework" } - -dependencies = [ - { id = "MoveStdlib", name = "MoveStdlib" }, -] - -[[move.package]] -id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ - { id = "Iota", name = "Iota" }, { id = "MoveStdlib", name = "MoveStdlib" }, ] [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/move-stdlib" } - -[[move.package]] -id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/stardust" } - -dependencies = [ - { id = "Iota", name = "Iota" }, - { id = "MoveStdlib", name = "MoveStdlib" }, -] +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/move-stdlib" } [move.toolchain-version] -compiler-version = "1.18.0-beta" +compiler-version = "1.16.2-rc" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "ecc0606a" -original-published-id = "0xfbddb4631d027b2c4f0b4b90c020713d258ed32bdb342b5397f4da71edb7478a" -latest-published-id = "0xfbddb4631d027b2c4f0b4b90c020713d258ed32bdb342b5397f4da71edb7478a" +chain-id = "4991e514" +original-published-id = "0x8d9e2e2f04101e66c778cfeef09a9fdc945b172cb9f550ace1d36b23ac536735" +latest-published-id = "0x8d9e2e2f04101e66c778cfeef09a9fdc945b172cb9f550ace1d36b23ac536735" published-version = "1" [env.devnet] diff --git a/notarization-move/Move.toml b/notarization-move/Move.toml index 93671c18..bb87dba8 100644 --- a/notarization-move/Move.toml +++ b/notarization-move/Move.toml @@ -6,6 +6,7 @@ name = "IotaNotarization" edition = "2024.beta" [dependencies] +Iota = { git = "https://github.com/iotaledger/iota.git", subdir = "crates/iota-framework/packages/iota-framework", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930" } [addresses] iota_notarization = "0x0" diff --git a/notarization-move/README.md b/notarization-move/README.md new file mode 100644 index 00000000..4c17fbdd --- /dev/null +++ b/notarization-move/README.md @@ -0,0 +1,86 @@ +![banner](https://github.com/iotaledger/notarization/raw/HEAD/.github/banner_notarization.png) + +

+ StackExchange + Discord + Apache 2.0 license +

+ +

+ Introduction ◈ + Modules ◈ + Development & Testing ◈ + Related Libraries ◈ + Contributing +

+ +--- + +# IOTA Notarization Move Package + +## Introduction + +`notarization-move` is the on-chain Move package behind IOTA Single Notarization. + +It defines the core `Notarization` object and the supporting modules for: + +- dynamic notarization flows +- locked notarization flows +- immutable creation metadata +- optional updatable metadata +- state updates, transfer rules, and destruction checks +- emitted events for notarization lifecycle changes + +## Modules + +- `iota_notarization::notarization` + Core object, state model, metadata, lock metadata, updates, and destruction logic. +- `iota_notarization::timelock` + Package-local timelock primitives used by notarization lock metadata. +- `iota_notarization::dynamic_notarization` + Dynamic notarization creation and transfer flows. +- `iota_notarization::locked_notarization` + Locked notarization creation flows with timelock controls. +- `iota_notarization::method` + Method discriminator helpers for dynamic and locked variants. + +## Development And Testing + +Build the Move package: + +```bash +cd notarization-move +iota move build +``` + +Run the Move test suite: + +```bash +cd notarization-move +iota move test +``` + +Publish locally: + +```bash +cd notarization-move +./scripts/publish_package.sh +``` + +The package history files [`Move.lock`](./Move.lock) and [`Move.history.json`](./Move.history.json) are used by the Rust package to resolve and track deployed package versions. + +## Related Libraries + +- [Rust Package](https://github.com/iotaledger/notarization/tree/main/notarization-rs/README.md) +- [Wasm Package](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm/README.md) +- [Repository Root](https://github.com/iotaledger/notarization/tree/main/README.md) + +## Contributing + +We would love to have you help us with the development of IOTA Notarization. Each and every contribution is greatly valued. + +Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). + +To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included. + +The best place to get involved in discussions about this package or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). diff --git a/notarization-move/api_mapping.toml b/notarization-move/api_mapping.toml new file mode 100644 index 00000000..7653ea87 --- /dev/null +++ b/notarization-move/api_mapping.toml @@ -0,0 +1,408 @@ +# Notarization API Mapping +# +# Maps each public Move function or struct in the `notarization-move/sources/` modules +# to the related Rust entities in `notarization-rs/src/` and WASM/TS entities in +# `bindings/wasm/notarization_wasm/src/`. +# +# TOML section keys are formed as `..`: +# - `` — the product identifier, derived from the basename of the +# Move package directory with any trailing `-move` stripped and `-` +# replaced by `_`. For this file: `notarization`. +# - `` — `main` for the Move source file whose basename matches +# the product name (`notarization.move` → `main`); for any other Move +# source file, the bare filename without extension (e.g. `method.move` +# → `method`). +# - `` — the function name or struct/enum/const name in that module. +# +# `rust` and `wasm` arrays list the Rust- resp. WASM-level functions, methods, +# and types that wrap, build, or otherwise correspond to the Move entity. +# Entry conventions: +# - `Type::method` — an inherent method on `Type` +# - `Type::Variant` — an enum variant +# - `Type` — a plain type/struct/enum +# - `Type.field` — a struct field +# - `module::function` — a free function +# +# An entry of `[]` means there is intentionally no counterpart on that side. +# +# This mapping is intended for automatic comparison of function and struct +# documentation across the three implementation layers, and is maintained via +# the `update-api-mapping` and `sync-product-docs` skills under `.claude/skills/`. + +# ============================================================================= +# Module: notarization::main (notarization-move/sources/notarization.move) +# ============================================================================= + +[notarization.main.Notarization] +rust = [ + "OnChainNotarization", + "NotarizationClientReadOnly::get_notarization_by_id", +] +wasm = [ + "WasmOnChainNotarization", +] + +[notarization.main.ImmutableMetadata] +rust = [ + "ImmutableMetadata", +] +wasm = [ + "WasmImmutableMetadata", +] + +[notarization.main.LockMetadata] +rust = [ + "LockMetadata", +] +wasm = [ + "WasmLockMetadata", +] + +[notarization.main.State] +rust = [ + "State", + "Data", + "State::data", + "State::metadata", + "State::from_bytes", + "State::from_string", + "State::as_bytes", + "State::as_text", +] +wasm = [ + "WasmState", + "WasmData", + "WasmState::data", + "WasmState::metadata", + "WasmState::from_string", + "WasmState::from_bytes", + "WasmData::value", + "WasmData::value_type", + "WasmData::value_byte_size", + "WasmData::to_string", + "WasmData::to_bytes", +] + +[notarization.main.NotarizationUpdated] +rust = [] +wasm = [] + +[notarization.main.NotarizationDestroyed] +rust = [] +wasm = [] + +[notarization.main.new_state_from_bytes] +rust = [ + "State::from_bytes", + "Data", + "NotarizationBuilder::with_bytes_state", +] +wasm = [ + "WasmState::from_bytes", + "WasmNotarizationBuilderLocked::with_bytes_state", + "WasmNotarizationBuilderDynamic::with_bytes_state", +] + +[notarization.main.new_state_from_string] +rust = [ + "State::from_string", + "Data", + "NotarizationBuilder::with_string_state", +] +wasm = [ + "WasmState::from_string", + "WasmNotarizationBuilderLocked::with_string_state", + "WasmNotarizationBuilderDynamic::with_string_state", +] + +[notarization.main.new_state_from_generic] +rust = [] +wasm = [] + +[notarization.main.new_lock_metadata] +rust = [ + "LockMetadata", + "TimeLock", + "TimeLock::new_with_ts", +] +wasm = [ + "WasmLockMetadata", + "WasmTimeLock", + "WasmTimeLock::with_unlock_at", + "WasmTimeLock::with_until_destroyed", + "WasmTimeLock::with_none", + "WasmTimeLockType", +] + +[notarization.main.update_state] +rust = [ + "UpdateState", + "UpdateState::new", + "NotarizationClient::update_state", +] +wasm = [ + "WasmUpdateState", + "WasmUpdateState::new", + "WasmUpdateState::build_programmable_transaction", + "WasmUpdateState::apply_with_events", + "WasmNotarizationClient::update_state", +] + +[notarization.main.destroy] +rust = [ + "DestroyNotarization", + "DestroyNotarization::new", + "NotarizationClient::destroy", +] +wasm = [ + "WasmDestroyNotarization", + "WasmDestroyNotarization::new", + "WasmDestroyNotarization::build_programmable_transaction", + "WasmDestroyNotarization::apply_with_events", + "WasmNotarizationClient::destroy_notarization", +] + +[notarization.main.update_metadata] +rust = [ + "UpdateMetadata", + "UpdateMetadata::new", + "NotarizationClient::update_metadata", +] +wasm = [ + "WasmUpdateMetadata", + "WasmUpdateMetadata::new", + "WasmUpdateMetadata::build_programmable_transaction", + "WasmUpdateMetadata::apply_with_events", + "WasmNotarizationClient::update_metadata", +] + +[notarization.main.id] +rust = [ + "NotarizationClientReadOnly::get_notarization_by_id", +] +wasm = [ + "WasmOnChainNotarization::id", +] + +[notarization.main.state] +rust = [ + "NotarizationClientReadOnly::state", + "NotarizationClientReadOnly::state_as", +] +wasm = [ + "WasmOnChainNotarization::state", +] + +[notarization.main.created_at] +rust = [ + "NotarizationClientReadOnly::created_at_ts", +] +wasm = [ + "WasmImmutableMetadata::created_at", +] + +[notarization.main.last_change] +rust = [ + "NotarizationClientReadOnly::last_state_change_ts", +] +wasm = [ + "WasmOnChainNotarization::last_state_change_at", +] + +[notarization.main.version_count] +rust = [ + "NotarizationClientReadOnly::state_version_count", +] +wasm = [ + "WasmOnChainNotarization::state_version_count", +] + +[notarization.main.description] +rust = [ + "NotarizationClientReadOnly::description", + "NotarizationBuilder::with_immutable_description", +] +wasm = [ + "WasmImmutableMetadata::description", + "WasmNotarizationBuilderLocked::with_immutable_description", + "WasmNotarizationBuilderDynamic::with_immutable_description", +] + +[notarization.main.updatable_metadata] +rust = [ + "NotarizationClientReadOnly::updatable_metadata", + "NotarizationBuilder::with_updatable_metadata", +] +wasm = [ + "WasmOnChainNotarization::updatable_metadata", + "WasmNotarizationBuilderLocked::with_updatable_metadata", + "WasmNotarizationBuilderDynamic::with_updatable_metadata", +] + +[notarization.main.notarization_method] +rust = [ + "NotarizationClientReadOnly::notarization_method", + "NotarizationMethod", +] +wasm = [ + "WasmOnChainNotarization::method", + "WasmNotarizationMethod", +] + +[notarization.main.lock_metadata] +rust = [ + "NotarizationClientReadOnly::lock_metadata", + "LockMetadata", +] +wasm = [ + "WasmImmutableMetadata::locking", + "WasmLockMetadata", +] + +[notarization.main.is_update_locked] +rust = [ + "NotarizationClientReadOnly::is_update_locked", +] +wasm = [] + +[notarization.main.is_delete_locked] +rust = [] +wasm = [] + +[notarization.main.is_transfer_locked] +rust = [ + "NotarizationClientReadOnly::is_transfer_locked", +] +wasm = [] + +[notarization.main.is_destroy_allowed] +rust = [ + "NotarizationClientReadOnly::is_destroy_allowed", +] +wasm = [] + +# ============================================================================= +# Module: notarization::dynamic_notarization (notarization-move/sources/dynamic_notarization.move) +# ============================================================================= + +[notarization.dynamic_notarization.DynamicNotarizationCreated] +rust = [] +wasm = [] + +[notarization.dynamic_notarization.DynamicNotarizationTransferred] +rust = [] +wasm = [] + +[notarization.dynamic_notarization.new] +rust = [ + "Dynamic", + "NotarizationBuilder", + "NotarizationBuilder::dynamic", +] +wasm = [ + "WasmNotarizationBuilderDynamic", + "WasmNotarizationBuilderDynamic::dynamic", +] + +[notarization.dynamic_notarization.create] +rust = [ + "CreateNotarization", + "CreateNotarization::new", + "NotarizationBuilder::finish", + "NotarizationClient::create_dynamic_notarization", +] +wasm = [ + "WasmCreateNotarizationDynamic", + "WasmCreateNotarizationDynamic::new", + "WasmCreateNotarizationDynamic::build_programmable_transaction", + "WasmCreateNotarizationDynamic::apply_with_events", + "WasmNotarizationClient::create_dynamic", + "WasmNotarizationBuilderDynamic::finish", +] + +[notarization.dynamic_notarization.transfer] +rust = [ + "TransferNotarization", + "TransferNotarization::new", + "NotarizationClient::transfer_notarization", +] +wasm = [ + "WasmTransferNotarization", + "WasmTransferNotarization::new", + "WasmTransferNotarization::build_programmable_transaction", + "WasmTransferNotarization::apply_with_events", + "WasmNotarizationClient::transfer_notarization", +] + +[notarization.dynamic_notarization.is_transferable] +rust = [] +wasm = [] + +# ============================================================================= +# Module: notarization::locked_notarization (notarization-move/sources/locked_notarization.move) +# ============================================================================= + +[notarization.locked_notarization.LockedNotarizationCreated] +rust = [] +wasm = [] + +[notarization.locked_notarization.new] +rust = [ + "Locked", + "NotarizationBuilder", + "NotarizationBuilder::locked", + "NotarizationBuilder::with_delete_lock", +] +wasm = [ + "WasmNotarizationBuilderLocked", + "WasmNotarizationBuilderLocked::locked", + "WasmNotarizationBuilderLocked::with_delete_lock", +] + +[notarization.locked_notarization.create] +rust = [ + "CreateNotarization", + "CreateNotarization::new", + "NotarizationBuilder::finish", + "NotarizationClient::create_locked_notarization", +] +wasm = [ + "WasmCreateNotarizationLocked", + "WasmCreateNotarizationLocked::new", + "WasmCreateNotarizationLocked::build_programmable_transaction", + "WasmCreateNotarizationLocked::apply_with_events", + "WasmNotarizationClient::create_locked", + "WasmNotarizationBuilderLocked::finish", +] + +# ============================================================================= +# Module: notarization::method (notarization-move/sources/method.move) +# ============================================================================= + +[notarization.method.NotarizationMethod] +rust = [ + "NotarizationMethod", +] +wasm = [ + "WasmNotarizationMethod", +] + +[notarization.method.new_dynamic] +rust = [] +wasm = [] + +[notarization.method.new_locked] +rust = [] +wasm = [] + +[notarization.method.is_dynamic] +rust = [] +wasm = [] + +[notarization.method.is_locked] +rust = [] +wasm = [] + +[notarization.method.to_str] +rust = [] +wasm = [] diff --git a/notarization-move/sources/dynamic_notarization.move b/notarization-move/sources/dynamic_notarization.move index ca5e7950..83f195d7 100644 --- a/notarization-move/sources/dynamic_notarization.move +++ b/notarization-move/sources/dynamic_notarization.move @@ -1,7 +1,10 @@ // Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -/// This module provides dynamic notarization capabilities that can be freely updated by its owner +/// Public entry surface for Dynamic-Notarizations: `Notarization` objects +/// configured with the `Dynamic` Notarization Method, whose `state` and +/// `updatable_metadata` can be updated after creation and which may +/// optionally carry a transfer lock. module iota_notarization::dynamic_notarization; use iota::{clock::Clock, event}; @@ -9,24 +12,35 @@ use iota_notarization::{notarization, timelock::TimeLock}; use std::string::String; // ===== Constants ===== -/// Cannot transfer a locked notarization +/// Raised when `transfer` is called on a notarization whose `transfer_lock` +/// is currently active. const ECannotTransferLocked: u64 = 0; -/// Event emitted when a dynamic notarization is created +/// Emitted by `create` after a Dynamic-Notarization is created and +/// transferred to the sender. public struct DynamicNotarizationCreated has copy, drop { - /// ID of the `Notarization` object that was created + /// Id of the newly created `Notarization` object. notarization_id: ID, } -/// Event emitted when a dynamic notarization is transferred +/// Emitted by `transfer` after a Dynamic-Notarization is transferred. public struct DynamicNotarizationTransferred has copy, drop { - /// ID of the `Notarization` object that was transferred + /// Id of the transferred `Notarization` object. notarization_id: ID, - /// Address of the new owner + /// Address of the new owner. recipient: address, } -/// Create a new dynamic `Notarization` +/// Creates a new Dynamic-Notarization `Notarization` without transferring +/// it. +/// +/// Delegates to `notarization::new_dynamic_notarization`; see that function +/// for the full contract. +/// +/// Aborts with: +/// * any error documented by `notarization::new_dynamic_notarization`. +/// +/// Returns the constructed `Notarization`. public fun new( state: notarization::State, immutable_description: Option, @@ -45,7 +59,13 @@ public fun new( ) } -/// Create and transfer a new dynamic `Notarization` to the sender +/// Creates a new Dynamic-Notarization `Notarization` and transfers it to +/// the transaction sender. +/// +/// Aborts with: +/// * any error documented by `notarization::new_dynamic_notarization`. +/// +/// Emits a `DynamicNotarizationCreated` event on success. public fun create( state: notarization::State, immutable_description: Option, @@ -70,8 +90,16 @@ public fun create( notarization::transfer_notarization(notarization, tx_context::sender(ctx)); } -/// Transfer a dynamic notarization to a new owner -/// Only works for dynamic notarizations that are marked as transferrable +/// Transfers `self` to `recipient`. +/// +/// Permitted only when `is_transferable` returns `true` against `clock`, +/// i.e. when `self` has no `LockMetadata` or its `transfer_lock` is not +/// currently active. +/// +/// Aborts with: +/// * `ECannotTransferLocked` when `is_transferable` is `false`. +/// +/// Emits a `DynamicNotarizationTransferred` event on success. public fun transfer( self: notarization::Notarization, recipient: address, @@ -90,7 +118,10 @@ public fun transfer( }); } -/// Check if the notarization is transferable +/// Checks whether `self` may currently be transferred. +/// +/// Returns `true` when `self` has no `LockMetadata` or when its +/// `transfer_lock` is not currently timelocked according to `clock`. public fun is_transferable( self: ¬arization::Notarization, clock: &Clock, diff --git a/notarization-move/sources/locked_notarization.move b/notarization-move/sources/locked_notarization.move index f1c02fa0..19fa59a9 100644 --- a/notarization-move/sources/locked_notarization.move +++ b/notarization-move/sources/locked_notarization.move @@ -1,20 +1,33 @@ // Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -/// This module provides locked notarization capabilities with timelock controls for updates and deletion +/// Public entry surface for Locked-Notarizations: `Notarization` objects +/// configured with the `Locked` Notarization Method, whose `state` and +/// `updatable_metadata` are immutable after creation and whose destruction +/// is gated by a `delete_lock`. module iota_notarization::locked_notarization; use iota::{clock::Clock, event}; use iota_notarization::{notarization, timelock::TimeLock}; use std::string::String; -/// Event emitted when a locked notarization is created +/// Emitted by `create` after a Locked-Notarization is created and +/// transferred to the sender. public struct LockedNotarizationCreated has copy, drop { - /// ID of the `Notarization` object that was created + /// Id of the newly created `Notarization` object. notarization_id: ID, } -/// Create a new locked `Notarization` +/// Creates a new Locked-Notarization `Notarization` without transferring +/// it. +/// +/// Delegates to `notarization::new_locked_notarization`; see that function +/// for the full contract. +/// +/// Aborts with: +/// * any error documented by `notarization::new_locked_notarization`. +/// +/// Returns the constructed `Notarization`. public fun new( state: notarization::State, immutable_description: Option, @@ -33,7 +46,13 @@ public fun new( ) } -/// Create and transfer a new locked notarization to the sender +/// Creates a new Locked-Notarization `Notarization` and transfers it to +/// the transaction sender. +/// +/// Aborts with: +/// * any error documented by `notarization::new_locked_notarization`. +/// +/// Emits a `LockedNotarizationCreated` event on success. public fun create( state: notarization::State, immutable_description: Option, diff --git a/notarization-move/sources/method.move b/notarization-move/sources/method.move index 67ada0c7..cb5c7170 100644 --- a/notarization-move/sources/method.move +++ b/notarization-move/sources/method.move @@ -1,29 +1,36 @@ // Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -/// This module provides enum NotarizationMethod used to distinguish programmatically -/// between Notarization methods. +/// Defines the `NotarizationMethod` enum used to distinguish between +/// Notarization Methods at runtime. module iota_notarization::method; use std::string::{Self, String}; -// Indicates the Notarization method. +/// Identifies the Notarization Method of a `Notarization`. +/// +/// The set of Notarization Methods is closed in the current version of the +/// package but may be extended in future versions. public enum NotarizationMethod has copy, drop, store { + /// Method whose `state` and `updatable_metadata` can be updated after + /// creation and which may optionally be transfer-locked. Dynamic, + /// Method whose `state` and `updatable_metadata` are immutable after + /// creation and whose destruction is gated by a `delete_lock`. Locked, } -/// Returns a new NotarizationMethod::Dynamic. +/// Returns the `Dynamic` Notarization Method. public fun new_dynamic(): NotarizationMethod { NotarizationMethod::Dynamic } -/// Returns a new NotarizationMethod::Locked. +/// Returns the `Locked` Notarization Method. public fun new_locked(): NotarizationMethod { NotarizationMethod::Locked } -/// Returns true if the NotarizationMethod is Dynamic +/// Returns `true` when `method` is `Dynamic`. public fun is_dynamic(method: &NotarizationMethod): bool { match (method) { NotarizationMethod::Dynamic => true, @@ -31,7 +38,7 @@ public fun is_dynamic(method: &NotarizationMethod): bool { } } -/// Returns true if the NotarizationMethod is Locked +/// Returns `true` when `method` is `Locked`. public fun is_locked(method: &NotarizationMethod): bool { match (method) { NotarizationMethod::Dynamic => false, @@ -39,7 +46,11 @@ public fun is_locked(method: &NotarizationMethod): bool { } } -/// Returns the Notarization method as String +/// Returns the human-readable name of `method` as a `String`. +/// +/// The result depends on the Notarization Method: +/// * `Dynamic`: `"DynamicNotarization"`. +/// * `Locked`: `"LockedNotarization"`. public fun to_str(method: &NotarizationMethod): String { match (method) { NotarizationMethod::Dynamic => { diff --git a/notarization-move/sources/notarization.move b/notarization-move/sources/notarization.move index 7ea207f7..29ffb970 100644 --- a/notarization-move/sources/notarization.move +++ b/notarization-move/sources/notarization.move @@ -1,8 +1,9 @@ // Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -/// This module provides core notarization capabilities to be used by -/// locked_notarization and dynamic_notarization modules +/// Implements the core `Notarization` object and the shared state, metadata +/// and locking primitives reused by the `dynamic_notarization` and +/// `locked_notarization` wrapper modules. #[allow(lint(self_transfer))] module iota_notarization::notarization; @@ -14,44 +15,44 @@ use iota_notarization::{ use std::string::String; // ===== Constants ===== -/// Cannot update state while notarization is locked for updates +/// Raised when `state` or `updatable_metadata` is updated while the update lock is active. const EUpdateWhileLocked: u64 = 0; -/// Cannot destroy while notarization is locked for deletion +/// Raised when `destroy` is called while the delete or transfer lock is active. const EDestroyWhileLocked: u64 = 1; -/// A lock time is not satisfied +/// Raised when a `LockMetadata` combination has a `delete_lock` that expires +/// before the `update_lock` or `transfer_lock`. const ELockTimeNotSatisfied: u64 = 2; -/// Delete lock cannot be TimeLock::UntilDestroyed +/// Raised when a `delete_lock` is constructed as `TimeLock::UntilDestroyed`. const EUntilDestroyedLockNotAllowed: u64 = 3; -/// Invariants for dynamic notarization are broken by the specified -/// Notarization configuration +/// Raised when the `LockMetadata` of a `Notarization` using the `Dynamic` +/// Notarization Method violates the invariants of that method (see +/// `are_dynamic_notarization_invariants_ok`). const EDynamicNotarizationInvariants: u64 = 4; -/// Invariants for locked notarization are broken by the specified -/// Notarization configuration +/// Raised when the `LockMetadata` of a `Notarization` using the `Locked` +/// Notarization Method violates the invariants of that method (see +/// `are_locked_notarization_invariants_ok`). const ELockedNotarizationInvariants: u64 = 5; // ===== Core Type ===== -/// A unified notarization type that can be either dynamic or locked +/// On-chain notarization object. Stores user-defined data together with +/// immutable provenance, optional updatable metadata, and lock metadata that +/// governs whether the object can be updated, transferred, or destroyed. +/// The selected Notarization Method determines which mutations are allowed +/// after creation. public struct Notarization has key { id: UID, - /// The state of the `Notarization` containing the notarized data - /// - /// `state` can be updated depending on the `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after creation - /// - Locked: Immutable after creation - /// - /// Use `Notarization::update_state()` for `state` updates. + /// Notarized data and its associated state metadata. Mutability depends + /// on the Notarization Method: + /// * `Dynamic`: updatable after creation via `update_state`. + /// * `Locked`: immutable after creation. state: State, - /// Immutable metadata, defined at creation time - /// - /// Provides immutable information, assertions and guaranties for third parties. - /// `immutable_metadata` are automatically created at creation time - /// and cannot be updated thereafter. + /// Provenance fixed at creation time (creation timestamp, description, + /// optional `LockMetadata`). immutable_metadata: ImmutableMetadata, - /// Provides context or additional information for third parties - /// - /// `updatable_metadata` can be updated depending on the `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after creation - /// - Locked: Immutable after creation + /// Free-form metadata. Mutability depends on the Notarization Method: + /// * `Dynamic`: updatable after creation via `update_metadata`; updates + /// do not bump `state_version_count` nor `last_state_change_at`. + /// * `Locked`: immutable after creation. /// /// NOTE: /// - `updatable_metadata` can be updated independently of `state` @@ -59,85 +60,91 @@ public struct Notarization has key { /// - Updating `updatable_metadata` does not change the `last_state_change_at` timestamp /// - Use `Notarization::update_metadata()` for `updatable_metadata` updates. updatable_metadata: Option, - /// Timestamp of the last `state` change (milliseconds since UNIX epoch) + /// Timestamp of the most recent `state` change, in milliseconds since the + /// Unix epoch. last_state_change_at: u64, - /// Counter for the number of `state` updates + /// Number of times `state` has been updated since creation. state_version_count: u64, - /// Notarization Method defining the overall behavior of the `Notarization` + /// Notarization Method governing the mutation and destruction rules of + /// this `Notarization`. method: NotarizationMethod, } // ===== Metadata and Locking ===== -/// Gathers immutable fields defined when the `Notarization` object is created +/// Immutable provenance fields of a `Notarization`, fixed at creation time. public struct ImmutableMetadata has store { - /// Timestamp when the `Notarization` was created + /// Creation timestamp, in milliseconds since the Unix epoch. created_at: u64, - /// Description of the `Notarization` + /// Human-readable description of the `Notarization`. description: Option, - /// Optional lock metadata for `Notarization` + /// Optional lock metadata. Presence depends on the Notarization Method: + /// * `Dynamic`: absent when the Dynamic-Notarization carries no transfer + /// lock; present otherwise. + /// * `Locked`: always present. locking: Option, } -/// Defines how a `Notarization` is locked. +/// Bundle of three `TimeLock`s controlling whether a `Notarization` can be +/// updated, destroyed, or transferred. public struct LockMetadata has store { - /// Update lock condition + /// Lock guarding `update_state` and `update_metadata`. update_lock: TimeLock, - /// Lock condition for deletion - /// - /// NOTE: delete_lock cannot be TimeLock::UntilDestroyed + /// Lock guarding `destroy`. Must not be `TimeLock::UntilDestroyed`. delete_lock: TimeLock, - /// Transfer lock - /// - /// NOTE: Only dynamic notarizations can be transferable + /// Lock guarding transfer. Its role depends on the Notarization Method: + /// * `Dynamic`: gates `dynamic_notarization::transfer`. + /// * `Locked`: pinned to `TimeLock::UntilDestroyed`, since + /// Locked-Notarizations are not transferable. transfer_lock: TimeLock, } // ===== Notarization State ===== -/// Represents the state of a `Notarization` containing the notarized `data` and its `metadata` -/// -/// The `Notarization` `State` can be updated by the owner depending on the used `NotarizationMethod`: -/// - Dynamic: `data` and `metadata` of the `State` can be updated anytime after creation -/// - Locked: The `State` is immutable after `Notarization` creation +/// Versioned state of a `Notarization`: the notarized `data` together with +/// optional state-associated `metadata`. /// -/// `State` `data` and `metadata` can only be updated at once, using method `Notarization::update_state()` -/// which will increase the `Notarization` `state_version_count` and update the `last_state_change_at` -/// timestamp even if only the `metadata` are altered. +/// Whether the `State` of an existing `Notarization` may change depends on +/// the Notarization Method: +/// * `Dynamic`: `data` and `metadata` are replaced together via +/// `update_state`. Every such update bumps +/// `Notarization.state_version_count` and refreshes +/// `Notarization.last_state_change_at`, even when only `metadata` changes. +/// * `Locked`: the `State` is immutable after `Notarization` creation. public struct State has copy, drop, store { - /// The data being notarized + /// Notarized payload. data: D, - /// State-associated metadata + /// Optional state-associated metadata, versioned together with `data`. metadata: Option, } // ===== Event Types ===== -/// Event emitted when the `state` of a `Notarization` is updated +/// Emitted by `update_state` after a successful state update. public struct NotarizationUpdated has copy, drop { - /// ID of the `Notarization` object that was updated + /// Id of the updated `Notarization` object. notarization_id: ID, - /// New version number after the update + /// Value of `state_version_count` after the update. state_version_count: u64, - /// Updated State + /// The new `State` after the update. updated_state: State, } -/// Event emitted when a `Notarization` is destroyed +/// Emitted by `destroy` after a successful destruction. public struct NotarizationDestroyed has copy, drop { - /// ID of the `Notarization` object that was destroyed + /// Id of the destroyed `Notarization` object. notarization_id: ID, } // ===== Constructor Functions ===== -/// Create a new state from a vector data +/// Constructs a `State>` from raw bytes and optional metadata. public fun new_state_from_bytes(data: vector, metadata: Option): State> { State { data, metadata } } -/// Create state from a string data +/// Constructs a `State` from a string payload and optional metadata. public fun new_state_from_string(data: String, metadata: Option): State { State { data, metadata } } -/// Create state from generic data +/// Constructs a `State` for an arbitrary payload type and optional metadata. public fun new_state_from_generic( data: D, metadata: Option, @@ -145,7 +152,25 @@ public fun new_state_from_generic( State { data, metadata } } -/// Create lock metadata +/// Constructs a `LockMetadata` from the three package-local `TimeLock`s. +/// +/// Rejects combinations that would let the object be destroyed before its +/// update or transfer locks expire. When `delete_lock` is a `TimeLock::UnlockAt`, +/// its unlock time must be greater than or equal to the unlock time of any +/// `UnlockAt` `update_lock` or `transfer_lock`. +/// +/// In the current implementation the legal combinations are further narrowed +/// by the method-specific invariants enforced in `new_dynamic_notarization` +/// and `new_locked_notarization`; edge cases where `delete_lock` is +/// `TimeLock::None` while other locks are `UnlockAt` are therefore not +/// reachable here. +/// +/// Aborts with: +/// * `EUntilDestroyedLockNotAllowed` when `delete_lock` is `TimeLock::UntilDestroyed`. +/// * `ELockTimeNotSatisfied` when `delete_lock` is `UnlockAt` and its unlock +/// time is earlier than the unlock time of `update_lock` or `transfer_lock`. +/// +/// Returns the constructed `LockMetadata`. public fun new_lock_metadata( update_lock: TimeLock, delete_lock: TimeLock, @@ -195,6 +220,11 @@ public fun new_lock_metadata( } } +/// Constructs an `ImmutableMetadata` from raw components. +/// +/// This is an internal helper for the wrapper modules; callers must already +/// have validated that `locking` is well-formed for the intended notarization +/// method. public(package) fun new_immutable_metadata( created_at: u64, description: Option, @@ -208,7 +238,22 @@ public(package) fun new_immutable_metadata( } // ===== Notarization Creation Functions ===== -/// Create a new dynamic `Notarization` +/// Creates a new `Notarization` using the `Dynamic` Notarization Method. +/// +/// When `transfer_lock` is `TimeLock::None`, the resulting object has no +/// `LockMetadata` and is freely transferable. Otherwise a `LockMetadata` is +/// built with `update_lock = delete_lock = TimeLock::None` and the supplied +/// `transfer_lock`. `state_version_count` starts at `0` and +/// `last_state_change_at` is set to the current clock timestamp. +/// +/// Aborts with: +/// * any error documented by `new_lock_metadata` when `transfer_lock` is not +/// `TimeLock::None`. +/// * `EDynamicNotarizationInvariants` when the resulting `ImmutableMetadata` +/// violates the invariants of the `Dynamic` Notarization Method (see +/// `are_dynamic_notarization_invariants_ok`). +/// +/// Returns the constructed `Notarization`. public(package) fun new_dynamic_notarization( state: State, immutable_description: Option, @@ -245,7 +290,20 @@ public(package) fun new_dynamic_notarization( } } -/// Create a new locked `Notarization` +/// Creates a new `Notarization` using the `Locked` Notarization Method. +/// +/// The resulting object always carries `LockMetadata` with both `update_lock` +/// and `transfer_lock` set to `TimeLock::UntilDestroyed` and `delete_lock` set +/// to the supplied value. `state_version_count` starts at `0` and +/// `last_state_change_at` is set to the current clock timestamp. +/// +/// Aborts with: +/// * any error documented by `new_lock_metadata` for the chosen `delete_lock`. +/// * `ELockedNotarizationInvariants` when the resulting `ImmutableMetadata` +/// violates the invariants of the `Locked` Notarization Method (see +/// `are_locked_notarization_invariants_ok`). +/// +/// Returns the constructed `Notarization`. public(package) fun new_locked_notarization( state: State, immutable_description: Option, @@ -283,15 +341,20 @@ public(package) fun new_locked_notarization( } // ===== State Management Functions ===== -/// Update the state of a `Notarization` -/// -/// Using this function will: -/// - set the `state` to the `new_state` -/// - increase the `state_version_count` by 1 -/// - set the `last_state_change_at` timestamp to the current `clock::timestamp_ms` -/// - emit a `NotarizationUpdated` event in case of success -/// - fail if the `Notarization` uses `NotarizationMethod::Locked` or is update-locked -/// (`Notarization::is_update_locked()` is true) by other means +/// Replaces the `state` of `self` with `new_state`. +/// +/// Bumps `state_version_count` by one and refreshes `last_state_change_at` to +/// the current clock timestamp. +/// +/// Behaviour depends on the Notarization Method: +/// * `Dynamic`: allways permitted - `update_lock` is allways `None`. +/// * `Locked`: always aborts, because `update_lock` is pinned to +/// `TimeLock::UntilDestroyed`. +/// +/// Aborts with: +/// * `EUpdateWhileLocked` when `is_update_locked` is `true`. +/// +/// Emits a `NotarizationUpdated` event on success. public fun update_state( self: &mut Notarization, new_state: State, @@ -310,7 +373,18 @@ public fun update_state( }); } -/// Destroy a `Notarization` +/// Destroys `self` and releases the underlying object id. +/// +/// All package-local `TimeLock`s of the optional `LockMetadata` are destroyed in +/// the process; the gating check `is_destroy_allowed` ensures that no +/// `UnlockAt` lock is still active. +/// +/// Aborts with: +/// * `EDestroyWhileLocked` when `is_destroy_allowed` is `false`. +/// * `iota_notarization::timelock::ETimelockNotExpired` when any `UnlockAt` +/// lock is destroyed before it expires. +/// +/// Emits a `NotarizationDestroyed` event on success. public fun destroy(self: Notarization, clock: &Clock) { assert!(self.is_destroy_allowed(clock), EDestroyWhileLocked); @@ -338,7 +412,7 @@ public fun destroy(self: Notarization, clock: &Clock) timelock::destroy(delete_lock, clock); timelock::destroy(transfer_lock, clock); } else { - // We know dynamic Notarizations have no lock metadata + // We know Dynamic-Notarizations have no lock metadata option::destroy_none(locking); }; @@ -347,9 +421,11 @@ public fun destroy(self: Notarization, clock: &Clock) event::emit(NotarizationDestroyed { notarization_id: id_inner }); } -/// Re-exports the transfer function from the core module +/// Transfers `self` to `recipient` using the IOTA `transfer` primitive. /// -/// Workaround for transferability +/// This helper exists only so that the wrapper modules can perform the +/// transfer without having direct access to the private fields of +/// `Notarization`; transferability checks live in the calling module. public(package) fun transfer_notarization( self: Notarization, recipient: address, @@ -358,14 +434,18 @@ public(package) fun transfer_notarization( } // ===== Metadata Management Functions ===== -/// Update the updatable metadata of a `Notarization` -/// -/// NOTE: -/// - does not affect the state version count or the `last_state_change_at` timestamp -/// - will fail if the `Notarization` uses `NotarizationMethod::Locked` or is update-locked -/// (`Notarization::is_update_locked()` is true) by other means -/// - Only the `updatable_metadata` can be changed; the `immutable_metadata::description` -/// remains fixed +/// Replaces `updatable_metadata` with `new_metadata`. +/// +/// Does not change `state`, `state_version_count`, or `last_state_change_at`. +/// The `immutable_metadata.description` field is unaffected. +/// +/// Behaviour depends on the Notarization Method: +/// * `Dynamic`: permitted - `update_lock` is always `None`. +/// * `Locked`: always aborts, because `update_lock` is pinned to +/// `TimeLock::UntilDestroyed`. +/// +/// Aborts with: +/// * `EUpdateWhileLocked` when `is_update_locked` is `true`. public fun update_metadata( self: &mut Notarization, new_metadata: Option, @@ -377,41 +457,62 @@ public fun update_metadata( } // ===== Getter Functions ===== +/// Returns a reference to the object id of `self`. public fun id(self: &Notarization): &UID { &self.id } +/// Returns a reference to the current `State` of `self`. public fun state(self: &Notarization): &State { &self.state } +/// Returns the creation timestamp, in milliseconds since the Unix epoch. public fun created_at(self: &Notarization): u64 { self.immutable_metadata.created_at } +/// Returns the timestamp of the most recent state change, in milliseconds +/// since the Unix epoch. public fun last_change(self: &Notarization): u64 { self.last_state_change_at } +/// Returns the number of times `state` has been updated since creation. public fun version_count(self: &Notarization): u64 { self.state_version_count } +/// Returns the immutable description set at creation time. public fun description(self: &Notarization): Option { self.immutable_metadata.description } +/// Returns the current value of `updatable_metadata`. public fun updatable_metadata(self: &Notarization): Option { self.updatable_metadata } +/// Returns the Notarization Method of `self`. public fun notarization_method(self: &Notarization): NotarizationMethod { self.method } // ===== Lock-Related Getter Functions ===== -/// Get the lock metadata if this is a locked Notarization +/// Returns a reference to the optional `LockMetadata` of `self`. public fun lock_metadata(self: &Notarization): &Option { &self.immutable_metadata.locking } -/// Check if the `Notarization` is locked for updates (always false for dynamic variant) +/// Checks whether `self` is currently locked against `state` and +/// `updatable_metadata` updates. +/// +/// The result depends on the Notarization Method: +/// * `Dynamic`: always returns `false`. +/// * `Locked`: returns whether `LockMetadata.update_lock` is currently +/// timelocked according to `clock`. +/// +/// Aborts with: +/// * `EDynamicNotarizationInvariants` when the invariants of the `Dynamic` +/// Notarization Method are violated. +/// * `ELockedNotarizationInvariants` when the invariants of the `Locked` +/// Notarization Method are violated. public fun is_update_locked(self: &Notarization, clock: &Clock): bool { assert_method_specific_invariants(self); if (self.method.is_dynamic()) { @@ -423,7 +524,18 @@ public fun is_update_locked(self: &Notarization, cloc } } -/// Check if the `Notarization` is locked for deletion (always false for dynamic variant) +/// Checks whether `self` is currently locked against destruction. +/// +/// The result depends on the Notarization Method: +/// * `Dynamic`: always returns `false`. +/// * `Locked`: returns whether `LockMetadata.delete_lock` is currently +/// timelocked according to `clock`. +/// +/// Aborts with: +/// * `EDynamicNotarizationInvariants` when the invariants of the `Dynamic` +/// Notarization Method are violated. +/// * `ELockedNotarizationInvariants` when the invariants of the `Locked` +/// Notarization Method are violated. public fun is_delete_locked(self: &Notarization, clock: &Clock): bool { assert_method_specific_invariants(self); @@ -436,14 +548,24 @@ public fun is_delete_locked(self: &Notarization, cloc } } -/// Check if the `Notarization` is locked for transfer +/// Checks whether `self` is currently locked against transfer. +/// +/// Returns `false` when `self` has no `LockMetadata`. Otherwise returns +/// whether `LockMetadata.transfer_lock` is currently timelocked according to +/// `clock`. public fun is_transfer_locked(self: &Notarization, clock: &Clock): bool { option::is_some_and!(&self.immutable_metadata.locking, |lock_metadata| { timelock::is_timelocked(&lock_metadata.transfer_lock, clock) }) } -/// Check if the `Notarization` can be destroyed +/// Checks whether `self` is currently eligible for destruction. +/// +/// The result depends on the Notarization Method: +/// * `Dynamic`: returns `false` when an `UnlockAt` +/// `transfer_lock` has not yet expired, and `true` otherwise. +/// * `Locked`: returns `true` only when none of `update_lock`, `delete_lock`, +/// or `transfer_lock` is currently an unexpired `UnlockAt` lock. public fun is_destroy_allowed(self: &Notarization, clock: &Clock): bool { if (self.method.is_dynamic()) { !option::is_some_and!( @@ -464,10 +586,18 @@ public fun is_destroy_allowed(self: &Notarization, cl } } -/// Ensures that the NotarizationMethod specific invariants are hold -/// See fun `are_locked_notarization_invariants_ok()` and -/// `are_dynamic_notarization_invariants_ok()` -/// for more details. +/// Asserts that the invariants on `self.immutable_metadata` required by the +/// Notarization Method of `self` hold. +/// +/// The rule set is selected by `self.method`: +/// * `Dynamic`: see `are_dynamic_notarization_invariants_ok`. +/// * `Locked`: see `are_locked_notarization_invariants_ok`. +/// +/// Aborts with: +/// * `EDynamicNotarizationInvariants` when `self.method` is `Dynamic` and the +/// invariants of the `Dynamic` Notarization Method are violated. +/// * `ELockedNotarizationInvariants` when `self.method` is `Locked` and the +/// invariants of the `Locked` Notarization Method are violated. public(package) fun assert_method_specific_invariants( self: &Notarization, ) { @@ -484,10 +614,11 @@ public(package) fun assert_method_specific_invariants( } } -/// Indicates if the invariants for `NotarizationMethod::Locked` are satisfied: +/// Checks whether `immutable_metadata` satisfies the invariants required by +/// the `Locked` Notarization Method. /// -/// - `self.immutable_metadata.locking` must exist. -/// - `updated_lock` and `transfer_lock` must be `TimeLock::UntilDestroyed`. +/// These invariants require that `locking` is `option::some(_)` and that both +/// `update_lock` and `transfer_lock` are `TimeLock::UntilDestroyed`. public(package) fun are_locked_notarization_invariants_ok( immutable_metadata: &ImmutableMetadata, ): bool { @@ -499,13 +630,14 @@ public(package) fun are_locked_notarization_invariants_ok( } } -/// Indicates if the invariants for `NotarizationMethod::Dynamic` are satisfied: +/// Checks whether `immutable_metadata` satisfies the invariants required by +/// the `Dynamic` Notarization Method. /// -/// - Dynamic notarization can only have transfer locking or no -/// `immutable_metadata.locking`. -/// If `immutable_metadata.locking` exists, all locks except `transfer_lock` -/// must be `TimeLock::None` -/// and the `transfer_lock` must not be `TimeLock::None`. +/// These invariants permit two shapes: +/// * `locking` is `option::none()`, i.e. the Dynamic-Notarization carries no +/// transfer lock; or +/// * `locking` is `option::some(_)` with `update_lock` and `delete_lock` both +/// `TimeLock::None` and `transfer_lock` anything other than `TimeLock::None`. public(package) fun are_dynamic_notarization_invariants_ok( immutable_metadata: &ImmutableMetadata, ): bool { diff --git a/notarization-rs/Cargo.toml b/notarization-rs/Cargo.toml index 46e3af48..254ee297 100644 --- a/notarization-rs/Cargo.toml +++ b/notarization-rs/Cargo.toml @@ -26,7 +26,6 @@ thiserror.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] iota_interaction_rust = { workspace = true, default-features = false } -hyper = { workspace = true } iota-sdk = { workspace = true } tokio = { workspace = true } diff --git a/notarization-rs/README.md b/notarization-rs/README.md index e7faf0a9..31517e0a 100644 --- a/notarization-rs/README.md +++ b/notarization-rs/README.md @@ -1,52 +1,27 @@ -# IOTA Notarization +# IOTA Single Notarization -The Notarization Rust library provides a `NotarizationBuilder` that can be used to create Notarization objects on -the IOTA ledger or to use an already existing Notarization object. The NotarizationBuilder returns a Notarization struct -instance, which is mapped to the Notarization object on the ledger and can be used to interact with the object. +The Single Notarization Rust package is the Rust client for individual locked and dynamic notarizations in the IOTA +Notarization Toolkit. -You can find the full IOTA Notarization documentation [here](https://docs.iota.org/developer/iota-notarization). +The package provides a `NotarizationBuilder` that creates notarization objects on the IOTA ledger or connects to existing +notarization objects. The builder returns a `Notarization` struct instance that maps to the on-chain object and provides +typed methods for interacting with it. -Following Notarization methods are currently provided: +Use Single Notarization when you need one notarized object for arbitrary data, documents, hashes, or latest-state +records. Use Audit Trails when you need a structured record history with roles, capabilities, locking, and tagging. -- Dynamic Notarization -- Locked Notarization - -These Notarization methods are implemented using a single Notarization Move object, stored on the IOTA Ledger. -The Method specific behavior is achieved via configuration of this object. - -To minimize the need for config settings, the Notarization methods reduce the number of available configuration -parameters while using method specific fixed settings for several parameters, resulting in the typical method -specific behaviour. Here, Notarization methods can be seen as prepared configuration sets to facilitate -Notarization usage for often needed use cases. - -Here is an overview of the most important configuration parameters for each of these methods: - -| Method | Locking exists | delete_lock* | update_lock | transfer_lock | -| ------- | --------------- | ---------------- | ----------------------- | ----------------------- | -| Dynamic | Optional [conf] | None [static] | None [static] | Optional [conf] | -| Locked | Yes [static] | Optional* [conf] | UntilDestroyed [static] | UntilDestroyed [static] | - -Explanation of terms and symbols for the table above: - -- [conf]: Configurable parameter. -- [static]: Fixed or static parameter. -- Optional: - - Locks: The lock can be set to UnlockAt or UntilDestroyed. - - Locking exists: If no locking is used, there will be no [`LockMetadata`] stored with the Notarization object - Otherwise [`LockMetadata`] will be created automatically. If no [`LockMetadata`] exist, the behaviour is - equivalent to existing [`LockMetadata`] with all locks set to [`None`]. - - *: delete_lock can not be set to `UntilDestroyed`. +You can find the full IOTA Notarization Toolkit documentation [here](https://docs.iota.org/developer/iota-notarization). ## Process Flows -The following workflows demonstrate how NotarizationBuilder and Notarization instances can be used to create, update and -destroy Notarization objects on the ledger. +The following workflows demonstrate how `NotarizationBuilder` and `Notarization` instances create, update, and destroy +single notarization objects on the ledger. ### Dynamic Notarizations A _Dynamic Notarization_ is created on the ledger using the `NotarizationBuilder::create_dynamic()` function. -To create a _Dynamic Notarization_, the following initial arguments need to be specified using the NotarizationBuilder -setter functions (The used terms can be found in the [glossary below](#glossary)): +To create a _Dynamic Notarization_, specify the following initial arguments with the `NotarizationBuilder` setter +functions. The terms used here are defined in the [glossary below](#glossary). - Initial State consisting of `Stored Data` and `State Metadata` that will be used to define the first version of the Notarization state. @@ -54,9 +29,9 @@ setter functions (The used terms can be found in the [glossary below](#glossary) - Optional `Updatable Metadata` (**Dynamic**: always updatable; **Locked**: immutable) - An optional boolean indicator if the Notarization shall be transferable -After a **dynamic** Notarization has been created, it can be updated using the `Notarization::update_state()` function and can be -destroyed using `Notarization::destroy()`. -**Locked** notarizations are immutable after creation. +After a **dynamic** Notarization has been created, it can be updated using the `Notarization::update_state()` function +and destroyed using `Notarization::destroy()`. +A **locked** Notarization is immutable after creation. #### Creating a new Dynamic Notarization on the Ledger @@ -91,7 +66,7 @@ sequenceDiagram Lib ->>- Prover: OnChainNotarization + IotaTransactionBlockResponse ``` -#### Fetching state data from a Notarization already existing on the ledger +#### Fetching state data from an existing Notarization on the ledger The following sequence diagram explains the component interaction for `Verifiers` (or other parties) fetching the `Latest State`: @@ -110,7 +85,7 @@ sequenceDiagram Lib ->>- Verifier: State ``` -#### Updating state data of a Notarization already existing on the ledger +#### Updating state data of an existing Notarization on the ledger The following sequence diagram shows the component interaction in case a `Prover` wants to update the `Latest State` of a Notarization: @@ -142,17 +117,18 @@ sequenceDiagram ### Locked Notarizations -In general _Locked Notarizations_ are handled similar to _Dynamic Notarizations_. A `NotarizationBuilder` for _Locked Notarization_ is created -using the `NotarizationClient::create_locked_notarization()` function. The resulting `NotarizationBuilder` can be used to -create the _Locked Notarization_ on the ledger using the `NotarizationBuilder::finish()` function. +In general, _Locked Notarizations_ are handled similarly to _Dynamic Notarizations_. A `NotarizationBuilder` for a +_Locked Notarization_ is created using the `NotarizationClient::create_locked_notarization()` function. The resulting +`NotarizationBuilder` can be used to create the _Locked Notarization_ on the ledger using the +`NotarizationBuilder::finish()` function. -To create a _Locked Notarization_ the following arguments need to be specified using the `NotarizationBuilder` setter +To create a _Locked Notarization_, specify the following arguments with the `NotarizationBuilder` setter functions: - all arguments needed to create a _Dynamic Notarization_ - Optional Delete Timelock -After the _Locked Notarization_ has been created - by design - the `Latest State` can not bee updated anymore. +After the _Locked Notarization_ has been created, the `Latest State` cannot be updated by design. The lifecycle of a _Locked Notarization_ can be described as: @@ -160,7 +136,7 @@ The lifecycle of a _Locked Notarization_ can be described as: - If a `Delete Timelock` has been used, wait at least until the time-lock has expired - Destroy the Notarization object -As the `Latest State` of a _Locked Notarization_ can not be updated the lifecycle doesn’t include any update processes. +As the `Latest State` of a _Locked Notarization_ cannot be updated, the lifecycle does not include any update processes. ## Glossary @@ -170,12 +146,12 @@ As the `Latest State` of a _Locked Notarization_ can not be updated the lifecycl data; each update completely overwrites the previous stored data. - `Ledger Object`: A single, updatable on-chain object that holds the `Latest State` of the notarized data. It is identified by a unique ObjectId and is modified through update transactions. -- `Transfer Timelock`k: An optional time-locking period during which the `Ledger Object` can not be transfered. -- `Delete Timelock`: An optional time-locking period during which the Ledger Object can not be deleted. +- `Transfer Timelock`: An optional time-locking period during which the `Ledger Object` cannot be transferred. +- `Delete Timelock`: An optional time-locking period during which the `Ledger Object` cannot be deleted. - `State Metadata`: An optional text describing the `Stored Data`. For example, if document hashes of succeeding revisions of a document are stored as `Stored Data`, State Metadata can be used to describe the revision specifier of the document. -- `Latest State`: The most recent version of the `Stored Data` (and optionally theState Metadata) within the +- `Latest State`: The most recent version of the `Stored Data` (and optionally the `State Metadata`) within the `Ledger Object`. In _Dynamic Notarization_, only this latest state is visible on-chain, as previous states are overwritten. As the `Stored Data` and optionally the `State Metadata` together build the `Latest State` they can only be updated together in one function call. @@ -190,16 +166,16 @@ As the `Latest State` of a _Locked Notarization_ can not be updated the lifecycl immutability. - `Immutable Description`: An arbitrary informational String that can be used for example to describe the purpose of the created _Dynamic Notarization_ object, how often it will be updated or other legally important or useful information. - The `Immutable Description` is specified by the `Prover` at creation time and can not be updated after the Notarization - abject has been created. + The `Immutable Description` is specified by the `Prover` at creation time and cannot be updated after the Notarization + object has been created. - `Creation Timestamp`: Indicates when the `Ledger Object` was initially created. - `Immutable Metadata`: Consists of the `Immutable Description` and `Creation Timestamp`. - `Updatable Metadata`: An arbitrary informational String that can be updated at any time by the `Prover` independently - from the `Latest State` (dynamic notarizations only; locked notarizations are immutable). Can be used to provide additional useful information that are subject to change from time to - time. + from the `Latest State` (Dynamic Notarizations only; Locked Notarizations are immutable). Can be used to provide + additional useful information that is subject to change from time to time. - `State Version Count`: Numerical value incremented with each update of the `Latest State`. -- `Last State Change Time`: Indicates when the `Latest State` has been updated the last time. +- `Last State Change Time`: Indicates when the `Latest State` was last updated. - `Calculated Metadata`: Consists of the `State Version Count` and `Last State Change Time` -- `Notarized Record`: Some information owned by the `Prover` that describe and include notarized data, so that these data +- `Notarized Record`: Some information owned by the `Prover` that describes and includes notarized data, so that this data can be verified by a `Verifier`. In the context of the _Dynamic Notarization_ method, the latest version of subsequent versions of a `Notarized Record` is the `Latest State`. diff --git a/notarization-rs/src/client/full_client.rs b/notarization-rs/src/client/full_client.rs index d76cb36d..df092462 100644 --- a/notarization-rs/src/client/full_client.rs +++ b/notarization-rs/src/client/full_client.rs @@ -160,7 +160,14 @@ where } impl NotarizationClient { - /// Creates a builder for a locked notarization. + /// Creates a builder for a Locked-Notarization. + /// + /// A Locked-Notarization is immutable after creation: its `state` and + /// `updatable_metadata` are fixed for the lifetime of the object. Its + /// destruction can be gated by a `delete_lock`. + /// + /// On execution the resulting transaction transfers the new `Notarization` + /// object to the sender and emits a `LockedNotarizationCreated` event. /// /// ## Example /// @@ -185,7 +192,16 @@ impl NotarizationClient { NotarizationBuilder::locked() } - /// Creates a builder for a dynamic notarization. + /// Creates a builder for a Dynamic-Notarization. + /// + /// A Dynamic-Notarization can be updated after creation: `state` and + /// `updatable_metadata` can be replaced via + /// [`Self::update_state`] and [`Self::update_metadata`], and ownership + /// can be transferred via [`Self::transfer_notarization`] when the + /// configured `transfer_lock` permits it. + /// + /// On execution the resulting transaction transfers the new `Notarization` + /// object to the sender and emits a `DynamicNotarizationCreated` event. /// /// ## Example /// @@ -217,16 +233,16 @@ where { /// Updates the state of a notarization. /// - /// **Important**: The `state` can only be updated depending on the used `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after notarization creation - /// - Locked: Immutable after notarization creation + /// On success the on-chain transaction replaces `state` with `new_state`, + /// increments `state_version_count` by one, refreshes + /// `last_state_change_at` to the on-chain clock timestamp (in + /// milliseconds since the Unix epoch), and emits a `NotarizationUpdated` + /// Move event. /// - /// Using this function will: - /// - set the `state` to the `new_state` - /// - increase the `state_version_count` by 1 - /// - set the `last_state_change_at` timestamp to the current clock timestamp in milliseconds - /// - emits a `NotarizationUpdated` Move event in case of success - /// - fail if the notarization uses `NotarizationMethod::Locked` + /// Behaviour depends on the Notarization Method: + /// * `Dynamic`: always permitted — the underlying `update_lock` is fixed to `TimeLock::None`. + /// * `Locked`: always aborts on-chain, because the underlying `update_lock` is pinned to + /// `TimeLock::UntilDestroyed`. /// /// ## Parameters /// @@ -256,9 +272,16 @@ where TransactionBuilder::new(UpdateState::new(new_state, notarization_id)) } - /// Destroys a notarization permanently. + /// Destroys a notarization permanently and releases its object ID. + /// + /// All package-local `TimeLock`s of the attached `LockMetadata` are + /// destroyed in the process. The notarization must currently be + /// destroy-allowed (see + /// [`NotarizationClientReadOnly::is_destroy_allowed`]); otherwise the + /// on-chain transaction aborts. /// - /// The notarization must not have active time locks preventing deletion. + /// On success the on-chain transaction emits a `NotarizationDestroyed` + /// event. /// /// ## Parameters /// @@ -283,17 +306,16 @@ where TransactionBuilder::new(DestroyNotarization::new(notarization_id)) } - /// Updates the metadata of a notarization. + /// Updates the `updatable_metadata` of a notarization. /// - /// **Important**: The `updatable_metadata` can only be updated depending on the used - /// `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after notarization creation - /// - Locked: Immutable after notarization creation + /// Does not affect `state`, `state_version_count`, + /// `last_state_change_at`, or the immutable description in + /// `immutable_metadata`. /// - /// NOTE: - /// - does not affect the `state_version_count` or the `last_state_change_at` timestamp - /// - will fail if the notarization uses the `NotarizationMethod::Locked` - /// - Only the `updatable_metadata` can be changed; the `immutable_metadata::description` remains fixed + /// Behaviour depends on the Notarization Method: + /// * `Dynamic`: always permitted — the underlying `update_lock` is fixed to `TimeLock::None`. + /// * `Locked`: always aborts on-chain, because the underlying `update_lock` is pinned to + /// `TimeLock::UntilDestroyed`. /// /// ## Parameters /// @@ -326,11 +348,19 @@ where TransactionBuilder::new(UpdateMetadata::new(metadata, notarization_id)) } - /// Transfers ownership of a dynamic notarization. + /// Transfers ownership of a notarization to another address. + /// + /// Permitted only when the notarization has no `LockMetadata` or when + /// its `transfer_lock` is not currently active. /// - /// The notarization must not have active transfer locks. + /// Behaviour depends on the Notarization Method: + /// * `Dynamic`: on success the notarization is transferred to `recipient`. Submitting while the configured + /// `transfer_lock` is engaged aborts on-chain. + /// * `Locked`: always aborts on-chain — Locked-Notarizations have their `transfer_lock` pinned to + /// `TimeLock::UntilDestroyed` and are therefore non-transferable. /// - /// **Important**: Only works on dynamic notarizations. + /// On success the on-chain transaction emits a + /// `DynamicNotarizationTransferred` event. /// /// ## Parameters /// diff --git a/notarization-rs/src/client/read_only.rs b/notarization-rs/src/client/read_only.rs index 1d9be50d..22716f96 100644 --- a/notarization-rs/src/client/read_only.rs +++ b/notarization-rs/src/client/read_only.rs @@ -251,10 +251,11 @@ impl NotarizationClientReadOnly { /// Retrieves the `updatable_metadata` of a notarization object by its `object_id`. /// - /// This metadata is an optional string. - /// - /// Dynamic notarizations can be updated anytime after creation - /// Locked notarizations are immutable after creation + /// This metadata is an optional string. Whether it can be modified after + /// creation depends on the Notarization Method: + /// * `Dynamic`: updatable via + /// [`NotarizationClient::update_metadata`](crate::client::NotarizationClient::update_metadata). + /// * `Locked`: immutable after creation. /// /// # Arguments /// @@ -268,9 +269,10 @@ impl NotarizationClientReadOnly { self.execute_read_only_transaction(tx).await } - /// Retrieves the `notarization_method` of a notarization object by its `object_id`. + /// Retrieves the [`NotarizationMethod`] of a notarization object by its `object_id`. /// - /// This indicates the method used for notarizing the object's state changes. + /// The Notarization Method is fixed at creation and determines which + /// operations are permitted on the notarization afterwards. /// /// # Arguments /// @@ -352,7 +354,12 @@ impl NotarizationClientReadOnly { self.execute_read_only_transaction(tx).await } - /// Checks if the notarized object is currently locked against state updates. + /// Checks if the notarized object is currently locked against `state` + /// and `updatable_metadata` updates. + /// + /// Result depends on the Notarization Method: + /// * `Dynamic`: always returns `false`. + /// * `Locked`: returns whether the configured `update_lock` is currently timelocked. /// /// # Arguments /// @@ -368,6 +375,12 @@ impl NotarizationClientReadOnly { /// Checks if the notarized object is currently allowed to be destroyed. /// + /// Behaviour depends on the Notarization Method: + /// * `Dynamic`: destruction is gated only on the `transfer_lock` — the object is destroy-allowed unless + /// `transfer_lock` is currently `UnlockAt`-locked. + /// * `Locked`: destruction is gated on all of `update_lock`, `delete_lock`, and `transfer_lock` — the object is + /// destroy-allowed only when none of them is currently `UnlockAt`-locked. + /// /// # Arguments /// /// * `notarized_object_id`: The [`ObjectID`] of the notarized object. @@ -382,6 +395,11 @@ impl NotarizationClientReadOnly { /// Checks if the notarized object is currently locked against transfer. /// + /// Returns `false` when the object has no `LockMetadata`. Otherwise + /// returns whether the configured `transfer_lock` is currently + /// timelocked. Locked-Notarizations always return `true` because their + /// `transfer_lock` is pinned to `TimeLock::UntilDestroyed`. + /// /// # Arguments /// /// * `notarized_object_id`: The [`ObjectID`] of the notarized object. diff --git a/notarization-rs/src/core/builder.rs b/notarization-rs/src/core/builder.rs index da9f846e..c4a74a00 100644 --- a/notarization-rs/src/core/builder.rs +++ b/notarization-rs/src/core/builder.rs @@ -8,10 +8,12 @@ //! ## Overview //! //! A notarization is a blockchain-based attestation that creates tamper-proof records with timestamps -//! and version tracking. This module supports two types of notarizations: +//! and version tracking. The Notarization Method selected at builder construction time fixes the +//! mutation and destruction rules of the resulting object: //! -//! - **Locked**: Immutable records that cannot be modified after creation -//! - **Dynamic**: Updatable records that can evolve over time +//! - `Locked` — state and updatable metadata are immutable after creation; destruction is gated by a `delete_lock`. +//! - `Dynamic` — state and updatable metadata can be updated after creation; ownership may optionally be +//! transfer-locked. //! //! ## Examples //! @@ -49,44 +51,49 @@ use super::transactions::CreateNotarization; use super::types::{NotarizationMethod, State, TimeLock}; use crate::error::Error; -/// Marker type for locked notarizations. +/// Marker type for the `Locked` Notarization Method. #[derive(Clone)] pub struct Locked; -/// Marker type for dynamic notarizations. +/// Marker type for the `Dynamic` Notarization Method. #[derive(Clone)] pub struct Dynamic; /// A builder for constructing notarization transactions. /// -/// This builder uses the type parameter `M` to enforce method-specific -/// constraints at compile time. -/// The two supported types are [`NotarizationMethod::Locked`] and [`NotarizationMethod::Dynamic`]. +/// The type parameter `M` selects the Notarization Method and enforces the +/// associated configuration constraints at compile time. The two supported +/// markers are [`Locked`] and [`Dynamic`]. #[derive(Debug, Clone)] pub struct NotarizationBuilder { - /// The data to be notarized + /// The notarized payload and its optional state-associated metadata. pub state: Option, - /// A permanent description set at creation + /// A permanent description set at creation. pub immutable_description: Option, - /// Metadata that can be updated + /// Initial updatable metadata. /// - /// Dynamic notarizations can be updated anytime after creation - /// Locked notarizations are immutable after creation + /// Mutability after creation depends on the Notarization Method: + /// * `Dynamic`: updatable after creation via + /// [`NotarizationClient::update_metadata`](crate::client::NotarizationClient::update_metadata). + /// * `Locked`: immutable after creation. pub updatable_metadata: Option, - /// Time restriction for deletion (Locked only) + /// Time restriction for destruction. Only configurable for the `Locked` + /// Notarization Method. pub delete_lock: Option, - /// Time restriction for transfers (Dynamic only) + /// Time restriction for ownership transfer. Only configurable for the + /// `Dynamic` Notarization Method. pub transfer_lock: Option, - /// The notarization method + /// The Notarization Method. pub method: NotarizationMethod, _marker: PhantomData, } impl NotarizationBuilder { - /// Creates a new builder for a locked notarization. + /// Creates a new builder for a Locked-Notarization. /// - /// Locked notarizations are immutable after creation. They cannot be updated - /// or transferred. Optionally, they can only be destroyed after a specified time period. + /// Locked-Notarizations are immutable after creation. They cannot be + /// updated or transferred. Optionally, their destruction can be gated by + /// a `delete_lock` set via [`Self::with_delete_lock`]. /// /// ## Example /// @@ -110,7 +117,7 @@ impl NotarizationBuilder { /// Sets when the notarization can be destroyed. /// - /// By default, locked notarizations can be destroyed freely. Use this + /// By default, Locked-Notarizations can be destroyed freely. Use this /// to add time-based restrictions. /// /// ## Parameters @@ -153,11 +160,11 @@ impl NotarizationBuilder { } impl NotarizationBuilder { - /// Creates a new builder for a dynamic notarization. + /// Creates a new builder for a Dynamic-Notarization. /// - /// Dynamic notarizations can be updated after creation and optionally + /// Dynamic-Notarizations can be updated after creation and optionally /// transferred to other owners. They maintain a version counter that - /// increments with each update. + /// increments with each `state` update. /// /// ## Example /// @@ -180,8 +187,10 @@ impl NotarizationBuilder { /// Sets restrictions on when the notarization can be transferred. /// - /// By default, dynamic notarizations can be transferred freely. Use this - /// to add time-based restrictions. + /// By default, Dynamic-Notarizations can be transferred freely. Use this + /// to add time-based restrictions. When the lock is `TimeLock::None`, + /// the resulting notarization carries no `LockMetadata` at all and is + /// freely transferable. /// /// ## Parameters /// @@ -206,8 +215,8 @@ impl NotarizationBuilder { /// Finalizes the builder and creates a transaction builder. /// - /// Unlike locked notarizations, dynamic notarizations have no required fields - /// beyond the method type. + /// Unlike Locked-Notarizations, Dynamic-Notarizations have no required + /// fields beyond the method type. /// /// ## Example /// @@ -310,8 +319,10 @@ impl NotarizationBuilder { /// Sets initial updatable metadata. /// - /// Unlike the immutable description, this metadata can be updated later - /// (for dynamic notarizations only). + /// Mutability after creation depends on the Notarization Method: + /// * `Dynamic`: updatable via + /// [`NotarizationClient::update_metadata`](crate::client::NotarizationClient::update_metadata). + /// * `Locked`: fixed at creation alongside `state`. /// /// ## Example /// diff --git a/notarization-rs/src/core/transactions/create.rs b/notarization-rs/src/core/transactions/create.rs index 642a2376..34ad26f9 100644 --- a/notarization-rs/src/core/transactions/create.rs +++ b/notarization-rs/src/core/transactions/create.rs @@ -1,13 +1,23 @@ // Copyright 2020-2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! # Notarization +//! # Create Notarization //! -//! This module defines the notarization struct and the operations for notarizations. +//! This module defines the create-notarization transaction. //! //! ## Overview //! -//! The notarization is a struct that contains the state, metadata, and operations for a notarization. +//! The create-notarization transaction creates a new on-chain +//! `Notarization` object and transfers it to the transaction sender. The +//! marker type parameter `M` selects the Notarization Method and the set of +//! per-method invariants enforced before submission: +//! * `Dynamic`: the resulting object has no `LockMetadata` when `transfer_lock` is `TimeLock::None`; otherwise its +//! `LockMetadata` has `update_lock = delete_lock = TimeLock::None` and the supplied `transfer_lock`. +//! * `Locked`: the resulting object always carries `LockMetadata` with both `update_lock` and `transfer_lock` pinned to +//! `TimeLock::UntilDestroyed` and `delete_lock` set to the supplied value. +//! +//! `state_version_count` starts at `0` and `last_state_change_at` is set to +//! the on-chain clock timestamp at creation. use async_trait::async_trait; use iota_interaction::rpc_types::{ @@ -29,7 +39,13 @@ use super::super::types::{ }; use crate::error::Error; -/// A transaction that creates a new notarization. +/// A transaction that creates a new notarization on-chain. +/// +/// On success the resulting `Notarization` object is transferred to the +/// transaction sender, and the on-chain transaction emits one of: +/// +/// - `DynamicNotarizationCreated` for `CreateNotarization` +/// - `LockedNotarizationCreated` for `CreateNotarization` #[derive(Debug, Clone)] pub struct CreateNotarization { builder: NotarizationBuilder, diff --git a/notarization-rs/src/core/transactions/destroy.rs b/notarization-rs/src/core/transactions/destroy.rs index 7e338a3e..043f4c87 100644 --- a/notarization-rs/src/core/transactions/destroy.rs +++ b/notarization-rs/src/core/transactions/destroy.rs @@ -3,11 +3,15 @@ //! # Destroy Notarization //! -//! This module defines the destroy notarization transaction. +//! This module defines the destroy-notarization transaction. //! //! ## Overview //! -//! The destroy notarization transaction is used to destroy a notarization. +//! The destroy-notarization transaction permanently destroys a notarization +//! and releases its object ID. All package-local [`TimeLock`](super::super::types::TimeLock)s +//! of the attached [`LockMetadata`](super::super::types::LockMetadata) are +//! destroyed in the process. The on-chain gating check `is_destroy_allowed` +//! ensures that no `UnlockAt` lock is still active. use async_trait::async_trait; use iota_interaction::OptionalSync; @@ -21,7 +25,17 @@ use tokio::sync::OnceCell; use super::super::operations::{NotarizationImpl, NotarizationOperations}; use crate::error::Error; -/// A transaction that destroys a notarization +/// A transaction that destroys a notarization on-chain and releases its +/// object ID. +/// +/// All package-local [`TimeLock`](super::super::types::TimeLock)s of the +/// attached [`LockMetadata`](super::super::types::LockMetadata) are +/// destroyed in the process. The notarization must currently be +/// destroy-allowed (see +/// [`NotarizationClientReadOnly::is_destroy_allowed`](crate::client::NotarizationClientReadOnly::is_destroy_allowed)); +/// otherwise the on-chain transaction aborts. +/// +/// Emits a `NotarizationDestroyed` event on success. pub struct DestroyNotarization { notarization_id: ObjectID, cached_ptb: OnceCell, diff --git a/notarization-rs/src/core/transactions/transfer.rs b/notarization-rs/src/core/transactions/transfer.rs index b50eb7b6..31ab2bf8 100644 --- a/notarization-rs/src/core/transactions/transfer.rs +++ b/notarization-rs/src/core/transactions/transfer.rs @@ -3,13 +3,19 @@ //! # Transfer Notarization //! -//! This module defines the transfer notarization transaction. +//! This module defines the transfer-notarization transaction. //! //! ## Overview //! -//! The transfer notarization transaction is used to transfer ownership of a dynamic notarization to a new address. +//! The transfer-notarization transaction transfers ownership of a +//! Dynamic-Notarization to a new address. Permitted only when the +//! notarization has no [`LockMetadata`](super::super::types::LockMetadata) +//! or when its `transfer_lock` is not currently active. //! -//! Note that this transaction is only available for dynamic notarizations. +//! Behaviour depends on the Notarization Method: +//! * `Dynamic`: gated by the configured `transfer_lock`. Submitting while the lock is engaged aborts on-chain. +//! * `Locked`: always aborts on-chain — Locked-Notarizations have their `transfer_lock` pinned to +//! `TimeLock::UntilDestroyed` and are therefore non-transferable. use async_trait::async_trait; use iota_interaction::OptionalSync; @@ -23,7 +29,20 @@ use tokio::sync::OnceCell; use super::super::operations::{NotarizationImpl, NotarizationOperations}; use crate::error::Error; -/// A transaction that transfers ownership of a dynamic notarization. +/// A transaction that transfers ownership of a Dynamic-Notarization to +/// another address. +/// +/// Permitted only when the notarization has no +/// [`LockMetadata`](super::super::types::LockMetadata) or when its +/// `transfer_lock` is not currently active. +/// +/// Behaviour depends on the Notarization Method: +/// * `Dynamic`: on success the notarization is transferred to `recipient`. Submitting while the configured +/// `transfer_lock` is engaged aborts on-chain. +/// * `Locked`: always aborts on-chain — Locked-Notarizations have their `transfer_lock` pinned to +/// `TimeLock::UntilDestroyed` and are therefore non-transferable. +/// +/// Emits a `DynamicNotarizationTransferred` event on success. pub struct TransferNotarization { recipient: IotaAddress, notarization_id: ObjectID, diff --git a/notarization-rs/src/core/transactions/update_metadata.rs b/notarization-rs/src/core/transactions/update_metadata.rs index 9f441167..149262ac 100644 --- a/notarization-rs/src/core/transactions/update_metadata.rs +++ b/notarization-rs/src/core/transactions/update_metadata.rs @@ -1,13 +1,18 @@ // Copyright 2020-2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! # Notarization Metadata +//! # Update Metadata //! -//! This module defines the metadata for notarizations. +//! This module defines the update-metadata transaction. //! //! ## Overview //! -//! The metadata is used to store the metadata for a notarization. +//! The update-metadata transaction replaces the `updatable_metadata` of an +//! existing notarization. It does not affect `state`, `state_version_count`, +//! `last_state_change_at`, or the immutable description. Behaviour depends +//! on the Notarization Method: +//! * `Dynamic`: always permitted — the underlying `update_lock` is fixed to `TimeLock::None`. +//! * `Locked`: always aborts on-chain, because the underlying `update_lock` is pinned to `TimeLock::UntilDestroyed`. use async_trait::async_trait; use iota_interaction::OptionalSync; @@ -21,7 +26,15 @@ use tokio::sync::OnceCell; use super::super::operations::{NotarizationImpl, NotarizationOperations}; use crate::error::Error; -/// A transaction that updates the metadata of a notarization. +/// A transaction that replaces the `updatable_metadata` of an existing +/// notarization. +/// +/// Does not affect `state`, `state_version_count`, `last_state_change_at`, +/// or the immutable description. +/// +/// Behaviour depends on the Notarization Method: +/// * `Dynamic`: always permitted — the underlying `update_lock` is fixed to `TimeLock::None`. +/// * `Locked`: always aborts on-chain, because the underlying `update_lock` is pinned to `TimeLock::UntilDestroyed`. pub struct UpdateMetadata { metadata: Option, /// The ID of the notarization to update diff --git a/notarization-rs/src/core/transactions/update_state.rs b/notarization-rs/src/core/transactions/update_state.rs index 4c151c60..6fa9883f 100644 --- a/notarization-rs/src/core/transactions/update_state.rs +++ b/notarization-rs/src/core/transactions/update_state.rs @@ -3,13 +3,14 @@ //! # Update State //! -//! This module defines the update state transaction. +//! This module defines the update-state transaction. //! //! ## Overview //! -//! The update state transaction is used to update the state of a notarization. -//! -//! Note that this transaction is only available for dynamic notarizations. +//! The update-state transaction replaces the `state` of an existing +//! notarization. Behaviour depends on the Notarization Method: +//! * `Dynamic`: always permitted — the underlying `update_lock` is fixed to `TimeLock::None`. +//! * `Locked`: always aborts on-chain, because the underlying `update_lock` is pinned to `TimeLock::UntilDestroyed`. use async_trait::async_trait; use iota_interaction::OptionalSync; @@ -24,10 +25,18 @@ use super::super::operations::{NotarizationImpl, NotarizationOperations}; use super::super::types::State; use crate::error::Error; -/// A transaction that updates the state of an existing notarization. +/// A transaction that replaces the `state` of an existing notarization. +/// +/// On success the on-chain transaction bumps +/// `OnChainNotarization::state_version_count` by one and refreshes +/// `OnChainNotarization::last_state_change_at` to the on-chain clock +/// timestamp (in milliseconds since the Unix epoch). +/// +/// Behaviour depends on the Notarization Method: +/// * `Dynamic`: always permitted — the underlying `update_lock` is fixed to `TimeLock::None`. +/// * `Locked`: always aborts on-chain, because the underlying `update_lock` is pinned to `TimeLock::UntilDestroyed`. /// -/// This transaction can only be used with dynamic notarizations, as locked -/// notarizations are immutable after creation. +/// Emits a `NotarizationUpdated` event on success. /// /// ## Example /// diff --git a/notarization-rs/src/core/types/metadata.rs b/notarization-rs/src/core/types/metadata.rs index fc507b41..fddf73bb 100644 --- a/notarization-rs/src/core/types/metadata.rs +++ b/notarization-rs/src/core/types/metadata.rs @@ -5,13 +5,17 @@ use serde::{Deserialize, Serialize}; use super::timelock::LockMetadata; -/// The immutable metadata of a notarization. +/// Immutable provenance fields of a notarization, fixed at creation time. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ImmutableMetadata { - /// Timestamp when the `Notarization` was created + /// Creation timestamp, in milliseconds since the Unix epoch. pub created_at: u64, - /// Description of the `Notarization` + /// Human-readable description of the notarization. pub description: Option, - /// Optional lock metadata for `Notarization` + /// Optional lock metadata. + /// + /// Presence depends on the Notarization Method: + /// * `Dynamic`: absent when the Dynamic-Notarization carries no transfer lock; present otherwise. + /// * `Locked`: always present. pub locking: Option, } diff --git a/notarization-rs/src/core/types/mod.rs b/notarization-rs/src/core/types/mod.rs index debe496b..b1b40c59 100644 --- a/notarization-rs/src/core/types/mod.rs +++ b/notarization-rs/src/core/types/mod.rs @@ -16,9 +16,18 @@ use serde::{Deserialize, Serialize}; pub use state::*; pub use timelock::*; -/// Indicates the used Notarization method. +/// Identifies the Notarization Method of a notarization. +/// +/// The Notarization Method is fixed at creation and determines which +/// operations are permitted on the notarization afterwards. The set of +/// Notarization Methods is closed in the current version of the package but +/// may be extended in future versions. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum NotarizationMethod { + /// Method whose `state` and `updatable_metadata` can be updated after + /// creation and which may optionally be transfer-locked. Dynamic, + /// Method whose `state` and `updatable_metadata` are immutable after + /// creation and whose destruction is gated by a `delete_lock`. Locked, } diff --git a/notarization-rs/src/core/types/notarization.rs b/notarization-rs/src/core/types/notarization.rs index bd5fb897..8c4d43d7 100644 --- a/notarization-rs/src/core/types/notarization.rs +++ b/notarization-rs/src/core/types/notarization.rs @@ -10,46 +10,52 @@ use super::metadata::ImmutableMetadata; use super::state::State; /// A notarization record stored on the blockchain. +/// +/// Stores user-defined data together with immutable provenance, optional +/// updatable metadata, and lock metadata that governs whether the object can +/// be updated, transferred, or destroyed. The selected +/// [`NotarizationMethod`] determines which mutations are allowed after +/// creation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct OnChainNotarization { /// The unique identifier of the notarization. pub id: UID, - /// The state of the notarization. + /// Notarized data and its associated state metadata. /// - /// The `state` of a notarization contains the notarized data and metadata associated with - /// the current version of the `state`. + /// The `state` of a notarization contains the notarized data and metadata + /// associated with the current version of the `state`. /// - /// `state` can be updated depending on the used `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after notarization creation - /// - Locked: Immutable after notarization creation - /// - /// Use `NotarizationClient::update_state()` for `state` updates. + /// Mutability depends on the Notarization Method: + /// * `Dynamic`: updatable after creation via + /// [`NotarizationClient::update_state`](crate::client::NotarizationClient::update_state). + /// * `Locked`: immutable after creation. pub state: State, - /// The immutable metadata of the notarization. + /// Provenance fixed at creation time. /// - /// NOTE: - /// - provides immutable information, assertions and guaranties for third parties - /// - `immutable_metadata` are automatically created at creation time and cannot be updated thereafter + /// Carries the creation timestamp, the optional immutable description, + /// and the optional [`LockMetadata`](crate::core::types::LockMetadata). + /// Provides immutable information, assertions, and guarantees for third + /// parties and cannot be updated after creation. pub immutable_metadata: ImmutableMetadata, - /// The updatable metadata of the notarization. - /// - /// Provides context or additional information for third parties + /// Free-form metadata providing context or additional information for + /// third parties. /// - /// `updatable_metadata` can be updated depending on the used `NotarizationMethod`: - /// - Dynamic: Can be updated anytime after notarization creation - /// - Locked: Immutable after notarization creation + /// Mutability depends on the Notarization Method: + /// * `Dynamic`: updatable after creation via + /// [`NotarizationClient::update_metadata`](crate::client::NotarizationClient::update_metadata); updates do not + /// bump `state_version_count` nor change `last_state_change_at`. + /// * `Locked`: immutable after creation. /// - /// NOTE: - /// - `updatable_metadata` can be updated independently of `state` - /// - Updating `updatable_metadata` does not increase the `state_version_count` - /// - Updating `updatable_metadata` does not change the `last_state_change_at` timestamp - /// - Use `NotarizationClient::update_metadata()` for `updatable_metadata` updates. + /// `updatable_metadata` can be updated independently of `state`. pub updatable_metadata: Option, - /// The timestamp of the last state change (milliseconds since UNIX epoch) + /// Timestamp of the most recent `state` change, in milliseconds since + /// the Unix epoch. pub last_state_change_at: u64, - /// The number of state changes. + /// Number of times `state` has been updated since creation. `0` means + /// the state has not been updated since creation. pub state_version_count: u64, - /// The method of the notarization. + /// Notarization Method governing the mutation and destruction rules of + /// this notarization. pub method: NotarizationMethod, /// The owner of the notarization. #[serde(skip, default = "iota_address_zero")] diff --git a/notarization-rs/src/core/types/state.rs b/notarization-rs/src/core/types/state.rs index 1805ae9b..bb6362e1 100644 --- a/notarization-rs/src/core/types/state.rs +++ b/notarization-rs/src/core/types/state.rs @@ -52,31 +52,28 @@ use serde::{Deserialize, Deserializer, Serialize}; use super::super::move_utils; use crate::error::Error; -/// Represents the state of a notarization. +/// Versioned state of a notarization: the notarized `data` together with +/// optional state-associated `metadata`. /// -/// State encapsulates the data being notarized along with optional metadata. -/// It serves as the primary content container for both locked and dynamic -/// notarizations. +/// `State` is the primary content container of every notarization regardless +/// of the configured `NotarizationMethod`. /// -/// The notarization `State` can be updated by the owner depending on the used `NotarizationMethod`: -/// - Dynamic: `data` and `metadata` of the `State` can be updated anytime after creation -/// - Locked: The `State` is immutable after notarization creation -/// -/// `State` `data` and `metadata` can only be updated at once, using method -/// `NotarizationClient::update_state()` which will increase the `state_version_count` and update the -/// `last_state_change_at` timestamp of the notarization even if only the `metadata` are altered. +/// Whether the `State` of an existing notarization may change depends on the +/// Notarization Method: +/// * `Dynamic`: `data` and `metadata` are replaced together via +/// [`NotarizationClient::update_state`](crate::client::NotarizationClient::update_state). Every such update bumps +/// `OnChainNotarization::state_version_count` and refreshes `OnChainNotarization::last_state_change_at`, even when +/// only `metadata` changes. +/// * `Locked`: the `State` is immutable after creation. /// /// ## Type Parameter /// /// - `T`: The data type, defaults to [`Data`] which can be either bytes or text #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] pub struct State { - /// The actual data being notarized + /// The notarized payload. pub data: T, - /// Optional metadata describing the data - /// - /// Dynamic notarizations can be updated together with state data - /// Locked notarizations are immutable after creation + /// Optional state-associated metadata, versioned together with `data`. #[serde(default)] pub metadata: Option, } diff --git a/notarization-rs/src/core/types/timelock.rs b/notarization-rs/src/core/types/timelock.rs index 6263f10a..475bf553 100644 --- a/notarization-rs/src/core/types/timelock.rs +++ b/notarization-rs/src/core/types/timelock.rs @@ -27,11 +27,34 @@ use serde::{Deserialize, Serialize}; use super::super::move_utils; use crate::error::Error; -/// Metadata containing time-based access restrictions for a notarization. +/// Bundle of three [`TimeLock`]s controlling whether a notarization can be +/// updated, destroyed, or transferred. +/// +/// `delete_lock` cannot be `TimeLock::UntilDestroyed` — that variant is +/// reserved for the other locks. The `delete_lock`'s unlock time must be +/// no earlier than the `update_lock` and `transfer_lock` unlock times; +/// the on-chain constructor aborts otherwise. +/// +/// Permitted lock configurations depend on the Notarization Method: +/// * `Dynamic`: `update_lock` is fixed to `TimeLock::None`; `transfer_lock` may carry any [`TimeLock`] variant. +/// * `Locked`: both `update_lock` and `transfer_lock` are pinned to `TimeLock::UntilDestroyed` — Locked-Notarizations +/// are non-transferable and their state is immutable. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct LockMetadata { + /// Lock guarding `update_state` and `update_metadata`. + /// + /// Value depends on the Notarization Method: + /// * `Dynamic`: fixed to `TimeLock::None`. + /// * `Locked`: fixed to `TimeLock::UntilDestroyed`. pub update_lock: TimeLock, + /// Lock guarding destruction. Must not be `TimeLock::UntilDestroyed`; + /// its unlock time must be ≥ both other locks' unlock times. pub delete_lock: TimeLock, + /// Lock guarding ownership transfer. + /// + /// Role depends on the Notarization Method: + /// * `Dynamic`: gates `NotarizationClient::transfer_notarization`. + /// * `Locked`: pinned to `TimeLock::UntilDestroyed` — Locked-Notarizations are non-transferable. pub transfer_lock: TimeLock, } @@ -39,20 +62,21 @@ pub struct LockMetadata { /// notarizations. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum TimeLock { - /// A lock that is unlocked at a specific time. + /// A lock that unlocks at a specific Unix timestamp (seconds since Unix epoch) UnlockAt(u32), - /// A lock that is unlocked when the notarization is destroyed. + /// A permanent lock that never unlocks until the locked object is destroyed (can't be used for `delete_lock`) UntilDestroyed, + /// No lock applied None, } impl TimeLock { - /// Creates a new `TimeLock` with a specified unlock time.\ + /// Creates a new `TimeLock::UnlockAt` with a specified unlock time.\ /// /// The unlock time is the time in seconds since the Unix epoch and /// must be in the future. - pub fn new_with_ts(unlock_time: u32) -> Result { - if unlock_time + pub fn new_with_ts(unlock_time_sec: u32) -> Result { + if unlock_time_sec <= SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("system time is before the Unix epoch") @@ -61,7 +85,7 @@ impl TimeLock { return Err(Error::InvalidArgument("unlock time must be in the future".to_string())); } - Ok(TimeLock::UnlockAt(unlock_time)) + Ok(TimeLock::UnlockAt(unlock_time_sec)) } /// Creates a new `Argument` from the `TimeLock`. @@ -77,16 +101,16 @@ impl TimeLock { } /// Creates a new `Argument` for the `unlock_at` function. -pub(super) fn new_unlock_at(ptb: &mut Ptb, unlock_time: u32, package_id: ObjectID) -> Result { +pub(super) fn new_unlock_at(ptb: &mut Ptb, unlock_time_sec: u32, package_id: ObjectID) -> Result { let clock = move_utils::get_clock_ref(ptb); - let unlock_time = move_utils::ptb_pure(ptb, "unlock_time", unlock_time)?; + let unlock_time_sec = move_utils::ptb_pure(ptb, "unlock_time", unlock_time_sec)?; Ok(ptb.programmable_move_call( package_id, ident_str!("timelock").as_str().into(), ident_str!("unlock_at").as_str().into(), vec![], - vec![unlock_time, clock], + vec![unlock_time_sec, clock], )) } diff --git a/notarization-rs/src/error.rs b/notarization-rs/src/error.rs index c5e32f99..8065811b 100644 --- a/notarization-rs/src/error.rs +++ b/notarization-rs/src/error.rs @@ -3,7 +3,7 @@ use crate::iota_interaction_adapter::AdapterError; -/// Errors that can occur when managing Notarizations +/// Errors that can occur when managing notarizations #[derive(Debug, thiserror::Error, strum::IntoStaticStr)] #[non_exhaustive] pub enum Error { diff --git a/notarization-rs/src/lib.rs b/notarization-rs/src/lib.rs index 611fae81..25512ddd 100644 --- a/notarization-rs/src/lib.rs +++ b/notarization-rs/src/lib.rs @@ -1,6 +1,8 @@ // Copyright 2020-2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +#![doc = include_str!("../README.md")] + pub mod client; pub mod core; pub mod error;