diff --git a/.ai/knowledge/README.md b/.ai/knowledge/README.md index d07928eb6..69a2509b3 100644 --- a/.ai/knowledge/README.md +++ b/.ai/knowledge/README.md @@ -15,7 +15,7 @@ Concept pages explain cross-module ideas. Read `concepts/README.md` before creat | Query, Router, RouteQuery, RouteNotFound, Reject, Accept, Session, Preprocessor, Gateway, routing pipeline | `concepts/query.md` | | Auth, Authorize, Action, ActionSudo, ActionRelayFor, auth handler, authorization | `concepts/auth.md` | | lib/astrald, lib/apphost, lib/routing, lib/apps, lib/ipc, lib/query, astrald.Default, OpRouter, IncomingQuery, Serve, AppRegistrar, client library | `concepts/lib.md` | -| Object, ObjectID, Repository, Receiver, Describer, Searcher, Finder, Holder, objects.Load, objects.Save, repo group | `concepts/objects.md` | +| Object, ObjectID, Repository, Receiver, Describer, Searcher, Finder, Holder, objects.Load, objects.Save, objects.purge, object holds, repo group | `concepts/objects.md` | | Op, operation, op_*.go, OpName, args struct, ops.Set, method name, query method string | `concepts/operations.md` | | Node, module lifecycle, Load Inject LoadDependencies Prepare Run, Scheduler, core.Inject, core.Node | `concepts/node.md` | | Transport, exonet, Stream, Link, link strategy, TCP, KCP, Tor, layer stack | `concepts/transport.md` | @@ -42,16 +42,16 @@ Read the module guide when entering that module's source. | Module path / keywords | Read | |---|---| | `mod/nodes/`, Link, Stream, Session, peer, flow control, frame protocol, migration, link establishment | `modules/nodes.md` | -| `mod/apphost/`, token, handler registration, IPC bridge, guest connection, contract indexing | `modules/apphost.md` | -| `mod/objects/`, Load[T], Save, Commit, Discard, Blueprint, repo group, Push, object store | `modules/objects.md` | +| `mod/apphost/`, token, handler registration, IPC bridge, guest connection, contract indexing, app-owned object holds | `modules/apphost.md` | +| `mod/objects/`, Load[T], Save, Commit, Discard, Blueprint, repo group, Push, object store, purge, Holder | `modules/objects.md` | | `mod/dir/`, alias, filter, resolve, DisplayName, ApplyFilters, IdentityFilter, identity resolver | `modules/dir.md` | -| `mod/auth/`, Authorize, Add, auth handler | `modules/auth.md` | +| `mod/auth/`, Authorize, Add, auth handler, active contract object holder | `modules/auth.md` | | `mod/gateway/`, relay socket, binder, connector, gateway relay | `modules/gateway.md` | | `mod/nat/`, hole punch, ConePuncher, UDP traversal, nat.Hole | `modules/nat.md` | | `mod/kcp/`, KCP, UDP transport, local-port mapping, ephemeral listener | `modules/kcp.md` | | `mod/tcp/`, TCP listener, ListenPort, CreateEphemeralListener | `modules/tcp.md` | | `mod/exonet/`, Dialer, Unpacker, Parser, SetDialer, network name, transport registry | `modules/exonet.md` | -| `mod/user/`, user identity, Swarm member, MaintainLinkTask, node contract | `modules/user.md` | +| `mod/user/`, user identity, Swarm member, MaintainLinkTask, node contract, asset object holder | `modules/user.md` | | `mod/nearby/`, local discovery, broadcast, Stealth, Visible, UDP discovery | `modules/nearby.md` | | `mod/scheduler/`, schedule task, run task, PoolLocker, Releaser, FuncAdapter | `modules/scheduler.md` | | `mod/events/`, event, subscribe, emit, EventReceiver, EventEmitter | `modules/events.md` | @@ -60,7 +60,7 @@ Read the module guide when entering that module's source. | `mod/services/`, service registry, named service, bind service, AddService | `modules/services.md` | | `mod/shell/`, shell command, terminal, admin CLI, command handler | `modules/shell.md` | | `mod/tree/`, config tree, persistent setting, tree.Value, Follow, tree path | `modules/tree.md` | -| `mod/crypto/`, sign, verify, Engine, PrivateKey, SignableObject, secp256k1, BIP137 | `modules/crypto.md` | +| `mod/crypto/`, sign, verify, Engine, PrivateKey, SignableObject, secp256k1, BIP137, private-key object holder | `modules/crypto.md` | | `mod/ip/`, LocalIPs, PublicIPCandidates, DefaultGateway, EventNetworkAddressChanged | `modules/ip.md` | | `mod/tor/`, Tor, onion, hidden service, SOCKS5, ED25519-V3 | `modules/tor.md` | | `mod/fwd/`, port forward, bridge, AstralServer, TCPServer, TorTarget | `modules/fwd.md` | diff --git a/.ai/knowledge/concepts/objects.md b/.ai/knowledge/concepts/objects.md index ab49f6ee7..e5461a701 100644 --- a/.ai/knowledge/concepts/objects.md +++ b/.ai/knowledge/concepts/objects.md @@ -44,4 +44,16 @@ automatically through type assertions. | `Describer` | metadata request for an ObjectID | | `Searcher` | text/tag search over module-owned indexes | | `Finder` | provider lookup by ObjectID | -| `Holder` | storage policy and eviction decisions | +| `Holder` | cleanup/purge policy; held objects are skipped by cleanup, not by direct delete | + +`Holder` is a purge-time protection hook. `objects.LoadDependencies` auto-registers +holders from loaded modules; `objects.purge` consults every holder before deleting +from the requested repository. `objects.delete` is a direct repository command and +does not consult holders. + +| Holder provider | Protected objects | +|---|---| +| `apphost` | app-owned persistent object holds in `apphost__object_holds` | +| `auth` | active indexed signed-contract objects used for authorization | +| `crypto` | indexed private-key objects and their corresponding public-key objects used for signing | +| `user` | active user asset rows | diff --git a/.ai/knowledge/modules/apphost.md b/.ai/knowledge/modules/apphost.md index dc477ad12..84b9c7e5f 100644 --- a/.ai/knowledge/modules/apphost.md +++ b/.ai/knowledge/modules/apphost.md @@ -8,7 +8,7 @@ Bridges local applications into the node over IPC and HTTP, letting them issue q |---|---| | `auth` | authorizes caller override with `SudoAction`, authorizes HTTP object reads with `ReadObjectAction`, signs/indexes app contracts, and searches signed contracts in the query preprocessor | | `dir` | resolves identities from configured static tokens and HTTP `@alias/path` targets; formats the host alias in `HostInfoMsg` | -| `objects` | stores signed app contracts and serves `Objects.ReadDefault()` through HTTP `/.objects/` | +| `objects` | stores signed app contracts, serves `Objects.ReadDefault()` through HTTP `/.objects/`, and registers apphost as a purge holder provider | | `user` (opt) | `PushToLocalSwarm` publishes signed app contracts after sign/install; current sign/install paths call it without a nil guard | | `core/assets` | `Database()` backs `apphost__access_tokens` and `apphost__local_apps`; `LoadYAML` loads apphost config | @@ -25,6 +25,7 @@ Bridges local applications into the node over IPC and HTTP, letting them issue q - Create/list tokens: `apphost.create_token` writes random 32-char token with default 1-year expiry when duration is empty; `apphost.list_tokens` streams matching tokens and ends with `EOS`. - Static tokens: `Prepare` resolves each `config.Tokens` identity -> deletes existing token row -> inserts a new token with a 100-year expiry. - Install app: local `apphost.install_app` -> build `RelayForAction` contract for app and node -> `Auth.SignContract` -> `Auth.IndexContract` -> `Objects.Store` -> `CreateLocalApp` upsert -> async `User.PushToLocalSwarm`. +- Object holds: local `apphost.hold_object` inserts an app-owned hold with an optional `duration` arg, `apphost.unhold_object` deletes only the caller-owned hold, and `apphost.list_held_objects` streams the caller's active holds. `objects.purge` skips any object with at least one active apphost hold. Active means `hold_until IS NULL OR hold_until > now`; omitted duration writes `NULL` (infinite hold), otherwise `hold_until = now + duration`. - HTTP bridge: bearer token -> `AuthenticateToken` -> set guest/host headers -> `/.objects/` goes through `ReadObjectAction`; other paths route a query to header target, `@alias/path` target, or local node. ## Source @@ -36,7 +37,8 @@ Bridges local applications into the node over IPC and HTTP, letting them issue q - `mod/apphost/src/query_router.go`, `ipc_handler.go`, `query_preprocessor.go` - hosted app dispatch and relay-contract preprocessing. - `mod/apphost/src/http_server.go`, `http_object_handler.go`, `http_query_handler.go` - bearer-auth HTTP bridge for objects and queries. - `mod/apphost/src/op_*.go` - query operation handlers. -- `mod/apphost/src/access_tokens.go`, `db.go`, `db_access_token.go`, `db_local_app.go` - token/local-app persistence and lookup helpers. +- `mod/apphost/src/access_tokens.go`, `db.go`, `db_access_token.go`, `db_local_app.go`, `db_object_hold.go` - token, local-app, and object-hold persistence plus lookup helpers. +- `mod/apphost/src/object_holder.go` - objects holder hook backed by active app-owned hold rows. - `mod/apphost/client/` - typed client wrappers for apphost ops. ## Surface @@ -44,19 +46,24 @@ Bridges local applications into the node over IPC and HTTP, letting them issue q | What | Why it matters | |--------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------| | `apphost.create_token`, `apphost.list_tokens` | issue and stream app access tokens | +| `apphost.hold_object`, `apphost.unhold_object`, `apphost.list_held_objects` | manage persistent app-owned object holds | +| `objects.Holder` | prevents actively held app-owned objects from being purged | | `apphost.register_handler`, `apphost.bind`, `apphost.cancel` | manage hosted app handlers and cancel in-flight guest queries | | `apphost.new_app_contract`, `apphost.sign_app_contract`, `apphost.install_app` | create, sign, store, and index relay contracts for local apps | | `Module.RouteQuery` | high-priority router that forwards target-addressed queries to registered local app handlers | | `Module.PreprocessQuery` | attaches local app relay contracts and adds relay hops for apps hosted elsewhere | | `config.Listen`, `config.BindHTTP` | configure IPC apphost listeners and the HTTP bridge | -| `apphost__access_tokens`, `apphost__local_apps` | persistent access tokens and installed-app records | +| `apphost__access_tokens`, `apphost__local_apps`, `apphost__object_holds` | persistent access tokens, installed-app records, and app-owned object holds | ## Invariants - `apphost.install_app`, `apphost.register_handler`, and `apphost.bind` reject network-origin queries. +- `apphost.hold_object`, `apphost.unhold_object`, and `apphost.list_held_objects` reject network-origin queries and require a non-zero caller identity. +- Apps can list and unhold only their own hold rows; many apps may hold the same object. - Anonymous IPC guests can route only when `allow_anonymous` is true and always lose `ZoneNetwork`. - A guest can set `Caller` to another identity only when `auth.Authorize(SudoAction{Actor:guestID, AsID:Caller})` grants it. - `apphost.list_tokens` always ends the stream with `EOS`. - `CreateLocalApp` uses `OnConflict{DoNothing}`; reinstall does not update an existing row. +- `HoldObject` uses `OnConflict{DoNothing}`; duplicate holds are idempotent, holding does not require the object to exist locally, and `Duration` is `*astral.Duration` (nil = no expiry). - `bind_http` empty disables the HTTP bridge. - Handler endpoints are removed when dial fails during inbound routing. diff --git a/.ai/knowledge/modules/auth.md b/.ai/knowledge/modules/auth.md index 95b926933..5daf1ff86 100644 --- a/.ai/knowledge/modules/auth.md +++ b/.ai/knowledge/modules/auth.md @@ -7,7 +7,7 @@ Decides whether an identity may perform a typed action, using local action handl | Module | Why | | --- | --- | | `crypto` | signs contracts through `ObjectSigner` and `TextObjectSigner`; verifies issuer and subject signatures with ASN1 or BIP137 schemes | -| `objects` | loads signed contracts for `auth.index`; scans `RepoLocal` for startup indexing | +| `objects` | loads signed contracts for `auth.index`; scans `RepoLocal` for startup indexing; exposes active-contract object holds for purge | | `secp256k1` | derives issuer and subject public keys from identities for signing and verification | | `core/assets` | `Database()` backs `auth__contracts` and `auth__contract_permits`; `LoadYAML` loads the empty auth config | @@ -21,6 +21,7 @@ Decides whether an identity may perform a typed action, using local action handl - Sign one side: `SignIssuer` or `SignSubject` refuses an existing signature -> derives the identity public key with `secp256k1.FromIdentity` -> tries ASN1 object signing -> falls back to BIP137 text-object signing. - Index contract op: `auth.index` loads the object ID through `Objects.ReadDefault()` -> requires `*SignedContract` -> `IndexContract` verifies and stores it -> sends `Ack`. - Index contract: `indexMu` -> resolve object ID -> skip when a complete contract row already exists -> verify issuer and subject signatures according to signature scheme -> upsert contract row and insert permits if no permits exist yet. +- Object holding: `HoldObject` returns true for active indexed signed-contract object IDs so `objects.purge` skips contracts still used for authorization. - Startup indexer: `Run` starts `indexer` -> scan `objects.RepoLocal` outside `ZoneNetwork` -> load each object -> index signed contracts and ignore other objects. - Query contracts: `SignedContracts` builder filters by issuer, subject, active time window, and permit action type -> decodes stored signatures and permits back into `SignedContract` objects. @@ -30,7 +31,7 @@ Decides whether an identity may perform a typed action, using local action handl - `mod/auth/src/loader.go`, `module.go`, `deps.go`, `config.go`, `prepare.go` - module registration, dependency injection, router setup, database migration, and lifecycle. - `mod/auth/src/authorize.go`, `authorizers.go` - handler dispatch, contract fallback, and built-in sudo authorization. - `mod/auth/src/signing.go` - issuer and subject signing plus signature verification by scheme. -- `mod/auth/src/contracts.go`, `contract_query.go` - contract indexing, repository scan, and active-contract query builder. +- `mod/auth/src/contracts.go`, `contract_query.go`, `object_holder.go` - contract indexing, repository scan, active-contract query builder, and cleanup hold hook. - `mod/auth/src/op_sign_contract.go`, `op_index.go` - query operation handlers. - `mod/auth/src/db.go`, `db_contract.go`, `db_contract_permit.go` - GORM rows, contract upsert, permit persistence, and lookup filters. - `mod/auth/client/` - typed client wrappers for auth operations. @@ -42,6 +43,7 @@ Decides whether an identity may perform a typed action, using local action handl | `Module.Authorize` and `Module.Add` | central extension point for modules that define typed authorization actions | | `Module.SignContract`, `VerifyContract`, and `IndexContract` | contract lifecycle used by modules that delegate authority between identities | | `Module.SignedContracts()` | builder used by authorization and callers that need active signed contract lookup | +| `objects.Holder` | prevents active indexed signed-contract objects from being purged | | `auth.sign_contract`, `auth.index` | query methods for signing a contract object and indexing an already stored signed contract | | `auth__contracts`, `auth__contract_permits` | persistent authorization index searched during contract fallback | @@ -49,7 +51,8 @@ Decides whether an identity may perform a typed action, using local action handl - Contracts never grant directly; they swap `action.Actor` to issuer and re-run local handlers. - Contract path skips `Contract.Allows` and `Constrainable.ApplyConstraints`. -- Contract lookup filters `subject_id`, `starts_at <= now`, `expires_at` zero-or-future, joins permits by action `ObjectType`. +- Contract lookup filters `subject_id`, `starts_at <= now`, `expires_at > now`, joins permits by action `ObjectType`. +- `HoldObject` uses the same active time window as contract lookup and fails closed on DB errors. - `contractExists` requires both signatures non-empty; partial rows re-index. - `IndexContract` serialized by `indexMu`. - `SignIssuer`/`SignSubject` refuse overwrite with `ErrAlreadySigned`. diff --git a/.ai/knowledge/modules/crypto.md b/.ai/knowledge/modules/crypto.md index 2ba5c4417..390facd80 100644 --- a/.ai/knowledge/modules/crypto.md +++ b/.ai/knowledge/modules/crypto.md @@ -6,7 +6,7 @@ Signs and verifies hashes and text through pluggable crypto engines, and indexes | Module | Why | | --- | --- | -| `objects` | stores the node key in the system repository, loads indexed private keys through `ReadDefault`, and scans configured repositories for private-key objects | +| `objects` | stores the node key in the system repository, loads indexed private keys through `ReadDefault`, scans configured repositories for private-key objects, and exposes private-key object holds for purge | | `dir` (opt) | injected in `Deps`; current crypto code does not call it | | `secp256k1` | derives the default signing public key from a query caller identity in signing ops | | `core` | discovers loaded `EngineProvider` modules during `LoadDependencies` and registers their crypto engines | @@ -18,6 +18,7 @@ Signs and verifies hashes and text through pluggable crypto engines, and indexes - Node key setup: loader reads `node_key` from resources -> decodes a `PrivateKey`; dependencies stage stores it in `Objects.System()` -> `indexPrivateKey` records its public key mapping. - Repository key indexing: `Run` starts one goroutine per configured repository -> `repo.Scan(ctx, true)` -> skip object IDs larger than 4096 bytes -> load private keys -> derive public key through engines -> insert `crypto__private_keys` row. - Private key lookup: `PrivateKeyID` marshals the public key text -> finds the matching row by `public_key`; `PrivateKey` then loads `KeyID` through `Objects.ReadDefault()` and requires a `PrivateKey` object. +- Object holding: `HoldObject` returns true for any object ID matching either `key_id` or `public_key_id` in `crypto__private_keys` so `objects.purge` cannot remove a private key or its corresponding public key while signing depends on it. - Public key derivation: `PublicKey` clones the engine set -> asks each engine to derive the public key -> first successful engine wins, otherwise returns `ErrUnsupported`. - Hash signing: `crypto.sign_hash` defaults scheme to `asn1` and signer key to `secp256k1.FromIdentity(q.Caller())` -> optional key/hash query args override channel input -> `HashSigner` dispatches to the first engine that can sign. - Text signing: `crypto.sign_text` follows the text-signer path and defaults to the caller identity key and BIP137-style text signatures. @@ -30,7 +31,7 @@ Signs and verifies hashes and text through pluggable crypto engines, and indexes - `mod/crypto/module.go`, `engine.go`, `nil_engine.go`, `errors.go` - public module interface, engine contracts, nil engine, and sentinels. - `mod/crypto/private_key.go`, `public_key.go`, `signature.go`, `hash.go`, `signable_object.go` - crypto object types, text encodings, and signable object contracts. - `mod/crypto/src/loader.go`, `module.go`, `deps.go`, `config.go` - registration, node key loading, dependency injection, engine fan-out, and indexing lifecycle. -- `mod/crypto/src/db.go`, `db_private_key.go` - private-key index schema and lookup helpers. +- `mod/crypto/src/db.go`, `db_private_key.go`, `object_holder.go` - private-key index schema, lookup helpers, and cleanup hold hook. - `mod/crypto/src/object_signer.go`, `text_object_signer.go` - object and text-object signing adapters. - `mod/crypto/src/op_public_key.go`, `op_sign_hash.go`, `op_sign_text.go`, `op_verify_hash_signature.go`, `op_verify_text_signature.go` - query operation handlers. - `mod/crypto/client/` - typed client wrappers for crypto operations. @@ -43,6 +44,7 @@ Signs and verifies hashes and text through pluggable crypto engines, and indexes | `HashSigner`, `TextSigner`, `ObjectSigner`, `TextObjectSigner` | signer abstractions used by auth, ether, and other modules | | `crypto.public_key`, `crypto.sign_hash`, `crypto.sign_text` | query methods for deriving public keys and producing signatures | | `crypto.verify_hash_signature`, `crypto.verify_text_signature` | query methods and module paths for validating signatures | +| `objects.Holder` | prevents indexed private-key objects from being purged | | `crypto__private_keys` | maps public-key text to private-key object IDs for local signing | ## Invariants @@ -51,6 +53,7 @@ Signs and verifies hashes and text through pluggable crypto engines, and indexes - `NilEngine` returns stdlib `errors.ErrUnsupported`; dispatch skips it. - `formatSignableText` reads `SignableHash()[0:15]`; objects must yield at least 15 hash bytes. - Private-key resolution limited to keys indexed from node key or `crypto.repos` (default `[local, system, mem0]`). +- `HoldObject` matches `crypto__private_keys.key_id` or `public_key_id` and fails closed on DB errors. - `NodeSigner` panics if no engine supplies `asn1` for the local secp256k1 identity. - Auto-index ceiling: hard-coded `maxObjectSize = 4096`. - Encodings: `PrivateKey` text `type:base64(key)`; `PublicKey` text `type:hex(key)`; `Signature` text `scheme:base64(data)`; `Hash` binary `Bytes8`, text/JSON hex. diff --git a/.ai/knowledge/modules/objects.md b/.ai/knowledge/modules/objects.md index d06477e40..67c991920 100644 --- a/.ai/knowledge/modules/objects.md +++ b/.ai/knowledge/modules/objects.md @@ -23,6 +23,7 @@ Hosts the node's content-addressed object layer behind a uniform query and repos - Search: preprocessors mutate `objects.Search` -> local searchers run concurrently -> network-zone searches query each requested source through `objects/client` -> operation handler deduplicates by object ID and applies optional repo filter. - Receive local object: `Receive` normalizes zero source to the node identity -> builds a `Drop` backed by `WriteDefault` -> calls all receivers -> accepted drops return success; `Accept(true)` stores at most once. - Push object: local target calls `Receive`; remote target calls `objects.push` through the typed client and expects a boolean result. +- Purge repository: `objects.purge` scans a named repository once, deduplicates object IDs, asks registered `Holder`s whether each ID is still in use, deletes unheld IDs through that repository, ignores not-found deletes, streams purged IDs, and ends with `EOS`. - Fetch object: HTTP and HTTPS fetch with `http.Get` then write to the default repo; `astral://` ARLs route an in-flight query and write the response to the default repo. - Extension discovery: `LoadDependencies` walks loaded modules and auto-registers any object describer, searcher, search preprocessor, finder, holder, or receiver. - Repository removal: `RemoveRepository` deletes the repo from the registry -> removes it from every group -> calls `AfterRemoved` when the repo implements the callback. @@ -34,9 +35,9 @@ Hosts the node's content-addressed object layer behind a uniform query and repos - `mod/objects/src/loader.go`, `mod/objects/src/deps.go`, `mod/objects/src/module.go`, `mod/objects/src/config.go`, `mod/objects/src/db.go`, `mod/objects/src/db_object.go` - default repo layout, dependency injection, config, and type index persistence. - `mod/objects/src/repo_group.go` - sequential and concurrent group behavior for read, create, contains, delete, scan, and free-space operations. - `mod/objects/src/drop.go`, `mod/objects/src/receive.go`, `mod/objects/src/push.go`, `mod/objects/src/fetch.go`, `mod/objects/src/network_reader.go` - object receive, save-on-accept, push, fetch, and routed network reads. -- `mod/objects/src/describe.go`, `mod/objects/src/search.go`, `mod/objects/src/find.go`, `mod/objects/src/holding.go` - extension dispatch. -- `mod/objects/src/op_*.go` - query handlers for object storage, reads, scans, search, repo management, probes, type lookup, push, echo, and spec. -- `mod/objects/client/` - typed remote clients used by push, search, create, read, scan, and repository operations. +- `mod/objects/src/describe.go`, `mod/objects/src/search.go`, `mod/objects/src/find.go`, `mod/objects/src/holding.go` - extension dispatch, including holder aggregation. +- `mod/objects/src/op_*.go` - query handlers for object storage, reads, scans, purge, search, repo management, probes, type lookup, push, echo, and spec. +- `mod/objects/client/` - typed remote clients used by push, search, create, read, scan, purge, and repository operations. - `mod/objects/mem/` - in-memory repository implementation for default memory repositories and `objects.new_mem`. - `mod/objects/fs/`, `mod/objects/views/` - filesystem adapter and presentation helpers. @@ -44,7 +45,7 @@ Hosts the node's content-addressed object layer behind a uniform query and repos | What | Why it matters | |---|---| -| `objects.new`, `objects.load`, `objects.store`, `objects.create`, `objects.read`, `objects.delete`, `objects.contains` | core object storage and byte streaming operations | +| `objects.new`, `objects.load`, `objects.store`, `objects.create`, `objects.read`, `objects.delete`, `objects.purge`, `objects.contains` | core object storage and byte streaming operations | | `objects.scan`, `objects.search`, `objects.describe`, `objects.probe`, `objects.get_type`, `objects.types`, `objects.spec` | discovery, metadata, type, and inspection operations | | `objects.push`, `objects.echo` | object delivery and connectivity helpers | | `objects.repositories`, `objects.remove_repository`, `objects.new_mem` | repository management | @@ -56,6 +57,8 @@ Hosts the node's content-addressed object layer behind a uniform query and repos - Every writer must end in exactly one `Commit` or `Discard`; `objects.create` defers `Discard` as a leak guard. - `objects.delete`, `objects.contains`, and `objects.scan` require an explicit repository. +- `objects.purge` is the cleanup path that interprets `Holder`; `objects.delete` remains a direct repository delete command. +- Holders are registered automatically only for loaded modules that implement `objects.Holder`; disabling a provider module removes that provider's purge protection. - `objects.repositories` excludes network routing. - `Load` returns `*astral.Blob` only for invalid astral magic bytes; other decode failures remain errors. - Generic object loading rejects sizes above `MaxObjectSize` before reading the object into memory. diff --git a/.ai/knowledge/modules/user.md b/.ai/knowledge/modules/user.md index 104e9c14c..f472a9dba 100644 --- a/.ai/knowledge/modules/user.md +++ b/.ai/knowledge/modules/user.md @@ -8,7 +8,7 @@ Represents the human operator across their nodes by binding the local node to a |---|---| | `auth` | verifies and signs swarm contracts, indexes accepted contracts, searches active node contracts, and registers relay/read authorizers | | `nodes` | checks link state, schedules ensure-link tasks, updates node endpoints after received contracts, and handles link events | -| `objects` | stores and pushes signed contracts, sends sibling notifications, registers receiver/holder/finder hooks, and exposes read actions for authorization | +| `objects` | stores and pushes signed contracts, sends sibling notifications, registers receiver/holder/finder hooks, exposes read actions for authorization, and consults user asset holds during purge | | `scheduler` | gates `Run` on `Ready`, schedules `MaintainLinkTask`, and schedules `SyncNodesAction` on first inbound sibling links | | `tree` | binds `/mod/user/config` and persists per-sibling asset sync heights under `/mod/user/assets//next_height` | | `dir` | resolves target aliases, reads and writes user and node aliases, and registers `localswarm` and `localuser` filters | @@ -30,7 +30,7 @@ Represents the human operator across their nodes by binding the local node to a - Asset synchronization: `syncAssets` reads next height from tree -> queries remote `user.sync_assets` as the active user -> applies `OpUpdate` rows with nonce idempotency -> writes returned next height back to tree. - Inbound signed contract: `ReceiveObject` accepts only contracts issued by the active user or involving local-swarm members -> verifies and indexes the contract -> if it is a node contract, updates endpoints and reruns sibling linker. - Query preprocessing: active-user caller gets the active contract attached -> queries targeting the active user get linked siblings as relays -> other targets get active nodes for that target as relays. -- Search preprocessing and object lookup: active-user object searches add linked siblings as sources; object finder returns linked siblings as candidate holders; holder reports true for non-removed asset rows. +- Search preprocessing and object lookup: active-user object searches add linked siblings as sources; object finder returns linked siblings as candidate holders; holder reports true for non-removed local asset rows so purge preserves user assets. - Nearby status: visible mode attaches the active contract, or claimable flag and public profile when unclaimed; stealth mode attaches a commitment and masked node identity derived from the active user. ## Source @@ -53,7 +53,7 @@ Represents the human operator across their nodes by binding the local node to a | `user.info`, `user.swarm_status`, `user.list_siblings` | identity and sibling status query surface | | `user.assets`, `user.add_asset`, `user.remove_asset`, `user.sync_assets`, `user.sync_with` | local asset inventory and sibling asset synchronization | | `core.QueryPreprocessor`, `objects.Search` preprocessor | attaches user contracts and adds local-swarm relay/search sources | -| `objects.Receiver`, `objects.Holder`, `objects.Finder` | accepts swarm contracts and advertises local or sibling object holdings | +| `objects.Receiver`, `objects.Holder`, `objects.Finder` | accepts swarm contracts, protects active asset rows from purge, and advertises sibling lookup candidates | | `nearby.Composer`, `dir` filters, auth authorizers | integrates user identity with presence, alias filters, relay auth, and object-read auth | | `users__assets`, `/mod/user/config` | durable asset log and tree-backed active contract | @@ -66,4 +66,5 @@ Represents the human operator across their nodes by binding the local node to a - `minimalContractLength` is 1 hour for invite acceptance; default new-node contract validity is 365 days. - One `MaintainLinkTask` is tracked per non-self sibling in `sibs`. - Asset rows are nonce-addressed and height-ordered; duplicate nonces from sync are ignored. +- `objects.Holder` reports only non-removed asset rows; removed assets no longer block purge. - `user.assets`, `user.list_siblings`, and `user.swarm_status` stream results and terminate with `EOS`. diff --git a/mod/apphost/client/hold_object.go b/mod/apphost/client/hold_object.go new file mode 100644 index 000000000..1f691cab5 --- /dev/null +++ b/mod/apphost/client/hold_object.go @@ -0,0 +1,24 @@ +package apphost + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/channel" + "github.com/cryptopunkscc/astrald/lib/query" + "github.com/cryptopunkscc/astrald/mod/apphost" +) + +func (client *Client) HoldObject(ctx *astral.Context, objectID *astral.ObjectID) error { + ch, err := client.queryCh(ctx, apphost.MethodHoldObject, query.Args{ + "id": objectID, + }) + if err != nil { + return err + } + defer ch.Close() + + return ch.Switch(channel.ExpectAck, channel.PassErrors, channel.WithContext(ctx)) +} + +func HoldObject(ctx *astral.Context, objectID *astral.ObjectID) error { + return Default().HoldObject(ctx, objectID) +} diff --git a/mod/apphost/client/list_held_objects.go b/mod/apphost/client/list_held_objects.go new file mode 100644 index 000000000..6e922b764 --- /dev/null +++ b/mod/apphost/client/list_held_objects.go @@ -0,0 +1,41 @@ +package apphost + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/channel" + "github.com/cryptopunkscc/astrald/mod/apphost" + "github.com/cryptopunkscc/astrald/sig" +) + +func (client *Client) ListHeldObjects(ctx *astral.Context) (<-chan *astral.ObjectID, *error) { + ch, err := client.queryCh(ctx, apphost.MethodListHeldObjects, nil) + if err != nil { + return nil, &err + } + + out := make(chan *astral.ObjectID) + errPtr := new(error) + + go func() { + defer close(out) + defer ch.Close() + + *errPtr = ch.Switch( + func(id *astral.ObjectID) error { + if id != nil && !id.IsZero() { + return sig.Send(ctx, out, id) + } + return nil + }, + channel.BreakOnEOS, + channel.PassErrors, + channel.WithContext(ctx), + ) + }() + + return out, errPtr +} + +func ListHeldObjects(ctx *astral.Context) (<-chan *astral.ObjectID, *error) { + return Default().ListHeldObjects(ctx) +} diff --git a/mod/apphost/client/unhold_object.go b/mod/apphost/client/unhold_object.go new file mode 100644 index 000000000..8847acd22 --- /dev/null +++ b/mod/apphost/client/unhold_object.go @@ -0,0 +1,24 @@ +package apphost + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/channel" + "github.com/cryptopunkscc/astrald/lib/query" + "github.com/cryptopunkscc/astrald/mod/apphost" +) + +func (client *Client) UnholdObject(ctx *astral.Context, objectID *astral.ObjectID) error { + ch, err := client.queryCh(ctx, apphost.MethodUnholdObject, query.Args{ + "id": objectID, + }) + if err != nil { + return err + } + defer ch.Close() + + return ch.Switch(channel.ExpectAck, channel.PassErrors, channel.WithContext(ctx)) +} + +func UnholdObject(ctx *astral.Context, objectID *astral.ObjectID) error { + return Default().UnholdObject(ctx, objectID) +} diff --git a/mod/apphost/module.go b/mod/apphost/module.go index c2affa232..9804c7084 100644 --- a/mod/apphost/module.go +++ b/mod/apphost/module.go @@ -18,6 +18,9 @@ const ( MethodNewAppContract = "apphost.new_app_contract" MethodSignAppContract = "apphost.sign_app_contract" MethodInstallApp = "apphost.install_app" + MethodHoldObject = "apphost.hold_object" + MethodUnholdObject = "apphost.unhold_object" + MethodListHeldObjects = "apphost.list_held_objects" ) type Module interface { @@ -26,3 +29,5 @@ type Module interface { } var ErrProtocolError = errors.New("protocol error") +var ErrMissingAppIdentity = errors.New("missing app identity") +var ErrMissingObjectID = errors.New("missing object id") diff --git a/mod/apphost/src/db.go b/mod/apphost/src/db.go index 52ddc222b..fdebf7948 100644 --- a/mod/apphost/src/db.go +++ b/mod/apphost/src/db.go @@ -45,3 +45,40 @@ func (db *DB) CreateLocalApp(appID, hostID *astral.Identity) error { func (db *DB) ListLocalApps() (list []*dbLocalApp, err error) { return list, db.Find(&list).Error } + +func (db *DB) HoldObject(appID *astral.Identity, objectID *astral.ObjectID, duration *astral.Duration) error { + // note: apps may hold object IDs before this node has fetched the object. + row := &dbObjectHold{AppID: appID, ObjectID: objectID, CreatedAt: time.Now()} + if duration != nil { + until := time.Now().Add(time.Duration(*duration)) + row.HoldUntil = &until + } + return db.Clauses(clause.OnConflict{DoNothing: true}).Create(row).Error +} + +func (db *DB) UnholdObject(appID *astral.Identity, objectID *astral.ObjectID) error { + return db. + Where("app_id = ? AND object_id = ?", appID, objectID). + Delete(&dbObjectHold{}). + Error +} + +func (db *DB) ListHeldObjects(appID *astral.Identity) (list []*dbObjectHold, err error) { + err = db. + Where("app_id = ?", appID). + Where("(hold_until IS NULL OR hold_until > ?)", time.Now()). + Find(&list). + Error + return +} + +func (db *DB) ObjectHeld(objectID *astral.ObjectID) (held bool, err error) { + err = db. + Model(&dbObjectHold{}). + Where("object_id = ?", objectID). + Where("(hold_until IS NULL OR hold_until > ?)", time.Now()). + Select("count(*) > 0"). + First(&held). + Error + return +} diff --git a/mod/apphost/src/db_object_hold.go b/mod/apphost/src/db_object_hold.go new file mode 100644 index 000000000..f86b27d1e --- /dev/null +++ b/mod/apphost/src/db_object_hold.go @@ -0,0 +1,19 @@ +package apphost + +import ( + "time" + + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/mod/apphost" +) + +type dbObjectHold struct { + AppID *astral.Identity `gorm:"primaryKey;index"` + ObjectID *astral.ObjectID `gorm:"primaryKey;index"` + HoldUntil *time.Time `gorm:"index"` + CreatedAt time.Time `gorm:"index"` +} + +func (dbObjectHold) TableName() string { + return apphost.DBPrefix + "object_holds" +} diff --git a/mod/apphost/src/loader.go b/mod/apphost/src/loader.go index f89e71a7f..2ec9e9822 100644 --- a/mod/apphost/src/loader.go +++ b/mod/apphost/src/loader.go @@ -29,7 +29,7 @@ func (Loader) Load(node astral.Node, assets assets.Assets, log *log.Logger) (cor // set up the database mod.db = &DB{assets.Database()} - err = mod.db.AutoMigrate(&dbAccessToken{}, &dbLocalApp{}) + err = mod.db.AutoMigrate(&dbAccessToken{}, &dbLocalApp{}, &dbObjectHold{}) if err != nil { return nil, err } diff --git a/mod/apphost/src/object_holder.go b/mod/apphost/src/object_holder.go new file mode 100644 index 000000000..9a6a7d025 --- /dev/null +++ b/mod/apphost/src/object_holder.go @@ -0,0 +1,12 @@ +package apphost + +import "github.com/cryptopunkscc/astrald/astral" + +func (mod *Module) HoldObject(objectID *astral.ObjectID) bool { + held, err := mod.db.ObjectHeld(objectID) + if err != nil { + mod.log.Error("object hold lookup failed: %v", err) + return true + } + return held +} diff --git a/mod/apphost/src/op_hold_object.go b/mod/apphost/src/op_hold_object.go new file mode 100644 index 000000000..b18478f29 --- /dev/null +++ b/mod/apphost/src/op_hold_object.go @@ -0,0 +1,38 @@ +package apphost + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/channel" + "github.com/cryptopunkscc/astrald/lib/routing" + "github.com/cryptopunkscc/astrald/mod/apphost" +) + +type opHoldObjectArgs struct { + ID *astral.ObjectID + Duration *astral.Duration `query:"optional"` + Out string `query:"optional"` +} + +func (mod *Module) OpHoldObject(ctx *astral.Context, q *routing.IncomingQuery, args opHoldObjectArgs) error { + if q.Origin() == astral.OriginNetwork { + return q.Reject() + } + + ch := channel.New(q.AcceptRaw(), channel.WithOutputFormat(args.Out)) + defer ch.Close() + + if q.Caller().IsZero() { + return ch.Send(astral.Err(apphost.ErrMissingAppIdentity)) + } + + if args.ID == nil || args.ID.IsZero() { + return ch.Send(astral.Err(apphost.ErrMissingObjectID)) + } + + err := mod.db.HoldObject(q.Caller(), args.ID, args.Duration) + if err != nil { + return ch.Send(astral.Err(err)) + } + + return ch.Send(&astral.Ack{}) +} diff --git a/mod/apphost/src/op_list_held_objects.go b/mod/apphost/src/op_list_held_objects.go new file mode 100644 index 000000000..16671fde7 --- /dev/null +++ b/mod/apphost/src/op_list_held_objects.go @@ -0,0 +1,38 @@ +package apphost + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/channel" + "github.com/cryptopunkscc/astrald/lib/routing" + "github.com/cryptopunkscc/astrald/mod/apphost" +) + +type opListHeldObjectsArgs struct { + Out string `query:"optional"` +} + +func (mod *Module) OpListHeldObjects(ctx *astral.Context, q *routing.IncomingQuery, args opListHeldObjectsArgs) error { + if q.Origin() == astral.OriginNetwork { + return q.Reject() + } + + ch := channel.New(q.AcceptRaw(), channel.WithOutputFormat(args.Out)) + defer ch.Close() + + if q.Caller().IsZero() { + return ch.Send(astral.Err(apphost.ErrMissingAppIdentity)) + } + + rows, err := mod.db.ListHeldObjects(q.Caller()) + if err != nil { + return ch.Send(astral.Err(err)) + } + + for _, row := range rows { + if err := ch.Send(row.ObjectID); err != nil { + return err + } + } + + return ch.Send(&astral.EOS{}) +} diff --git a/mod/apphost/src/op_unhold_object.go b/mod/apphost/src/op_unhold_object.go new file mode 100644 index 000000000..d01ff0208 --- /dev/null +++ b/mod/apphost/src/op_unhold_object.go @@ -0,0 +1,36 @@ +package apphost + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/channel" + "github.com/cryptopunkscc/astrald/lib/routing" + "github.com/cryptopunkscc/astrald/mod/apphost" +) + +type opUnholdObjectArgs struct { + ID *astral.ObjectID + Out string `query:"optional"` +} + +func (mod *Module) OpUnholdObject(ctx *astral.Context, q *routing.IncomingQuery, args opUnholdObjectArgs) error { + if q.Origin() == astral.OriginNetwork { + return q.Reject() + } + + ch := channel.New(q.AcceptRaw(), channel.WithOutputFormat(args.Out)) + defer ch.Close() + + if q.Caller().IsZero() { + return ch.Send(astral.Err(apphost.ErrMissingAppIdentity)) + } + + if args.ID == nil || args.ID.IsZero() { + return ch.Send(astral.Err(apphost.ErrMissingObjectID)) + } + + if err := mod.db.UnholdObject(q.Caller(), args.ID); err != nil { + return ch.Send(astral.Err(err)) + } + + return ch.Send(&astral.Ack{}) +} diff --git a/mod/auth/src/db.go b/mod/auth/src/db.go index 79f5261bc..e85400ea3 100644 --- a/mod/auth/src/db.go +++ b/mod/auth/src/db.go @@ -16,7 +16,7 @@ func (db *DB) findActiveContracts(q *contractQuery) ([]*dbContract, error) { now := time.Now() gq := db.DB. Where("starts_at <= ?", now). - Where("expires_at = ? OR expires_at > ?", time.Time{}, now) + Where("expires_at > ?", now) if q.issuer != nil { gq = gq.Where("issuer_id = ?", q.issuer) @@ -48,6 +48,19 @@ func (db *DB) contractExists(objectID *astral.ObjectID) bool { return err == nil && len(row.IssuerSig) > 0 && len(row.SubjectSig) > 0 } +func (db *DB) activeContractExists(objectID *astral.ObjectID) (exists bool, err error) { + now := time.Now() + err = db. + Model(&dbContract{}). + Where("object_id = ?", objectID). + Where("starts_at <= ?", now). + Where("expires_at > ?", now). + Select("count(*) > 0"). + First(&exists). + Error + return +} + func (db *DB) storeSignedContract(sc *auth.SignedContract) error { objectID, err := astral.ResolveObjectID(sc) if err != nil { diff --git a/mod/auth/src/object_holder.go b/mod/auth/src/object_holder.go new file mode 100644 index 000000000..ea7f06115 --- /dev/null +++ b/mod/auth/src/object_holder.go @@ -0,0 +1,17 @@ +package auth + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/mod/objects" +) + +var _ objects.Holder = &Module{} + +func (mod *Module) HoldObject(objectID *astral.ObjectID) bool { + held, err := mod.db.activeContractExists(objectID) + if err != nil { + mod.log.Error("object hold lookup failed: %v", err) + return true + } + return held +} diff --git a/mod/crypto/src/db.go b/mod/crypto/src/db.go index c079933f4..ed8a05cff 100644 --- a/mod/crypto/src/db.go +++ b/mod/crypto/src/db.go @@ -27,8 +27,13 @@ func (db *DB) findPrivateKeyByPublicKey(pubKey string) (row dbPrivateKey, err er return } -func (db *DB) isPrivateKeyIndexed(keyID *astral.ObjectID) (exist bool, err error) { - err = db.Select("count(*) > 0").Where("data_id = ?", keyID).First(&exist).Error +func (db *DB) isKeyIndexed(keyID *astral.ObjectID) (exist bool, err error) { + err = db. + Model(&dbPrivateKey{}). + Where("key_id = ? OR public_key_id = ?", keyID, keyID). + Select("count(*) > 0"). + First(&exist). + Error return } diff --git a/mod/crypto/src/object_holder.go b/mod/crypto/src/object_holder.go new file mode 100644 index 000000000..48a86d192 --- /dev/null +++ b/mod/crypto/src/object_holder.go @@ -0,0 +1,17 @@ +package crypto + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/mod/objects" +) + +var _ objects.Holder = &Module{} + +func (mod *Module) HoldObject(objectID *astral.ObjectID) bool { + held, err := mod.db.isKeyIndexed(objectID) + if err != nil { + mod.log.Error("object hold lookup failed: %v", err) + return true + } + return held +} diff --git a/mod/objects/client/purge.go b/mod/objects/client/purge.go new file mode 100644 index 000000000..d1a636fc3 --- /dev/null +++ b/mod/objects/client/purge.go @@ -0,0 +1,44 @@ +package objects + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/channel" + "github.com/cryptopunkscc/astrald/lib/query" + "github.com/cryptopunkscc/astrald/mod/objects" + "github.com/cryptopunkscc/astrald/sig" +) + +func (client *Client) Purge(ctx *astral.Context, repo string) (<-chan *astral.ObjectID, *error) { + ch, err := client.queryCh(ctx, objects.MethodPurge, query.Args{ + "repo": repo, + }) + if err != nil { + return nil, &err + } + + out := make(chan *astral.ObjectID) + errPtr := new(error) + + go func() { + defer close(out) + defer ch.Close() + + *errPtr = ch.Switch( + func(id *astral.ObjectID) error { + if id != nil && !id.IsZero() { + return sig.Send(ctx, out, id) + } + return nil + }, + channel.BreakOnEOS, + channel.PassErrors, + channel.WithContext(ctx), + ) + }() + + return out, errPtr +} + +func Purge(ctx *astral.Context, repo string) (<-chan *astral.ObjectID, *error) { + return Default().Purge(ctx, repo) +} diff --git a/mod/objects/module.go b/mod/objects/module.go index 5ff7aaab0..b5f394f96 100644 --- a/mod/objects/module.go +++ b/mod/objects/module.go @@ -13,6 +13,7 @@ const ( MethodLoad = "objects.load" MethodStore = "objects.store" MethodDelete = "objects.delete" + MethodPurge = "objects.purge" MethodContains = "objects.contains" MethodScan = "objects.scan" MethodSearch = "objects.search" diff --git a/mod/objects/src/op_purge.go b/mod/objects/src/op_purge.go new file mode 100644 index 000000000..cbac2a15f --- /dev/null +++ b/mod/objects/src/op_purge.go @@ -0,0 +1,47 @@ +package objects + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/channel" + "github.com/cryptopunkscc/astrald/lib/routing" +) + +type opPurgeArgs struct { + Repo string + Out string `query:"optional"` + Zone *astral.Zone `query:"optional"` +} + +func (mod *Module) OpPurge(ctx *astral.Context, q *routing.IncomingQuery, args opPurgeArgs) error { + ctx = ctx.WithIdentity(q.Caller()) + if args.Zone == nil { + ctx = ctx.WithZone(astral.ZoneAll) + } else { + ctx = ctx.WithZone(*args.Zone) + } + + ch := channel.New(q.AcceptRaw(), channel.WithOutputFormat(args.Out)) + defer ch.Close() + + repo := mod.GetRepository(args.Repo) + if repo == nil { + return ch.Send(astral.NewError("repository not found")) + } + + ctx, cancel := ctx.WithCancel() + defer cancel() + + purged, errPtr := mod.purgeRepository(ctx, repo) + for id := range purged { + err := ch.Send(id) + if err != nil { + return err + } + } + + if *errPtr != nil { + return ch.Send(astral.Err(*errPtr)) + } + + return ch.Send(&astral.EOS{}) +} diff --git a/mod/objects/src/purge.go b/mod/objects/src/purge.go new file mode 100644 index 000000000..6e31b1dcb --- /dev/null +++ b/mod/objects/src/purge.go @@ -0,0 +1,59 @@ +package objects + +import ( + "errors" + + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/mod/objects" + "github.com/cryptopunkscc/astrald/sig" +) + +func (mod *Module) purgeRepository(ctx *astral.Context, repo objects.Repository) (<-chan *astral.ObjectID, *error) { + out := make(chan *astral.ObjectID) + errPtr := new(error) + + go func() { + defer close(out) + + scan, err := repo.Scan(ctx, false) + if err != nil { + *errPtr = err + return + } + + seen := map[string]bool{} + for id := range scan { + // note: this is n-th time i see classic dedup pattern, maybe we will create a helper + if id == nil { + continue + } + + key := id.String() + if seen[key] { + continue + } + seen[key] = true + + if len(mod.Holders(id)) > 0 { + continue + } + + err := repo.Delete(ctx, id) + if err != nil { + if errors.Is(err, objects.ErrNotFound) { + continue + } + *errPtr = err + return + } + + err = sig.Send(ctx, out, id) + if err != nil { + *errPtr = err + return + } + } + }() + + return out, errPtr +}