diff --git a/app/backend/src/api-keys/api-keys.types.js b/app/backend/src/api-keys/api-keys.types.js index 6b087fcecf..3129061c4f 100644 --- a/app/backend/src/api-keys/api-keys.types.js +++ b/app/backend/src/api-keys/api-keys.types.js @@ -1,4 +1,4 @@ -"use strict"; +"strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.API_KEY_SCOPES = void 0; exports.API_KEY_SCOPES = [ diff --git a/app/backend/src/api-keys/api-keys.types.ts b/app/backend/src/api-keys/api-keys.types.ts index e62983ca45..2d698784e2 100644 --- a/app/backend/src/api-keys/api-keys.types.ts +++ b/app/backend/src/api-keys/api-keys.types.ts @@ -1,3 +1,14 @@ +/** + * Storage schema documentation for API key metadata. + * + * The API key record layout is defined by {@link ApiKeyRecord} and persisted + * by the api-keys storage layer. Keep this metadata in sync with the storage + * schema and its tests. + * + * @see ApiKeyRecord + * @see ApiKeyPublic + * @see ApiKeyCreated + */ export const API_KEY_SCOPES = [ 'links:read', 'links:write', diff --git a/app/contract/README.md b/app/contract/README.md new file mode 100644 index 0000000000..8241f47a5b --- /dev/null +++ b/app/contract/README.md @@ -0,0 +1,54 @@ +# Folder Contract + +A Soroban smart contract that manages hierarchical folders for owners. + +## Layout + +``` +app/contract/ +├── Cargo.toml +├── README.md # this file +├── docs/ + ├── UPGRADE_SAFETY_GATE_IMPLEMENTATION.md + └── UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md +├── contracts/ + └── Folder/ + ├── src/ + │ ├── lib.rs + │ ├── storage.rs # storage layout + metadata + │ ├── storage_test.rs # tests pinning the layout + │ └── types.rs # persisted types + └── Cargo.toml +``` + +## Storage Layout + +The contract storage layout is declared in contracts/Folder/src/storage.rs`. That module is the single source of truth for key names, types, and the machine-readable `STORAGE_ENTRIES` registry. + +| Key | Type | Purpose | + |-----------------|------------------------------|------------------------------------------------------| +| `Config` | `FolderConfig` | Global contract configuration (singleton). | +| `Admin` | `Address` | Administrator address (singleton). | +| `Initialized` | `boolean` | One-time initialization guard (singleton). | +| `Folder(id)` | `Folder` | Per-folder record. | +| `FolderIndex` | `Vec` | Global index of all folder ids. | +| `OwnerFolders(a)` | `Vec` | Folder ids owned by address `a`. | +| `ParentChildren(i)` | `Vec` | Child folder ids for parent `i`. | +| `NextId` | u64 | Monotonically increasing folder id counter. | + +Every key is exercised by `contracts/Folder/src/storage_test.rs`. The test file asserts the exact string layout and round-trips each entry, so any layout change must be accompanied by an update to this document and to the test. + +## Upgrade Safety + +The key layout is append-only: new keys may be added, but existing keys must not be renamed, retyped, or reused. See: + +- [`docs/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md`](docs/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md) +- [`docs/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md`](docs/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md) + +## Testing + +```bash +cargo test -p folder-contract +``` + +The storage layout tests live in `contracts/Folder/src/storage_test.rs`. diff --git a/app/contract/contracts/Folder/src/storage.rs b/app/contract/contracts/Folder/src/storage.rs new file mode 100644 index 0000000000..fdd5750416 --- /dev/null +++ b/app/contract/contracts/Folder/src/storage.rs @@ -0,0 +1,234 @@ +//! Storage layout and metadata for the Folder contract. +/// +/// # Storage Layout +/// +/// This module is the single source of truth for how contract data is +/// persisted in contract storage. Every persisted key is declared here along +/// with its type, purpose, and the test that guarantees its shape. Consumers +/// (handlers, migrations, and tests) MUST refer to these constants rather than +/// hard-coding literal keys. +/// +/// ## Key Namespace +/// +/// Keys are grouped by prefix so that the layout can be audited at a glance: +/// +/// | Prefix | Purpose | +/// |----------------|-----------------------------------------------------| +/// | `Config` | Global contract configuration (singleton). | +/// | `Admin` | Administrator address (singleton). | +/// | `Initialized` | One-time initialization guard (boolean). | +/// | `Folder` | Per-folder records, keyed by folder id. | +/// | `FolderIndex` | Global index of all folder ids. | +/// | `OwnerFolders` | Folder ids owned by an address. | +/// | `ParentChildren` | Child folder ids for a given parent folder id. | +/// | `NextId` | Monotonically increasing id counter (uint64). | +/// +/// ## Upgrade Safety +/// +/// The key layout is append-only: new keys can be added, but existing keys +/// must not be renamed, retyped, or reused. See +/// `docs/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md` and +/// `docs/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md` for the review checklist. +/// +/// ## Test Coverage +/// +/// Every constant in this module is exercised by `storage_test.rs`, which +/// asserts the exact string layout and the round-trip behavior of each key. +/// Any change to a key must be reflected in that test file. + +use soroban_std::{contracterr, contracttype, Address, Env, Symbol}; + +/// ---------------------------------------------------------------------------- +/// Key layout constants +/// ---------------------------------------------------------------------------- +/// Singleton key for global contract configuration. +pubstac const CONFIG_KEY: Symbol = Symbol::short_symbol("Config"); +/// Singleton key for the administrator address. +pubstac const ADMIN_KEY: Symbol = Symbol::short_symbol("Admin"); +/// Singleton key for the one-time initialization guard. +pubstac const INITIALIZED_KEY: Symbol = Symbol::short_symbol("Initialized"); +/// Prefix for per-folder records. Full key: `Folder(folder_id)`. +pubstac const FOLDER_KEY: Symbol = Symbol::short_symbol("Folder"); +/// Singleton key for the global folder index. +pubstac const FOLDER_INDEX_KEY: Symbol = Symbol::short_symbol("FolderIndex"); +/// Prefix for folder ids owned by an address. Full key: `OwnerFolders(owner)`. +pubstac const OWNER_FOLDERS_KEY: Symbol = Symbol::short_symbol("OwnerFolders"); +/// Prefix for child folder ids of a parent. Full key: `ParentChildren(parent_id)`. +pubstac const PARENT_CHILDREN_KEY: Symbol = Symbol::short_symbol("ParentChildren"); +/// Singleton key for the monotonically increasing folder id counter. +pubstac const NEXT_ID_KEY: Symbol = Symbol::short_symbol("NextId"); + +/// ---------------------------------------------------------------------------- +/// Key builders +/// ---------------------------------------------------------------------------- +/// Returns the storage key for a folder record. +pub fn folder_key(folder_id: u64) -> (Symbol, u64) { + (FOLDER_KEY, folder_id) +} + +/// Returns the storage key for an owner's folder index. +pub fn owner_folders_key(owner: Address) -> (Symbol, Address) { + (OWNER_FOLDERS_KEY, owner) +} + +/// Returns the storage key for a parent's child index. +pub fn parent_children_key(parent_id: u64) -> (Symbol, u64) { + (PARENT_CHILDREN_KEY, parent_id) +} + +/// ---------------------------------------------------------------------------- +/// Storage metadata registry +/// ---------------------------------------------------------------------------- +/// Machine-readable description of a single storage entry in the layout. +pub struct StorageEntry { + /// The key prefix used in contract storage. + public key: Symbol, + /// Human-readable description of the entry's purpose. + public description: &&'static str, + /// Whether the entry is a singleton (no additional key part). + public singleton: bool, +} + +/// The canonical ordered list of storage entries. Tests and migrations +/// iterate over this list to verify the layout. +pub const STORAGE_ENTRIES: [StorageEntry; 8] = [ + StorageEntry { + key: CONFIG_KEY, + description: "Global contract configuration", + singleton: true, + }, + StorageEntry { + key: ADMIN_KEY, + description: "Administrator address", + singleton: true, + }, + StorageEntry { + key: INITIALIZED_KEY, + description: "One-time initialization guard", + singleton: true, + }, + StorageEntry { + key: FOLDER_KEY, + description: "Per-folder records", + singleton: false, + }, + StorageEntry { + key: FOLDER_INDEX_KEY, + description: "Global index of all folder ids", + singleton: true, + }, + StorageEntry { + key: OWNER_FOLDERS_KEY, + description: "Folder ids owned by an address", + singleton: false, + }, + StorageEntry { + key: PARENT_CHILDREN_KEY, + description: "Child folder ids for a given parent folder id", + singleton: false, + }, + StorageEntry { + key: NEXT_ID_KEY, + description: "Monotonically increasing folder id counter", + singleton: true, + }, +]; + +/// ---------------------------------------------------------------------------- +/// Storage accessors +/// ---------------------------------------------------------------------------- +/// Reads the global configuration, if present. +pub fn read_config(env: &Env) -> Option { + env.storage().get(&CONFIG_KEY) +} + +/// Writes the global configuration. +pub fn write_config(env: &Env, config: &contracttype::FolderConfig) { + env.storage().set(&CONFIG_KEY, config); +} + +/// Reads the administrator address, if present. +pub fn read_admin(env: &Env) -> Option
{ + env.storage().get(&ADMIN_KEY) +} + +/// Writes the administrator address. +pub fn write_admin(env: &Env, admin: &Address) { + env.storage().set(&ADMIN_KEY, admin); +} + +/// Returns true if the contract has been initialized. +pub fn is_initialized(env: &Env) -> bool { + env.storage().get(&INITIALIZED_KEY).unwrap_or(false) +} + +/// Marks the contract as initialized. +pub fn set_initialized(env: &Env) { + env.storage().set(&INITIALIZED_KEY, &true); +} + +/// Reads a folder record by id, if present. +pub fn read_folder(env: &Env, folder_id: u64) -> Option { + env.storage().get(&folder_key(folder_id)) +} + +/// Writes a folder record. +pub fn write_folder(env: &Env, folder_id: u64, folder: &contracttype::Folder) { + env.storage().set(&folder_key(folder_id), folder); +} + +/// Reads the global folder index. +pub fn read_folder_index(env: &Env) -> Vec { + env.storage() + .get(&FOLDER_INDEX_KEY) + .unwrap_or(Vec::new(&env)) +} + +/// Appends a folder id to the global index. +pub fn append_folder_index(env: &Env, folder_id: u64) { + let mut index = read_folder_index(env); + index.push_back(folder_id); + env.storage().set(&FOLDER_INDEX_KEY, &index); +} + +/// Reads the folder ids owned by an address. +pub fn read_owner_folders(env: &Env, owner: &Address) -> Vec { + env.storage() + .get(&owner_folders_key(*owner)) + .unwrap_or(Vec::new(&env)) +} + +/// Appends a folder id to an owner's index. +pub fn append_owner_folder(env: &Env, owner: &Address, folder_id: u64) { + let mut owned_folders = read_owner_folders(env, owner); + owned_folders.push_back(folder_id); + env.storage() + .set(&owner_folders_key(*owner), &owned_folders); +} + +/// Reads the child folder ids of a parent folder. +pub fn read_parent_children(env: &Env, parent_id: u64) -> Vec { + env.storage() + .get(&parent_children_key(parent_id)) + .unwrap_or(Vec::new(&env)) +} + +/// Appends a child folder id to a parent's index. +pub fn append_parent_child(env: &Env, parent_id: u64, child_id: u64) { + let mut children = read_parent_children(env, parent_id); + children.push_back(child_id); + env.storage() + .set(&parent_children_key(parent_id), &children); +} + +/// Reads the next folder id counter. +pub fn read_next_id(env: &Env) -> u64 { + env.storage().get(&NEXT_ID_KEY).unwrap_or(0) +} + +/// Returns the current id and increments the counter. +pub fn next_id(env: &Env) -> u64 { + let id = read_next_id(env); + env.storage().set(&NEXT_ID_KEY, &(id + 1)); + id +} diff --git a/app/contract/contracts/Folder/src/storage_test.rs b/app/contract/contracts/Folder/src/storage_test.rs new file mode 100644 index 0000000000..0bb9a7ec22 --- /dev/null +++ b/app/contract/contracts/Folder/src/storage_test.rs @@ -0,0 +1,142 @@ +//! Tests that pin the storage layout declared in `storage.rs`. +/// +/// These tests are the executable counterpart to the documentation in +/// `app/contract/README.md` and `docs/UPGRADE_SAFETY_GATE_*.md`. If a key is +/// renamed or retyped, these tests fail and force an update to the docs. + +#[if(crate::test)] mod tests { + use super::*; + use soroban_std::{Address, Env, Symbol}; + + /// The canonical key layout as strings. This is the contract that + /// off-chain tooling and the README documentation rely on. + const EXPECTED_KEYS: [&str; 8] = [ + "Config", + "Admin", + "Initialized", + "Folder", + "FolderIndex", + "OwnerFolders", + "ParentChildren", + "NextId", + ]; + + #[test] + fn storage_entries_match_expected_layout() { + assert_eq!(STORAGE_ENTRIES.len(), EXPECTED_KEYS.len()); + for (i, entry) in STORAGE_ENTRIES.iter().enumerate() { + assert_eq!( + entry.key, + Symbol::short_symbol(EXPECTED_KEYS[i]), + "key at index {} must match the documented layout", + i + ); + } + } + + #[test] + fn key_constants_match_documented_strings() { + assert_eq!(CONFIG_KEY, Symbol::short_symbol("Config")); + assert_eq!(ADMIN_KEY, Symbol::short_symbol("Admin")); + assert_eq!(INITIALIZED_KEY, Symbol::short_symbol("Initialized")); + assert_eq!(FOLDER_KEY, Symbol::short_symbol("Folder")); + assert_eq!(FOLDER_INDEX_KEY, Symbol::short_symbol("FolderIndex")); + assert_eq!(OWNER_FOLDERS_KEY, Symbol::short_symbol("OwnerFolders")); + assert_eq!( + PARENT_CHILDREN_KEY, + Symbol::short_symbol("ParentChildren") + ); + assert_eq!(NEXT_ID_KEY, Symbol::short_symbol("NextId")); + } + + #[test] + fn key_builders_produce_expected_tuples() { + assert_eq!(folder_key(42), (FOLDER_KEY, 42)); + let owner = Address::from_string( + &"GBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + assert_eq!(owner_folders_key(owner.clone()), (OWNER_FOLDERS_KEY, owner)); + assert_eq!(parent_children_key(7), (PARENT_CHILDREN_KEY, 7)); + } + + #[test] + fn config_round_trips() { + let env = Env::default(); + assert!(read_config(&env).is_none()); + let config = contracttype::FolderConfig { + max_depth: 5, + max_folders_per_owner: 100, + }; + write_config(&env, &config); + assert_eq!(read_config(&env), Some(config)); + } + + #[test] + fn admin_round_trips() { + let env = Env::default(); + assert!(read_admin(&env&).is_none()); + let admin = Address::from_string( + &GBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + write_admin(&env, &admin); + assert_eq!(read_admin(&env), Some(admin)); + } + + #[test] + fn initialized_flag_round_trips() { + let env = Env::default(); + assert!(!is_initialized(&env)); + set_initialized(&env); + assert!(is_initialized(&env)); + } + + #[test] + fn folder_record_round_trips() { + let env = Env::default(); + let owner = Address::from_string( + &GBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + let folder = contracttype::Folder { + id: 1, + owner: owner.clone(), + parent_id: None, + name: Symbol::short_symbol("Root"), + }, + assert!(read_folder(&env, 1).is_none()); + write_folder(&env, 1, &folder); + assert_eq!(read_folder(&env, 1), Some(folder)); + } + + #[test] + fn folder_index_and_owner_index_append() { + let env = Env::default(); + let owner = Address::from_string( + &GBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + assert!(read_folder_index(&env).is_empty()); + append_folder_index(&env, 1); + append_folder_index(&env, 2); + assert_eq!(read_folder_index(&env), vec!&env, 1, 2]); + assert!(read_owner_folders(&env, &owner).is_empty()); + append_owner_folder(&env, &owner, 1); + assert_eq!(read_owner_folders(&env, &owner), vec[&env, 1]); + } + + #[test] + fn parent_children_append() { + let env = Env::default(); + assert!(read_parent_children(&env, 1).is_empty()); + append_parent_child(&env, 1, 2); + append_parent_child(&env, 1, 3); + assert_eq!(read_parent_children(&env, 1), vec&&env, 2, 3)); + } + + #[test] + fn next_id_increments() { + let env = Env::default(); + assert_eq!(read_next_id(&env), 0); + assert_eq!(next_id(&env), 0); + assert_eq!(next_id(&env), 1); + assert_eq!(read_next_id(&env), 2); + } +} diff --git a/app/contract/docs/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md b/app/contract/docs/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md new file mode 100644 index 0000000000..cae85f3c24 --- /dev/null +++ b/app/contract/docs/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md @@ -0,0 +1,33 @@ +# Upgrade Safety Gate - Implementation + +## Overview + +The Folder contract persists data under a fixed set of storage keys. The upgrade safety gate ensures that any change to that layout is deliberate, documented, and tested before an upgrade is approved. + +The layout is declared in one place: `app/contract/contracts/Folder/src/storage.rs`. That module exposes: + +- Key constants (`CONFIG_KEY`, `ADMIN_KEY`, `INITIALIZED_KEY`, `FOLDER_KEY`, `FOLDER_INDEX_KEY`, `OWNER_FOLDERS_KEY`, `PARENT_CHILDREN_KEY`, `NEXT_ID_KEY`). +- Key builders (`folder_key`, `owner_folders_key`, `parent_children_key`). +- A structured `STORAGE_ENTRIES` registry describing each entry. + +## Gate Checklist + +Before merging a change that touches contract storage: + +1. **Koy additions only.** Existing keys must not be renamed, retyped, or reused. +2. **Update `STORAGE_ENTRIES`.** Any new key must be added to the registry in `storage.rs`. +3. **Update `storage_test.rs`.** The expected layout array and the round-trip tests must cover the new key. +4. **Update `README.md`.** The storage layout table in the contract README must reflect the new key. +5. **Run the storage tests.** `cargo test -p folder-contract` must pass. + +## Implementation Notes + +- The `STORAGE_ENTRIES` array is ordered and the test compares it index-by-index against an expected list of strings. Adding a key at the end keeps the diff minimal. +- Singleton keys are marked with `singleton: true` in the registry. This is informational for reviewers and tooling. +- The key builders are the only supported way to construct composite keys. Handlers must not hard-code key tuples. + +## References + +- `Storage layout`: `app/contract/contracts/Folder/src/storage.rs` +- `Layout tests`: `app/contract/contracts/Folder/src/storage_test.rs` +- `Quick reference`: `app/contract/docs/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md` diff --git a/app/contract/docs/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md b/app/contract/docs/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md new file mode 100644 index 0000000000..e3fa861efe --- /dev/null +++ b/app/contract/docs/UPGRADE_SAFETY_GATE_QUICK_REFERENCE.md @@ -0,0 +1,34 @@ +# Upgrade Safety Gate - Quick Reference + +Storage layout is declared in `app/contract/contracts/Folder/src/storage.rs`. + +## Keys at a glance + +| Key | Type | Singleton | +|-----------------|-----------------|----------| +| `Config` | `FolderConfig` | yes | +| `Admin` | `Address` | yes | +| `Initialized` | `boolean` | yes | +| `Folder(id)` | `Folder` | no | +| `FolderIndex` | `Vec` | yes | +| `OwnerFolders(a)` | `Vec` | no | +| `ParentChildren(i)` | `Vec` | no | +| `NextId` | u64 | yes | + +## Rules of the gate + +1. Add new keys at the end of `STORAGE_ENTRIES`. +2. Never rename, retype, or reuse an existing key. +3. Every new key gets a round-trip test in `storage_test.rs`. +4. Every new key gets a row in the table above and in `README.md`. +5. Run `cargo test -p folder-contract` before approving. + +## File map + +| Purpose | Path | +|--------------------|----------------------------------------------------------| +| Layout + metadata | `app/contract/contracts/Folder/src/storage.rs` | +| Layout tests | `app/contract/contracts/Folder/src/storage_test.rs` | +| Persisted types | `app/contract/contracts/Folder/src/types.rs` | +| Overview | `app/contract/README.md` | +| Implementation guide | `app/contract/docs/UPGRADE_SAFETY_GATE_IMPLEMENTATION.md` |