From 6eb74a8fc421d7b83e2028e7e3c0695e8711f187 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 25 Sep 2026 12:26:03 -0500 Subject: [PATCH 1/6] feat: forge-v2 data plane for repos, pushes and membership Repositories are now forge-v2: a repo document plus the owner's maintainer membership and an initial config in the shared forge-core contract, created by one journaled, resumable session. Refs, config, pack manifests and chunks are addressed through a DocScope that adds repoId to every query and write; v1 repositories resolve read-only through the registry. Membership is writer/maintainer documents (dg collab add/remove/list); consensus refuses a non-member's write with 40120. Fetch reads each pack from its best verifying copy in the FORGE_RULES_V2 order, and repack consolidates without deleting anything. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/testnet-nightly.yml | 54 +- Makefile | 7 +- crates/dg/src/collab.rs | 213 +- crates/dg/src/common.rs | 25 +- crates/dg/src/cost.rs | 26 +- crates/dg/src/errors.rs | 1 - crates/dg/src/fmt.rs | 27 +- crates/dg/src/issue.rs | 32 +- crates/dg/src/main.rs | 62 +- crates/dg/src/maint.rs | 203 +- crates/dg/src/pr.rs | 36 +- crates/dg/src/release.rs | 12 +- crates/dg/src/repo.rs | 364 ++- crates/dg/src/storage.rs | 30 +- crates/forge-core/src/backends.rs | 4 +- crates/forge-core/src/backends/live_tests.rs | 7 +- crates/forge-core/src/backends/platform.rs | 176 +- crates/forge-core/src/collab.rs | 18 +- crates/forge-core/src/create.rs | 568 +++++ crates/forge-core/src/error.rs | 27 + crates/forge-core/src/lib.rs | 5 + crates/forge-core/src/members.rs | 333 +++ crates/forge-core/src/platform.rs | 484 +--- crates/forge-core/src/private.rs | 111 + crates/forge-core/src/refs.rs | 44 +- crates/forge-core/src/repo.rs | 2074 +++++------------- crates/forge-core/src/resolve.rs | 244 +++ crates/forge-core/src/scope.rs | 402 ++++ crates/forge-core/src/storage/mod.rs | 2 +- crates/forge-core/src/storage/targets.rs | 82 +- crates/forge-core/src/tokens.rs | 261 +-- crates/forge-core/src/user_error.rs | 20 + crates/forge-core/tests/collab_tokens.rs | 129 +- crates/forge-core/tests/repo_lifecycle.rs | 339 +-- crates/forge-import/src/estimate.rs | 30 +- crates/forge-import/src/importer.rs | 99 +- crates/git-remote-dash/src/admin.rs | 201 +- crates/git-remote-dash/src/helper.rs | 357 ++- crates/git-remote-dash/src/main.rs | 2 +- crates/git-remote-dash/src/url.rs | 98 +- docs/errors.md | 12 +- e2e/README.md | 30 +- e2e/cli/config.sh | 88 +- e2e/cli/lib.sh | 31 +- e2e/cli/run.sh | 13 +- e2e/cli/scenarios/01-round-trip.sh | 1 + e2e/cli/scenarios/02-non-ff.sh | 1 + e2e/cli/scenarios/03-ref-delete.sh | 1 + e2e/cli/scenarios/04-frozen-push.sh | 151 -- e2e/cli/scenarios/04-revoked-writer-push.sh | 119 + e2e/cli/scenarios/05-no-token-push.sh | 65 - e2e/cli/scenarios/05-non-member-push.sh | 62 + e2e/cli/scenarios/06-third-party-verify.sh | 1 + e2e/cli/scenarios/07-depth-and-filter.sh | 1 + e2e/cli/scenarios/08-v1-read-compat.sh | 53 + e2e/cli/seed-read-fixture.sh | 17 +- e2e/cli/storage-byo.sh | 37 +- 57 files changed, 3998 insertions(+), 3894 deletions(-) create mode 100644 crates/forge-core/src/create.rs create mode 100644 crates/forge-core/src/members.rs create mode 100644 crates/forge-core/src/private.rs create mode 100644 crates/forge-core/src/resolve.rs create mode 100644 crates/forge-core/src/scope.rs delete mode 100755 e2e/cli/scenarios/04-frozen-push.sh create mode 100755 e2e/cli/scenarios/04-revoked-writer-push.sh delete mode 100755 e2e/cli/scenarios/05-no-token-push.sh create mode 100755 e2e/cli/scenarios/05-non-member-push.sh create mode 100755 e2e/cli/scenarios/08-v1-read-compat.sh diff --git a/.github/workflows/testnet-nightly.yml b/.github/workflows/testnet-nightly.yml index d1c3813f5..effe2b6b9 100644 --- a/.github/workflows/testnet-nightly.yml +++ b/.github/workflows/testnet-nightly.yml @@ -4,8 +4,10 @@ name: Testnet Nightly # # The suites this drives are implemented and live in the repo: # * forge-web/e2e/ — Playwright: read paths, fallback browse, zero-backend, a11y -# * e2e/cli/run.sh — 7 CLI scenarios: clone/push round-trip, non-ff, ref delete, -# frozen push, no-token push, third-party verify, depth+filter +# * e2e/cli/run.sh — 8 CLI scenarios on devnet moutai (forge-v2): clone/push +# round-trip, non-ff, ref delete, revoked-writer push, non-member +# push, third-party verify, depth+filter; plus a testnet v1 +# read-compat clone # # They split by what they need: # * READ-ONLY specs need only a network path to testnet DAPI and the read fixture repo @@ -17,13 +19,15 @@ name: Testnet Nightly # green job with everything gated out — a distinction any badge or alert rule can see. # # Fixture identity secrets (e2e/cli/config.sh names the roles): -# FORGE_TEST_IDENTITY_DEPLOYER - owns the CLI suite's repo and the read fixture, grants -# tokens (which repo each suite may write: e2e/README.md) -# FORGE_TEST_IDENTITY_COLLAB - holds an unfrozen WRITE token -# FORGE_TEST_IDENTITY_CONTRIB - holds no token (negative push case) -# FORGE_TEST_IDENTITY_FROZEN - holds a frozen WRITE token (negative push case) +# FORGE_MOUTAI_IDENTITY_OWNER - owns the forge-v2 e2e-cli repo, adds/removes members +# FORGE_MOUTAI_IDENTITY_COLLAB - added as a writer, then removed (scenario 04) +# FORGE_MOUTAI_IDENTITY_CONTRIB - never a member (scenario 05) +# FORGE_TEST_IDENTITY_CONTRIB - any testnet identity, for the v1 read-compat clone (08) +# FORGE_TEST_IDENTITY_DEPLOYER - testnet: owns the browser specs' read fixture (v1), +# verified by the seed job # Each holds the bridge-format identity JSON that `dg` reads from -# ~/.config/dash-forge/test-identities/.identity.json. +# ~/.config/dash-forge/test-identities/devnet-moutai/.identity.json (moutai) or +# ~/.config/dash-forge/test-identities/.identity.json (testnet). on: workflow_dispatch: @@ -89,10 +93,10 @@ jobs: - name: Probe id: probe env: - FORGE_TEST_IDENTITY_DEPLOYER: ${{ secrets.FORGE_TEST_IDENTITY_DEPLOYER }} - FORGE_TEST_IDENTITY_COLLAB: ${{ secrets.FORGE_TEST_IDENTITY_COLLAB }} + FORGE_MOUTAI_IDENTITY_OWNER: ${{ secrets.FORGE_MOUTAI_IDENTITY_OWNER }} + FORGE_MOUTAI_IDENTITY_COLLAB: ${{ secrets.FORGE_MOUTAI_IDENTITY_COLLAB }} + FORGE_MOUTAI_IDENTITY_CONTRIB: ${{ secrets.FORGE_MOUTAI_IDENTITY_CONTRIB }} FORGE_TEST_IDENTITY_CONTRIB: ${{ secrets.FORGE_TEST_IDENTITY_CONTRIB }} - FORGE_TEST_IDENTITY_FROZEN: ${{ secrets.FORGE_TEST_IDENTITY_FROZEN }} run: | if [ -n "$FORGE_TEST_IDENTITY_DEPLOYER" ]; then echo "deployer=true" >> "$GITHUB_OUTPUT" @@ -101,8 +105,8 @@ jobs: fi missing=() # Report the SECRET names an operator has to create, not internal aliases. - for name in FORGE_TEST_IDENTITY_DEPLOYER FORGE_TEST_IDENTITY_COLLAB \ - FORGE_TEST_IDENTITY_CONTRIB FORGE_TEST_IDENTITY_FROZEN; do + for name in FORGE_MOUTAI_IDENTITY_OWNER FORGE_MOUTAI_IDENTITY_COLLAB \ + FORGE_MOUTAI_IDENTITY_CONTRIB FORGE_TEST_IDENTITY_CONTRIB; do [ -z "${!name}" ] && missing+=("$name") done if [ "${#missing[@]}" -ne 0 ]; then @@ -112,7 +116,7 @@ jobs: echo echo "Missing repository secrets: \`${missing[*]}\`." echo - echo "These identities must exist and be funded on testnet for the CLI suite" + echo "These identities must exist and be funded (moutai; testnet for 08) for the CLI suite" echo "to run. The suite job below reports SKIPPED — it is **not** a passing" echo "run. See \`e2e/cli/config.sh\` for the role pool and" echo "\`docs/testing/e2e-test-plan.md\` for provisioning." @@ -163,17 +167,17 @@ jobs: run: bash e2e/cli/seed-read-fixture.sh cli-suite: - name: cli e2e (live testnet) + name: cli e2e (live devnet moutai) needs: fixtures if: needs.fixtures.outputs.present == 'true' runs-on: ubuntu-latest # checkout + protoc + toolchain + cache (~3 min), build (≤30), suite (≤45). timeout-minutes: 85 env: - ID_DEPLOYER_JSON: ${{ secrets.FORGE_TEST_IDENTITY_DEPLOYER }} - ID_COLLAB_JSON: ${{ secrets.FORGE_TEST_IDENTITY_COLLAB }} - ID_CONTRIB_JSON: ${{ secrets.FORGE_TEST_IDENTITY_CONTRIB }} - ID_FROZEN_JSON: ${{ secrets.FORGE_TEST_IDENTITY_FROZEN }} + ID_OWNER_JSON: ${{ secrets.FORGE_MOUTAI_IDENTITY_OWNER }} + ID_COLLAB_JSON: ${{ secrets.FORGE_MOUTAI_IDENTITY_COLLAB }} + ID_CONTRIB_JSON: ${{ secrets.FORGE_MOUTAI_IDENTITY_CONTRIB }} + ID_TESTNET_JSON: ${{ secrets.FORGE_TEST_IDENTITY_CONTRIB }} steps: - uses: actions/checkout@v5 @@ -200,12 +204,12 @@ jobs: - name: Materialize fixture identities run: | dir="${HOME}/.config/dash-forge/test-identities" - mkdir -p "$dir" && chmod 700 "$dir" - printf '%s' "$ID_DEPLOYER_JSON" > "$dir/DEPLOYER.identity.json" - printf '%s' "$ID_COLLAB_JSON" > "$dir/COLLAB.identity.json" - printf '%s' "$ID_CONTRIB_JSON" > "$dir/CONTRIB.identity.json" - printf '%s' "$ID_FROZEN_JSON" > "$dir/FROZEN.identity.json" - chmod 600 "$dir"/*.identity.json + mkdir -p "$dir/devnet-moutai" && chmod 700 "$dir" "$dir/devnet-moutai" + printf '%s' "$ID_OWNER_JSON" > "$dir/devnet-moutai/OWNER.identity.json" + printf '%s' "$ID_COLLAB_JSON" > "$dir/devnet-moutai/COLLAB.identity.json" + printf '%s' "$ID_CONTRIB_JSON" > "$dir/devnet-moutai/CONTRIB.identity.json" + printf '%s' "$ID_TESTNET_JSON" > "$dir/CONTRIB.identity.json" + chmod 600 "$dir"/*.identity.json "$dir"/devnet-moutai/*.identity.json # Built in its own step so a slow (cache-miss) build cannot eat into the suite's time # budget below; run.sh picks the binaries up from target/debug. diff --git a/Makefile b/Makefile index e5ee50712..2aeee5c58 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,8 @@ infra-up: infra-down: docker compose -f $(COMPOSE_FILE) down -v -## e2e: run the CLI end-to-end suite (LIVE testnet) against its reserved repo (e2e/README.md). +## e2e: run the CLI end-to-end suite (LIVE devnet moutai, forge-v2; scenario 08 reads a +## testnet v1 repo) against the OWNER-owned e2e-cli repo (created on first run). ## Builds the binaries if needed, then drives real git push/clone through the ## dash:// helper. See e2e/cli/README-less run.sh header for env knobs ## (RUN_ID, E2E_TIMEOUT, E2E_NO_CLEANUP, subset args). Exits non-zero on any FAIL. @@ -110,8 +111,8 @@ storage-it: infra-up FORGE_IT_S3=1 FORGE_IT_IPFS=1 cargo test -p forge-core --lib -- backends::live_tests storage:: ## storage-e2e: a REAL `git push` / `git clone` through git-remote-dash with packs stored -## on local MinIO + kubo and only the manifest + ref on testnet, against the dedicated -## storage-e2e-a / storage-e2e-b repos (e2e/README.md; created once, ~1.18 tDASH each). +## on local MinIO + kubo and only the manifest + ref on devnet moutai, against the +## dedicated storage-e2e-a / storage-e2e-b repos (e2e/README.md; ~0.001 DASH each, once). ## Builds the helper with the `test-hooks` fault-injection feature. Opt-in. storage-e2e: infra-up cargo build -p dg diff --git a/crates/dg/src/collab.rs b/crates/dg/src/collab.rs index dedb2c6a6..343808030 100644 --- a/crates/dg/src/collab.rs +++ b/crates/dg/src/collab.rs @@ -1,9 +1,16 @@ -//! `dg collab` — collaborator (token) management: add / suspend / remove / list. +//! `dg collab` — repository members: add / remove / list. +//! +//! On forge-v2 a member is a `writer` or `maintainer` document the repo owner creates +//! (add) or deletes (remove); consensus refuses the removed member's next write at once. +//! There is no suspend: remove and re-add instead. v1 repositories are read only; their +//! token-based collaborator list can still be listed. use anyhow::{Context, Result}; use serde_json::json; +use forge_core::members::{doc_type as role_name, MemberReader, MemberService}; use forge_core::tokens::TokenService; +use forge_core::user_error::{codes, UserError}; use crate::common::{resolve, RepoRef}; use crate::context::Ctx; @@ -13,10 +20,14 @@ use crate::{CollabCommand, RoleArg}; pub async fn run(ctx: &Ctx, cmd: &CollabCommand) -> Result<()> { match cmd { CollabCommand::Add { repo, member, role } => add(ctx, repo, member, *role).await, - CollabCommand::Suspend { repo, member, role } => suspend(ctx, repo, member, *role).await, - CollabCommand::Unsuspend { repo, member, role } => { - unsuspend(ctx, repo, member, *role).await - } + CollabCommand::Suspend { .. } | CollabCommand::Unsuspend { .. } => Err(UserError::new( + codes::NOT_IMPLEMENTED, + "suspend/unsuspend are not supported on forge-v2", + ) + .cause("forge-v2 has no freeze: membership is a document the owner creates or deletes") + .fix("`dg collab remove / ` revokes access immediately") + .fix("`dg collab add / --role writer` restores it") + .into()), CollabCommand::Remove { repo, member, role } => remove(ctx, repo, member, *role).await, CollabCommand::List { repo } => list(ctx, repo).await, } @@ -24,132 +35,142 @@ pub async fn run(ctx: &Ctx, cmd: &CollabCommand) -> Result<()> { async fn add(ctx: &Ctx, repo: &str, member: &str, role: RoleArg) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; + let role = role.to_core(); if !ctx.confirm(&format!( - "Grant {role:?} to {member} on {repo}? (mints a token)" + "Add {member} as a {} of {repo}? (one small document)", + role_name(role) ))? { return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; - let svc = TokenService::new(&client, &identity, &bridge); - svc.grant(&handle.repo_contract_id, member, role.to_core()) + let handle = resolve(&client, &identity, &repo_ref).await?; + let granted = MemberService::new(&client, &identity, &bridge) + .grant(&handle, member, role) .await - .context("grant")?; - + .context("adding the member")?; ctx.emit( - json!({ "status": "granted", "member": member, "role": format!("{role:?}").to_lowercase() }), - || println!("Granted {role:?} to {member}."), + json!({ + "status": "granted", + "member": member, + "role": role_name(role), + "documentId": granted.document_id, + "repo": handle.display(), + }), + || { + println!( + "{member} is a {} of {} (document {}).", + role_name(role), + handle.display(), + granted.document_id + ); + }, ); Ok(()) } -async fn suspend(ctx: &Ctx, repo: &str, member: &str, role: RoleArg) -> Result<()> { +async fn remove(ctx: &Ctx, repo: &str, member: &str, role: RoleArg) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; + let role = role.to_core(); if !ctx.confirm(&format!( - "Suspend {role:?} for {member}? (freezes the token)" + "Remove {member} as a {} of {repo}? Their next push is refused at once", + role_name(role) ))? { return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; - let svc = TokenService::new(&client, &identity, &bridge); - svc.suspend(&handle.repo_contract_id, member, role.to_core()) + let handle = resolve(&client, &identity, &repo_ref).await?; + let removed = MemberService::new(&client, &identity, &bridge) + .revoke(&handle, member, role) .await - .context("suspend")?; - + .context("removing the member")?; ctx.emit( - json!({ "status": "suspended", "member": member, "role": format!("{role:?}").to_lowercase() }), - || println!("Suspended {role:?} for {member} (token frozen)."), + json!({ + "status": if removed { "removed" } else { "not_a_member" }, + "member": member, + "role": role_name(role), + "repo": handle.display(), + }), + || { + if removed { + println!( + "Removed {member} ({}) from {}.", + role_name(role), + handle.display() + ); + } else { + println!( + "{member} is not a {} of {}; nothing to remove.", + role_name(role), + handle.display() + ); + } + }, ); Ok(()) } -async fn unsuspend(ctx: &Ctx, repo: &str, member: &str, role: RoleArg) -> Result<()> { +async fn list(ctx: &Ctx, repo: &str) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; - if !ctx.confirm(&format!( - "Unsuspend {role:?} for {member}? (thaws the token)" - ))? { - return Err(crate::errors::cancelled()); - } - let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; - let svc = TokenService::new(&client, &identity, &bridge); - svc.unsuspend(&handle.repo_contract_id, member, role.to_core()) - .await - .context("unsuspend")?; - - ctx.emit( - json!({ "status": "unsuspended", "member": member, "role": format!("{role:?}").to_lowercase() }), - || println!("Unsuspended {role:?} for {member} (token thawed)."), - ); - Ok(()) -} + let (client, _bridge, identity) = ctx.connect_with_identity().await?; + let handle = resolve(&client, &identity, &repo_ref).await?; -async fn remove(ctx: &Ctx, repo: &str, member: &str, role: RoleArg) -> Result<()> { - let repo_ref = RepoRef::parse(repo)?; - if !ctx.confirm(&format!( - "Remove {member} ({role:?})? This FREEZES then DESTROYS their frozen balance" - ))? { - return Err(crate::errors::cancelled()); + if let Ok(contract_id) = handle.v1_contract_id() { + // v1 (read only): the token balances are the collaborator list. + let collaborators = TokenService::new(&client) + .list_collaborators(contract_id) + .await + .context("list_collaborators")?; + let rows: Vec<_> = collaborators + .iter() + .map(|c| { + json!({ + "identityId": c.identity_id, + "write": c.holdings.write, + "writeFrozen": c.holdings.write_frozen, + "maintain": c.holdings.maintain, + "maintainFrozen": c.holdings.maintain_frozen, + }) + }) + .collect(); + ctx.emit( + json!({ "generation": "v1", "count": rows.len(), "collaborators": rows }), + || { + println!( + "{} collaborator(s) (v1 repo, read only):", + collaborators.len() + ); + for c in &collaborators { + println!( + " {} write={} maintain={}", + c.identity_id, c.holdings.write, c.holdings.maintain + ); + } + }, + ); + return Ok(()); } - let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; - let svc = TokenService::new(&client, &identity, &bridge); - svc.revoke(&handle.repo_contract_id, member, role.to_core()) - .await - .context("revoke")?; - - ctx.emit( - json!({ "status": "removed", "member": member, "role": format!("{role:?}").to_lowercase() }), - || println!("Removed {member} ({role:?}) — frozen balance destroyed."), - ); - Ok(()) -} -async fn list(ctx: &Ctx, repo: &str) -> Result<()> { - let repo_ref = RepoRef::parse(repo)?; - let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; - let svc = TokenService::new(&client, &identity, &bridge); - let collaborators = svc - .list_collaborators(&handle.repo_contract_id) + let members = MemberReader::new(&client) + .list(&handle) .await - .context("list_collaborators")?; - - let rows: Vec<_> = collaborators + .context("listing members")?; + let rows: Vec<_> = members .iter() - .map(|c| { + .map(|m| { json!({ - "identityId": c.identity_id, - "write": c.holdings.write, - "writeFrozen": c.holdings.write_frozen, - "maintain": c.holdings.maintain, - "maintainFrozen": c.holdings.maintain_frozen, + "identityId": m.identity_id, + "role": role_name(m.role), + "documentId": m.document_id, + "since": m.created_at, }) }) .collect(); - ctx.emit( - json!({ "count": rows.len(), "collaborators": rows }), + json!({ "generation": "v2", "count": rows.len(), "members": rows }), || { - println!("{} collaborator(s):", collaborators.len()); - for c in &collaborators { - let mut roles = Vec::new(); - if c.holdings.write { - roles.push(if c.holdings.write_frozen { - "WRITE(frozen)" - } else { - "WRITE" - }); - } - if c.holdings.maintain { - roles.push(if c.holdings.maintain_frozen { - "MAINTAIN(frozen)" - } else { - "MAINTAIN" - }); - } - println!(" {} [{}]", c.identity_id, roles.join(", ")); + println!("{} member(s) of {}:", members.len(), handle.display()); + for m in &members { + println!(" {} {}", m.identity_id, role_name(m.role)); } }, ); diff --git a/crates/dg/src/common.rs b/crates/dg/src/common.rs index 93e01248c..e053d8e82 100644 --- a/crates/dg/src/common.rs +++ b/crates/dg/src/common.rs @@ -2,9 +2,8 @@ use anyhow::{Context as _, Result}; -use forge_core::keystore::BridgeIdentity; use forge_core::platform::{LoadedIdentity, PlatformClient}; -use forge_core::repo::{RepoHandle, RepoService}; +use forge_core::scope::RepoRef as Repo; use forge_core::user_error::{codes, UserError}; /// A parsed `owner/name` (or bare `name`) repository reference. @@ -53,9 +52,9 @@ impl RepoRef { self.owner.as_deref().unwrap_or(default_owner) } - /// The repo contract id, when the reference is a bare base58 contract id (what the - /// helper prints for a `dash://` remote). Repo names are lowercase, so a - /// base58 id — which mixes cases — can never be mistaken for one. + /// The repo id, when the reference is a bare base58 id (a forge-v2 repo id or a v1 + /// repo contract id, as `dash://` takes). Repo names are lowercase, so a base58 + /// id — which mixes cases — can never be mistaken for one. pub fn contract_id(&self) -> Option<&str> { (self.owner.is_none() && looks_like_identity_id(&self.name) @@ -83,24 +82,22 @@ fn looks_like_identity_id(s: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() && !matches!(c, '0' | 'O' | 'I' | 'l')) } -/// Resolve a [`RepoRef`] to a [`RepoHandle`] via the registry. +/// Resolve a [`RepoRef`]: a bare id as a forge-v2 repo id or a v1 contract id; otherwise +/// `owner/name` as a forge-v2 repo, falling back to a (read-only) v1 registry listing. pub async fn resolve( client: &PlatformClient, identity: &LoadedIdentity, - bridge: &BridgeIdentity, repo_ref: &RepoRef, -) -> Result { - let svc = RepoService::new(client, identity, bridge); - if let Some(contract_id) = repo_ref.contract_id() { - return svc - .resolve_repo_by_contract(contract_id) +) -> Result { + if let Some(id) = repo_ref.contract_id() { + return forge_core::resolve::resolve_id(client, id) .await - .with_context(|| format!("resolving repo contract {contract_id}")); + .with_context(|| format!("resolving repo {id}")); } let owner = repo_ref.owner_or(&identity.id()).to_string(); // `with_context`, not a flattened message: the typed forge-core error must survive for // the error renderer (NotFound → E102, a network failure → E701). - svc.resolve_repo(&owner, &repo_ref.name) + forge_core::resolve::resolve_named(client, &owner, &repo_ref.name) .await .with_context(|| format!("resolving {owner}/{}", repo_ref.name)) } diff --git a/crates/dg/src/cost.rs b/crates/dg/src/cost.rs index 0417b163f..4d54f98b1 100644 --- a/crates/dg/src/cost.rs +++ b/crates/dg/src/cost.rs @@ -3,12 +3,12 @@ use anyhow::{Context, Result}; use serde_json::json; -use forge_core::cost::{estimate, prompt_delete_refund}; +use forge_core::cost::estimate; use forge_core::repo::RepoService; use crate::common::{resolve, RepoRef}; use crate::context::Ctx; -use crate::fmt::{cost_json, cost_line, dash_usd_price, refund_line, REPO_CREATE_ESTIMATE_CREDITS}; +use crate::fmt::{cost_json, cost_line, dash_usd_price, REPO_CREATE_ESTIMATE_CREDITS}; use crate::{Backend, CostCommand}; /// Dispatch a `cost` subcommand. @@ -51,13 +51,13 @@ fn estimate_cmd( "burnCredits": est.burn, "totalCredits": est.total(), "cost": cost_json(est.total(), price), - "refundableDeposit": cost_json(est.deposit, price), + "storageDeposit": cost_json(est.deposit, price), }), || { println!("Estimate for {bytes} bytes ({backend_label} tier):"); println!(" total: {}", cost_line(est.total(), price)); - println!(" refundable: {} (storage deposit, reclaimable on delete)", cost_line(est.deposit, price)); - println!(" burned: {} (non-refundable processing)", cost_line(est.burn, price)); + println!(" storage: {} (deposit; Platform packs are permanent, not refunded)", cost_line(est.deposit, price)); + println!(" processing: {}", cost_line(est.burn, price)); if !matches!(backend, None | Some(Backend::Platform)) { println!(" note: external backends store pack bytes off-chain — only the manifest + refs are billed on-chain."); } @@ -104,33 +104,31 @@ async fn audit(ctx: &Ctx, repo: Option<&str>) -> Result<()> { // Live storage tally for a repo. let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = RepoService::new(&client, &identity, &bridge); let manifests = svc.read_pack_manifests(&handle).await.unwrap_or_default(); let total_bytes: u64 = manifests.iter().map(|m| m.size_bytes).sum(); let deposit_locked: u64 = est_deposit(total_bytes); - let refund = prompt_delete_refund(total_bytes); ctx.emit( json!({ "mode": "repo_storage_tally", - "repoContractId": handle.repo_contract_id, + "repoId": handle.id(), + "generation": handle.generation(), "packCount": manifests.len(), "packBytes": total_bytes, "depositLocked": cost_json(deposit_locked, price), - "promptRefund": cost_json(refund, price), }), || { - println!( - "Storage tally for {}/{}:", - handle.owner_id, handle.normalized_name - ); + println!("Storage tally for {}:", handle.display()); println!( " packs: {} ({total_bytes} bytes)", manifests.len() ); println!(" deposit locked: {}", cost_line(deposit_locked, price)); - println!(" prompt refund: {}", refund_line(refund, price)); + if !handle.is_v1() { + println!(" (forge-v2 packs are permanent: the deposit is not refundable)"); + } }, ); Ok(()) diff --git a/crates/dg/src/errors.rs b/crates/dg/src/errors.rs index efaeeeab4..e7cf2c953 100644 --- a/crates/dg/src/errors.rs +++ b/crates/dg/src/errors.rs @@ -114,7 +114,6 @@ pub fn context_for(cmd: &Command) -> (Option<&'static str>, Option<&str>) { ("could not show the repository", Some(repo)) } Command::Repo(Rp::List { .. }) => ("could not list repositories", None), - Command::Repo(Rp::Delete { repo }) => ("repository storage not deleted", Some(repo)), Command::Repo(Rp::Backend(RepoBackendCommand::Set { repo, .. })) => { ("backend not changed", Some(repo)) } diff --git a/crates/dg/src/fmt.rs b/crates/dg/src/fmt.rs index c6731ff3f..6a49502c4 100644 --- a/crates/dg/src/fmt.rs +++ b/crates/dg/src/fmt.rs @@ -13,10 +13,10 @@ use serde_json::{json, Value}; /// Override with the `DASH_USD` environment variable. pub const FALLBACK_DASH_USD: f64 = 30.0; -/// The measured repo-v1 instantiation cost, in credits, used for the *pre-write* estimate -/// shown before `dg repo create` signs (the economics docs reconcile ~1.18 DASH). The -/// actual measured cost is reported after the create lands. -pub const REPO_CREATE_ESTIMATE_CREDITS: u64 = 118_000_000_000; +/// The pre-write estimate shown before `dg repo create` signs, in credits: a forge-v2 +/// repo is three small documents (`repo`, the owner's `maintainer`, the first `config`). +/// An upper bound; the measured cost is reported after the create lands. +pub const REPO_CREATE_ESTIMATE_CREDITS: u64 = 200_000_000; /// The DASH/USD price to use: `DASH_USD` env override, else the offline fallback. pub fn dash_usd_price() -> f64 { @@ -53,14 +53,6 @@ pub fn cost_line(credits: u64, price_usd: f64) -> String { format!("~{} DASH ≈ ${:.2}", dash_amount(dash), usd) } -/// A refund display (destructive-delete estimate; green in the UI), e.g. -/// `+0.0003 DASH ≈ $0.01 refund`. -pub fn refund_line(credits: u64, price_usd: f64) -> String { - let dash = credits_to_dash(credits); - let usd = dash * price_usd; - format!("+{} DASH ≈ ${:.2} refund", dash_amount(dash), usd) -} - /// The `--json` block for a cost quote (shared by `cost estimate` and the write previews). pub fn cost_json(credits: u64, price_usd: f64) -> Value { let dash = credits_to_dash(credits); @@ -104,16 +96,11 @@ mod tests { #[test] fn cost_line_shows_dash_primary_usd_secondary() { // 1 MiB storage deposit ≈ 0.283 DASH. - let line = cost_line(REPO_CREATE_ESTIMATE_CREDITS, 30.0); + let line = cost_line(118_000_000_000, 30.0); assert!(line.starts_with("~1.18 DASH"), "line was {line}"); assert!(line.contains("$35.40"), "line was {line}"); - } - - #[test] - fn refund_line_is_positive_and_labeled() { - let line = refund_line(27_000_000, 30.0); - assert!(line.starts_with('+')); - assert!(line.ends_with("refund")); + // A forge-v2 repo create is quoted well under a cent of a DASH. + const { assert!(REPO_CREATE_ESTIMATE_CREDITS < forge_core::cost::CREDITS_PER_DASH / 100) }; } #[test] diff --git a/crates/dg/src/issue.rs b/crates/dg/src/issue.rs index c84050f35..9b856cabc 100644 --- a/crates/dg/src/issue.rs +++ b/crates/dg/src/issue.rs @@ -39,10 +39,10 @@ fn to_filter(state: StateArg) -> StateFilter { async fn list(ctx: &Ctx, repo: &str, state: StateArg, limit: u32) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = IssueService::new(&client, &identity, &bridge); let issues = svc - .list_issues(&handle.repo_contract_id, to_filter(state), limit, None) + .list_issues(handle.v1_contract_id()?, to_filter(state), limit, None) .await .context("list_issues")?; @@ -72,10 +72,10 @@ async fn list(ctx: &Ctx, repo: &str, state: StateArg, limit: u32) -> Result<()> async fn view(ctx: &Ctx, repo: &str, number: u64) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = IssueService::new(&client, &identity, &bridge); let iw = svc - .issue_state(&handle.repo_contract_id, number) + .issue_state(handle.v1_contract_id()?, number) .await .context("issue_state")? .ok_or_else(|| { @@ -123,10 +123,10 @@ async fn create(ctx: &Ctx, repo: &str, title: &str, body: &str) -> Result<()> { return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = IssueService::new(&client, &identity, &bridge); let issue = svc - .create_issue(&handle.repo_contract_id, title, body) + .create_issue(handle.v1_contract_id()?, title, body) .await .context("create_issue")?; @@ -148,10 +148,10 @@ async fn comment(ctx: &Ctx, repo: &str, number: u64, body: &str) -> Result<()> { return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = IssueService::new(&client, &identity, &bridge); let issue = svc - .get_issue(&handle.repo_contract_id, number) + .get_issue(handle.v1_contract_id()?, number) .await? .ok_or_else(|| { crate::errors::not_found( @@ -160,7 +160,7 @@ async fn comment(ctx: &Ctx, repo: &str, number: u64, body: &str) -> Result<()> { ) })?; let doc_id = svc - .comment(&handle.repo_contract_id, &issue.document_id, body, None) + .comment(handle.v1_contract_id()?, &issue.document_id, body, None) .await .context("comment")?; @@ -178,10 +178,10 @@ async fn close_reopen(ctx: &Ctx, repo: &str, number: u64, close: bool) -> Result return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = IssueService::new(&client, &identity, &bridge); let issue = svc - .get_issue(&handle.repo_contract_id, number) + .get_issue(handle.v1_contract_id()?, number) .await? .ok_or_else(|| { crate::errors::not_found( @@ -190,10 +190,10 @@ async fn close_reopen(ctx: &Ctx, repo: &str, number: u64, close: bool) -> Result ) })?; let event_id = if close { - svc.close(&handle.repo_contract_id, &issue.document_id) + svc.close(handle.v1_contract_id()?, &issue.document_id) .await? } else { - svc.reopen(&handle.repo_contract_id, &issue.document_id) + svc.reopen(handle.v1_contract_id()?, &issue.document_id) .await? }; @@ -231,10 +231,10 @@ async fn label( return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = IssueService::new(&client, &identity, &bridge); let issue = svc - .get_issue(&handle.repo_contract_id, number) + .get_issue(handle.v1_contract_id()?, number) .await? .ok_or_else(|| { crate::errors::not_found( @@ -244,7 +244,7 @@ async fn label( })?; let event_id = svc .add_event( - &handle.repo_contract_id, + handle.v1_contract_id()?, &issue.document_id, kind, Some(&value), diff --git a/crates/dg/src/main.rs b/crates/dg/src/main.rs index f014a35d2..b9ef3fc4a 100644 --- a/crates/dg/src/main.rs +++ b/crates/dg/src/main.rs @@ -113,7 +113,7 @@ pub enum Command { /// Cost estimates and spend audits. #[command(subcommand)] Cost(CostCommand), - /// Repack and reclaim storage (delete superseded docs → refund). + /// Consolidate a repo's packs into one superseding pack (deletes nothing on Platform). Repack { /// The repository (`owner/name`). repo: Option, @@ -187,16 +187,22 @@ pub enum AuthCommand { #[derive(Debug, Subcommand)] pub enum RepoCommand { - /// Instantiate a repo contract, listing and token setup. + /// Create a forge-v2 repository (repo + your maintainer membership + initial config). Create { - /// Repository name. + /// Repository name: the URL slug (a-z, 0-9, `.`, `_`, `-`; upper case is folded). name: String, /// Storage backend policy. #[arg(long, value_enum, default_value = "platform")] storage: StorageArg, - /// Listing description. + /// Description. #[arg(long, default_value = "")] description: String, + /// Display name (defaults to none; the slug is shown). + #[arg(long, default_value = "")] + display_name: String, + /// Default branch. + #[arg(long, default_value = "main")] + default_branch: String, }, /// Print the `git clone` command for a repo (`owner/name`). Clone { @@ -219,11 +225,6 @@ pub enum RepoCommand { #[arg(long)] owner: Option, }, - /// Delete a repo's deletable storage (chunks + manifests → refund). - Delete { - /// The repository (`owner/name`), or just `name` for the signing identity. - repo: String, - }, /// Backend configuration. #[command(subcommand)] Backend(RepoBackendCommand), @@ -433,47 +434,49 @@ pub enum ReleaseCommand { #[derive(Debug, Subcommand)] pub enum CollabCommand { - /// Grant access (mint a WRITE/MAINTAIN token). + /// Add a member (the repo owner creates a writer/maintainer document). Add { /// The repository (`owner/name`). repo: String, /// The collaborator identity id (base58). member: String, /// The role to grant. - #[arg(long, value_enum, default_value = "write")] + #[arg(long, value_enum, default_value = "writer")] role: RoleArg, }, - /// Suspend a collaborator (freeze tokens). + /// Not supported on forge-v2 (remove revokes access immediately). + #[command(hide = true)] Suspend { /// The repository (`owner/name`). repo: String, /// The collaborator identity id (base58). member: String, /// The role to suspend. - #[arg(long, value_enum, default_value = "write")] + #[arg(long, value_enum, default_value = "writer")] role: RoleArg, }, - /// Unsuspend a collaborator (thaw frozen tokens). + /// Not supported on forge-v2 (add restores access). + #[command(hide = true)] Unsuspend { /// The repository (`owner/name`). repo: String, /// The collaborator identity id (base58). member: String, /// The role to unsuspend. - #[arg(long, value_enum, default_value = "write")] + #[arg(long, value_enum, default_value = "writer")] role: RoleArg, }, - /// Remove a collaborator (freeze + destroy). + /// Remove a member (the owner deletes their document; their next push is refused). Remove { /// The repository (`owner/name`). repo: String, /// The collaborator identity id (base58). member: String, /// The role to revoke. - #[arg(long, value_enum, default_value = "write")] + #[arg(long, value_enum, default_value = "writer")] role: RoleArg, }, - /// List collaborators (token-balance query). + /// List members. List { /// The repository (`owner/name`). repo: String, @@ -713,19 +716,23 @@ impl VerdictArg { } } -/// A collaborator role. +/// A member role. #[derive(Debug, Clone, Copy, clap::ValueEnum)] pub enum RoleArg { - Write, - Maintain, + /// Push, upload (a `writer` document). + #[value(alias = "write")] + Writer, + /// Also protected refs, config, releases (a `maintainer` document). + #[value(alias = "maintain")] + Maintainer, } impl RoleArg { - /// The forge-core role. - pub fn to_core(self) -> forge_core::tokens::Role { + /// The forge-v2 role. + pub fn to_core(self) -> forge_core::rules::v2::Role { match self { - RoleArg::Write => forge_core::tokens::Role::Write, - RoleArg::Maintain => forge_core::tokens::Role::Maintain, + RoleArg::Writer => forge_core::rules::v2::Role::Writer, + RoleArg::Maintainer => forge_core::rules::v2::Role::Maintainer, } } } @@ -924,6 +931,7 @@ mod tests { name, storage, description, + .. }) => { assert_eq!(name, "my-repo"); assert_eq!(storage.mode(), 4); @@ -959,7 +967,7 @@ mod tests { Command::Collab(CollabCommand::Add { repo, member, role }) => { assert_eq!(repo, "o/r"); assert_eq!(member, "member123"); - assert!(matches!(role, RoleArg::Maintain)); + assert!(matches!(role, RoleArg::Maintainer)); } _ => panic!("expected collab add"), } @@ -972,7 +980,7 @@ mod tests { Command::Collab(CollabCommand::Unsuspend { repo, member, role }) => { assert_eq!(repo, "o/r"); assert_eq!(member, "member123"); - assert!(matches!(role, RoleArg::Write)); // default role + assert!(matches!(role, RoleArg::Writer)); // default role } _ => panic!("expected collab unsuspend"), } diff --git a/crates/dg/src/maint.rs b/crates/dg/src/maint.rs index 942164937..f8b705c09 100644 --- a/crates/dg/src/maint.rs +++ b/crates/dg/src/maint.rs @@ -1,10 +1,12 @@ //! `dg repack` / `dg reseed` / `dg import` — maintenance commands. //! -//! - `repack` consolidates a repo's live packs into one optimized pack, publishes it, and -//! deletes the caller's own now-superseded storage → an on-chain refund -//! (`forge_core::repo::RepoService::repack`). -//! - `reseed` re-uploads pack bytes to another backend for availability, announcing the -//! new URIs via `packMirror` docs when the contract template carries them. +//! - `repack` consolidates a repo's live packs into one optimized pack and publishes it +//! with a `supersedes` list (`forge_core::repo::RepoService::repack`). It deletes +//! nothing on Platform — forge-v2 chunks and manifests are permanent, so there is no +//! refund — and the superseded packs stay readable as a fallback. Only packs on your own +//! external storage can be garbage-collected afterwards, by you. +//! - `reseed` re-uploads pack bytes to another backend for availability and records the new +//! location as the caller's own copy of the pack. //! - `import` remains a thin, not-yet-wired wrapper over `forge-import` (PRD 06). use anyhow::{bail, Context, Result}; @@ -18,12 +20,11 @@ use forge_core::storage::{ExternalTarget, StorageProfiles, StorageTarget}; use crate::common::{resolve, RepoRef}; use crate::context::Ctx; -use crate::fmt::{cost_line, dash_usd_price, refund_line}; +use crate::fmt::{cost_line, dash_usd_price}; use crate::Backend; -/// `dg repack [--backend]` — consolidate + reclaim storage (delete superseded docs -/// → refund). Shows an estimated refund, prompts unless `--yes`, then reports the measured -/// upload cost, observed refund, and net. +/// `dg repack [--backend]` — consolidate the live packs into one superseding pack. +/// Shows what will be consolidated, prompts unless `--yes`, then reports what it cost. pub async fn repack( ctx: &Ctx, repo: Option<&str>, @@ -33,46 +34,34 @@ pub async fn repack( let repo = repo.context("`dg repack` needs a repository: dg repack /")?; let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; + handle.require_v2()?; let svc = RepoService::new(&client, &identity, &bridge); let price = dash_usd_price(); - // Pre-flight: show what will be consolidated + a rough refund estimate (superseded - // bytes × the per-byte storage deposit) so the operator can weigh it before signing. let manifests = svc.read_pack_manifests(&handle).await.unwrap_or_default(); - let kind0: Vec<_> = manifests.iter().filter(|m| m.kind == 0).collect(); - let owned_bytes: u64 = kind0 - .iter() - .filter(|m| m.owner_id == identity.id()) - .map(|m| m.size_bytes) - .sum(); - let est_refund = forge_core::cost::prompt_delete_refund(owned_bytes); - + let space = forge_core::repo::locator_pack_space(&manifests, None); + let live_bytes: u64 = space.iter().map(|m| m.size_bytes).sum(); if !ctx.json { println!( - "Repack {}/{}: {} live pack(s), {owned_bytes} caller-owned bytes", - handle.owner_id, - handle.normalized_name, - kind0.len() + "Repack {}: {} live pack(s), {live_bytes} bytes", + handle.display(), + space.len() ); println!( - " estimated storage refund from deleting superseded packs: {}", - refund_line(est_refund, price) + " writes one consolidated pack + manifest; deletes nothing (Platform packs are \ + permanent, so there is no refund). Superseded packs stay readable as a fallback." ); - println!(" (repack re-uploads one consolidated pack first — availability never dips)"); } if !ctx.confirm(&format!( - "Repack {}/{}? Consolidates packs, then deletes superseded storage (est. {})", - handle.owner_id, - handle.normalized_name, - refund_line(est_refund, price) + "Repack {}? Uploads one consolidated pack (paid like a push)", + handle.display() ))? { return Err(crate::errors::cancelled()); } - // The consolidated pack's destination. Platform (default) is the tier the refund - // reclaims from; an external profile (verified upload) or legacy env-configured - // backend migrates cold history outward (mixed mode). + // The consolidated pack's destination: Platform (default), an external profile + // (verified upload), or a legacy env-configured backend (migrates cold history out). let profile_target = profile.map(external_profile_target).transpose()?; let external = if profile_target.is_some() { None @@ -100,14 +89,14 @@ pub async fn repack( /// Print (or `--json`-emit) a finished repack. fn emit_repack_report( ctx: &Ctx, - handle: &forge_core::repo::RepoHandle, + handle: &forge_core::scope::RepoRef, report: &forge_core::repo::RepackReport, price: f64, ) { ctx.emit( json!({ "status": "repacked", - "repoContractId": handle.repo_contract_id, + "repoId": handle.id(), "newPackHash": hex::encode(report.new_pack_hash), "newManifestId": report.new_manifest_id, "locatorManifestId": report.locator_manifest_id, @@ -115,21 +104,22 @@ fn emit_repack_report( "objectCount": report.object_count, "newUris": report.new_uris, "supersededCount": report.superseded_count, - "bytesReclaimed": report.bytes_reclaimed, - "deletedChunks": report.deleted_chunks, - "deletedManifests": report.deleted_manifests, - "uploadCost": cost_json_credits(report.upload_cost_credits, price), - "refund": cost_json_credits(report.refund_credits, price), - "netCredits": report.net_credits, - "netDash": net_credits_to_dash(report.net_credits), + "supersededBytes": report.superseded_bytes, + "deletedDocuments": 0, + "cost": crate::fmt::cost_json(report.cost_credits, price), }), || { println!( - "Repacked {}/{} → 1 consolidated pack ({} objects, {} bytes).", - handle.owner_id, handle.normalized_name, report.object_count, report.new_pack_bytes + "Repacked {} → 1 consolidated pack ({} objects, {} bytes).", + handle.display(), + report.object_count, + report.new_pack_bytes ); println!(" new pack: {}", hex::encode(report.new_pack_hash)); - println!(" superseded: {} pack(s)", report.superseded_count); + println!( + " supersedes: {} pack(s), {} bytes (kept; nothing deleted)", + report.superseded_count, report.superseded_bytes + ); // The locator is what makes the repo browsable without downloading every pack, // so say plainly whether it landed rather than leaving it to be inferred. match &report.locator_manifest_id { @@ -140,24 +130,9 @@ fn emit_repack_report( ), } println!( - " deleted: {} chunk(s), {} manifest(s) ({} bytes reclaimed)", - report.deleted_chunks, report.deleted_manifests, report.bytes_reclaimed - ); - println!( - " upload cost: {}", - cost_line(report.upload_cost_credits, price) - ); - println!( - " observed refund: {}", - refund_line(report.refund_credits, price) + " cost: {}", + cost_line(report.cost_credits, price) ); - let net = report.net_credits; - let net_dash = net_credits_to_dash(net); - if net >= 0 { - println!(" net: +{net_dash:.8} DASH reclaimed"); - } else { - println!(" net: {net_dash:.8} DASH (consolidation spend)"); - } }, ); } @@ -173,7 +148,7 @@ pub async fn reseed( let repo = repo.context("`dg reseed` needs a repository: dg reseed /")?; let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = RepoService::new(&client, &identity, &bridge); let backend = match profile { @@ -186,9 +161,10 @@ pub async fn reseed( }; let target_label = profile.unwrap_or_else(|| to.map_or("external", Backend::label)); + handle.require_v2()?; if !ctx.confirm(&format!( - "Reseed {}/{} packs to {target_label}? (re-uploads pack bytes for availability)", - handle.owner_id, handle.normalized_name + "Reseed {} packs to {target_label}? (re-uploads pack bytes for availability)", + handle.display() ))? { return Err(crate::errors::cancelled()); } @@ -201,48 +177,38 @@ pub async fn reseed( let reseeded_json: Vec<_> = report .reseeded .iter() - .map(|(hash, uris)| json!({ "packHash": hex::encode(hash), "uris": uris })) + .map(|r| { + json!({ + "packHash": hex::encode(r.pack_hash), + "uris": r.uris, + "announced": r.announced, + }) + }) .collect(); - ctx.emit( json!({ "status": "reseeded", - "repoContractId": handle.repo_contract_id, + "repoId": handle.id(), "target": target_label, "packs": reseeded_json, - "announcedOnChain": report.announced_on_chain, - "packMirrorDocsWritten": report.mirror_docs_written, - "note": if report.announced_on_chain { - "new URIs announced on-chain via packMirror docs" - } else { - "packMirror type absent on this contract (v1 template) — URIs returned but \ - not announced on-chain; packMirror is the template-v2 addition that closes this" - }, }), || { println!( - "Reseeded {} pack(s) of {}/{} to {target_label}.", + "Reseeded {} pack(s) of {} to {target_label}.", report.reseeded.len(), - handle.owner_id, - handle.normalized_name + handle.display() ); - for (hash, uris) in &report.reseeded { - println!(" {} →", hex::encode(hash)); - for u in uris { + for r in &report.reseeded { + let note = if r.announced { + "recorded as your copy" + } else { + "uploaded (you already hold a manifest for this pack)" + }; + println!(" {} — {note}", hex::encode(r.pack_hash)); + for u in &r.uris { println!(" {u}"); } } - if report.announced_on_chain { - println!( - " announced on-chain: {} packMirror doc(s).", - report.mirror_docs_written - ); - } else { - println!( - " note: this contract has no packMirror type (v1 template); the URIs \ - above are not announced on-chain. packMirror is a template-v2 addition." - ); - } }, ); Ok(()) @@ -277,13 +243,12 @@ pub async fn reseed_from_local( let (targets, required, label) = reseed_targets(profile)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = RepoService::new(&client, &identity, &bridge); if !ctx.confirm(&format!( - "Restore unreadable packs of {}/{} from {} to {label}? (uploads to your storage; \ - no Platform spend unless packMirror docs are written)", - handle.owner_id, - handle.normalized_name, + "Restore unreadable packs of {} from {} to {label}? (uploads to your storage; no \ + Platform spend)", + handle.display(), git_dir.display() ))? { return Err(crate::errors::cancelled()); @@ -343,7 +308,7 @@ fn reseed_targets(profile: Option<&str>) -> Result<(Vec, usize, /// Print (or `--json`-emit) a finished `dg reseed --from-local`. fn emit_local_reseed( ctx: &Ctx, - handle: &forge_core::repo::RepoHandle, + handle: &forge_core::scope::RepoRef, git_dir: &std::path::Path, label: &str, report: &forge_core::repo::LocalReseedReport, @@ -363,20 +328,17 @@ fn emit_local_reseed( ctx.emit( json!({ "status": if missing.is_empty() { "reseeded" } else { "partial" }, - "repoContractId": handle.repo_contract_id, + "repoId": handle.id(), "targets": label, "restored": restored_json, "healthy": report.healthy.len(), "missingLocally": missing, - "announcedOnChain": report.announced_on_chain, - "packMirrorDocsWritten": report.mirror_docs_written, }), || { println!( - "Restored {} pack(s) of {}/{} from {} to {label} ({} still healthy).", + "Restored {} pack(s) of {} from {} to {label} ({} still healthy).", report.restored.len(), - handle.owner_id, - handle.normalized_name, + handle.display(), git_dir.display(), report.healthy.len() ); @@ -384,28 +346,13 @@ fn emit_local_reseed( let state = if r.restored_recorded_uri { "its recorded copy is readable again" } else { - "stored at NEW locations only (see below)" + "stored at NEW locations only — run `dg reseed --profile

` to record them" }; println!(" {} — {state}", hex::encode(r.pack_hash)); for u in &r.uris { println!(" {u}"); } } - if report.restored.iter().any(|r| !r.restored_recorded_uri) { - if report.announced_on_chain { - println!( - " new locations announced on-chain: {} packMirror doc(s).", - report.mirror_docs_written - ); - } else { - println!( - " note: this repo's contract has no packMirror type, and a manifest is \ - immutable, so readers will not find NEW locations. Re-run with \ - --profile to recreate the \ - recorded URI." - ); - } - } for h in &missing { println!(" {h} — no local copy in this clone (try a clone that fetched it)"); } @@ -482,18 +429,6 @@ fn build_external_backend(backend: Option) -> Result serde_json::Value { - crate::fmt::cost_json(credits, price_usd) -} - -/// Convert a signed net-credit delta to DASH for display (1 DASH = 1e11 credits). The -/// magnitudes here (a repo's storage) sit far inside f64's exact-integer range. -#[allow(clippy::cast_precision_loss)] -fn net_credits_to_dash(net: i128) -> f64 { - net as f64 / 1e11 -} - /// `dg import ` — thin wrapper over `forge-import` (PRD 06), not yet wired. /// /// Fails (E103) rather than exiting 0, so `dg import X && …` does not proceed as if a diff --git a/crates/dg/src/pr.rs b/crates/dg/src/pr.rs index 817c078fc..c573061b4 100644 --- a/crates/dg/src/pr.rs +++ b/crates/dg/src/pr.rs @@ -84,7 +84,7 @@ async fn create( return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = PullRequestService::new(&client, &identity, &bridge); let input = PullRequestInput { title: title.to_string(), @@ -97,7 +97,7 @@ async fn create( patch_manifest_hash: None, }; let pr = svc - .create_pr(&handle.repo_contract_id, &input) + .create_pr(handle.v1_contract_id()?, &input) .await .context("create_pr")?; @@ -118,10 +118,10 @@ async fn create( async fn list(ctx: &Ctx, repo: &str, limit: u32) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = PullRequestService::new(&client, &identity, &bridge); let prs = svc - .list_prs(&handle.repo_contract_id, limit, None) + .list_prs(handle.v1_contract_id()?, limit, None) .await .context("list_prs")?; @@ -154,10 +154,10 @@ async fn list(ctx: &Ctx, repo: &str, limit: u32) -> Result<()> { async fn view(ctx: &Ctx, repo: &str, number: u64) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = PullRequestService::new(&client, &identity, &bridge); let pw = svc - .pr_state(&handle.repo_contract_id, number, None) + .pr_state(handle.v1_contract_id()?, number, None) .await .context("pr_state")? .ok_or_else(|| { @@ -172,7 +172,7 @@ async fn view(ctx: &Ctx, repo: &str, number: u64) -> Result<()> { // `unwrap_or_default()` would print "no reviews" on a failed read — reintroducing the // exact invisibility that made reviews worth surfacing in the first place. let reviews_result = svc - .list_reviews(&handle.repo_contract_id, &pw.pr.document_id) + .list_reviews(handle.v1_contract_id()?, &pw.pr.document_id) .await; let reviews = reviews_result.as_deref().unwrap_or(&[]); let reviews_error = reviews_result.as_ref().err().map(ToString::to_string); @@ -264,10 +264,10 @@ async fn review( return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = PullRequestService::new(&client, &identity, &bridge); let pr = svc - .get_pr(&handle.repo_contract_id, number) + .get_pr(handle.v1_contract_id()?, number) .await? .ok_or_else(|| { crate::errors::not_found( @@ -279,7 +279,7 @@ async fn review( let commit_oid = hex::decode(commit_hex).context("--commit must be hex")?; let doc_id = svc .review( - &handle.repo_contract_id, + handle.v1_contract_id()?, &pr.document_id, verdict.code(), &commit_oid, @@ -314,10 +314,10 @@ async fn merge(ctx: &Ctx, repo: &str, number: u64, merge_oid: Option<&str>) -> R return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = PullRequestService::new(&client, &identity, &bridge); let pr = svc - .get_pr(&handle.repo_contract_id, number) + .get_pr(handle.v1_contract_id()?, number) .await? .ok_or_else(|| { crate::errors::not_found( @@ -328,7 +328,7 @@ async fn merge(ctx: &Ctx, repo: &str, number: u64, merge_oid: Option<&str>) -> R let oid_hex = merge_oid.unwrap_or(&pr.head_oid); let oid = hex::decode(oid_hex).context("--merge-oid must be hex")?; let event_id = svc - .merge_event(&handle.repo_contract_id, &pr.document_id, &oid) + .merge_event(handle.v1_contract_id()?, &pr.document_id, &oid) .await .context("merge_event")?; @@ -336,7 +336,7 @@ async fn merge(ctx: &Ctx, repo: &str, number: u64, merge_oid: Option<&str>) -> R // asking the same question a reader would ask is the only honest way to report the // outcome. let merged = svc - .pr_state(&handle.repo_contract_id, number, None) + .pr_state(handle.v1_contract_id()?, number, None) .await .ok() .flatten() @@ -394,10 +394,10 @@ async fn merge(ctx: &Ctx, repo: &str, number: u64, merge_oid: Option<&str>) -> R async fn checkout(ctx: &Ctx, repo: &str, number: u64) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = PullRequestService::new(&client, &identity, &bridge); let pr = svc - .get_pr(&handle.repo_contract_id, number) + .get_pr(handle.v1_contract_id()?, number) .await? .ok_or_else(|| { crate::errors::not_found( @@ -507,10 +507,10 @@ fn fetch_pr_head(ctx: &Ctx, pr: &forge_core::collab::PullRequest) -> Result Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = PullRequestService::new(&client, &identity, &bridge); let pr = svc - .get_pr(&handle.repo_contract_id, number) + .get_pr(handle.v1_contract_id()?, number) .await? .ok_or_else(|| { crate::errors::not_found( diff --git a/crates/dg/src/release.rs b/crates/dg/src/release.rs index 5f67e8412..ca2452a9b 100644 --- a/crates/dg/src/release.rs +++ b/crates/dg/src/release.rs @@ -45,7 +45,7 @@ async fn create( return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = ReleaseService::new(&client, &identity, &bridge); let input = ReleaseInput { tag_name: tag.to_string(), @@ -55,7 +55,7 @@ async fn create( assets: Vec::new(), }; let doc_id = svc - .create_release(&handle.repo_contract_id, &input) + .create_release(handle.v1_contract_id()?, &input) .await .context("create_release")?; @@ -69,10 +69,10 @@ async fn create( async fn list(ctx: &Ctx, repo: &str) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = ReleaseService::new(&client, &identity, &bridge); let releases = svc - .list_releases(&handle.repo_contract_id) + .list_releases(handle.v1_contract_id()?) .await .context("list_releases")?; @@ -113,9 +113,9 @@ async fn download( ) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = ReleaseService::new(&client, &identity, &bridge); - let releases = svc.list_releases(&handle.repo_contract_id).await?; + let releases = svc.list_releases(handle.v1_contract_id()?).await?; let release = releases .into_iter() .find(|r| r.tag_name == tag) diff --git a/crates/dg/src/repo.rs b/crates/dg/src/repo.rs index b82796573..c3bc72f32 100644 --- a/crates/dg/src/repo.rs +++ b/crates/dg/src/repo.rs @@ -1,17 +1,23 @@ -//! `dg repo` — repo lifecycle: create / view / list / delete / backend set (+ clone/fork). +//! `dg repo` — repository lifecycle: create / view / list / backend set (+ clone/fork). +//! +//! New repositories are forge-v2: a `repo` document plus the owner's `maintainer` +//! membership and an initial `config` in the network's shared forge-core contract, written +//! by one resumable session (`forge_core::create`). v1 repositories (one contract each) +//! remain viewable and cloneable but are read only. Repositories cannot be deleted. use anyhow::{Context, Result}; use serde_json::json; -use forge_core::cost::prompt_delete_refund; -use forge_core::platform::{self, FieldValue, QueryFilter, QueryOrder}; -use forge_core::repo::{CreateRepoOpts, RepoService}; +use forge_core::create::{create_repo, default_journal_dir, CreateRepoOpts, StepOutcome}; +use forge_core::members::MemberReader; +use forge_core::repo::RepoService; +use forge_core::resolve::{list_owned, repo_slug}; use forge_core::tokens::TokenService; use forge_core::user_error::{codes, UserError}; use crate::common::{resolve, RepoRef}; use crate::context::Ctx; -use crate::fmt::{cost_json, cost_line, dash_usd_price, refund_line, REPO_CREATE_ESTIMATE_CREDITS}; +use crate::fmt::{cost_json, cost_line, dash_usd_price, REPO_CREATE_ESTIMATE_CREDITS}; use crate::{RepoBackendCommand, RepoCommand}; /// Dispatch a `repo` subcommand. @@ -21,76 +27,90 @@ pub async fn run(ctx: &Ctx, cmd: &RepoCommand) -> Result<()> { name, storage, description, - } => create(ctx, name, storage.mode(), storage.label(), description).await, + display_name, + default_branch, + } => { + let opts = CreateRepoOpts { + display_name: display_name.clone(), + description: description.clone(), + default_branch: default_branch.clone(), + backend_mode: storage.mode(), + ..CreateRepoOpts::public(name.clone()) + }; + create(ctx, &opts, storage.label()).await + } RepoCommand::Clone { repo } => clone(ctx, repo), RepoCommand::Fork { repo } => fork(ctx, repo), RepoCommand::View { repo } => view(ctx, repo).await, RepoCommand::List { owner } => list(ctx, owner.as_deref()).await, - RepoCommand::Delete { repo } => delete(ctx, repo).await, RepoCommand::Backend(RepoBackendCommand::Set { repo, mode }) => { backend_set(ctx, repo, mode.mode(), mode.label()).await } } } -/// Create a repo (contract instantiate + listing + token setup). Shows the ~1.18 DASH -/// instantiation estimate and prompts unless `--yes`, then reports the measured cost. -async fn create( - ctx: &Ctx, - name: &str, - backend_mode: u8, - storage_label: &str, - description: &str, -) -> Result<()> { +/// Create a forge-v2 repository. Shows the estimate and prompts unless `--yes`, then reports +/// the measured cost. Re-running a create that was interrupted finishes it without paying +/// for any step twice; re-running one that finished changes nothing. +async fn create(ctx: &Ctx, opts: &CreateRepoOpts, storage_label: &str) -> Result<()> { + let slug = repo_slug(&opts.name)?; + if ctx.target.v2.is_none() { + return Err(forge_core::Error::V2NotDeployed { + network: ctx.network_label(), + } + .into()); + } let price = dash_usd_price(); if !ctx.json { println!( - "Creating repo {name:?} ({storage_label} storage) — estimated cost {}", + "Creating {slug} on {} ({storage_label} storage)\n repo + maintainer + config {}", + ctx.network_label(), cost_line(REPO_CREATE_ESTIMATE_CREDITS, price) ); } - if !ctx.confirm(&format!( - "Create repo {name:?}? This instantiates a contract (~{})", - cost_line(REPO_CREATE_ESTIMATE_CREDITS, price) - ))? { + if !ctx.confirm(&format!("Create {slug}?"))? { return Err(crate::errors::cancelled()); } let (client, bridge, identity) = ctx.connect_with_identity().await?; - let svc = RepoService::new(&client, &identity, &bridge); - let opts = CreateRepoOpts { - default_branch: "main".to_string(), - backend_mode, - description: description.to_string(), - template_version: 1, - }; - let result = svc.create_repo(name, &opts).await.context("create_repo")?; - let credits = result.repo_v1_instantiation_cost_credits; + let result = create_repo(&client, &identity, &bridge, opts, &default_journal_dir()?) + .await + .context("creating the repository")?; + let repo = &result.repo; + let credits = result.cost_credits; + let steps: serde_json::Map<_, _> = result + .steps + .iter() + .map(|(name, o)| ((*name).to_string(), json!(o))) + .collect(); ctx.emit( json!({ - "status": "created", - "repoContractId": result.handle.repo_contract_id, - "ownerId": result.handle.owner_id, - "name": result.handle.name, - "normalizedName": result.handle.normalized_name, - "listingDocumentId": result.listing_document_id, + "status": if result.already_existed() { "exists" } else { "created" }, + "generation": "v2", + "repoId": repo.id(), + "ownerId": repo.owner_id(), + "name": repo.name(), "storage": storage_label, - "remoteUrl": format!("dash://{}/{}", result.handle.owner_id, result.handle.normalized_name), + "remoteUrl": repo.remote_url(), + "steps": steps, + "network": ctx.network_label(), "cost": cost_json(credits, price), }), || { - println!("Created {}/{}", result.handle.owner_id, result.handle.normalized_name); - println!(" contract: {}", result.handle.repo_contract_id); - println!( - " remote: dash://{}/{}", - result.handle.owner_id, result.handle.normalized_name - ); - if credits == 0 { - println!(" cost: 0 (repo already existed — idempotent, no double-pay)"); + if result.already_existed() { + println!("{} already exists; nothing was written.", repo.display()); } else { - println!(" cost: {}", cost_line(credits, price)); + println!("✓ created {}", repo.display()); + for (name, o) in &result.steps { + if *o == StepOutcome::Resumed { + println!(" {name}: finished an interrupted create (not paid twice)"); + } + } } + println!(" repo id: {}", repo.id()); + println!(" remote: {}", repo.remote_url()); + println!(" cost: {}", cost_line(credits, price)); }, ); Ok(()) @@ -111,43 +131,50 @@ fn clone(ctx: &Ctx, repo: &str) -> Result<()> { Ok(()) } -/// Fork — not yet wired (needs the fork-contract + copied-refs pipeline, PRD 02 §B). +/// Fork — not yet wired (`repo.forkOf` + copied refs; the collab PR). /// -/// Fails rather than returning success. It used to print a TODO and exit 0, which made -/// `dg repo fork X && dg pr create ...` proceed as though a fork existed, and left the -/// contributor half of the PR flow with an entry point that silently did nothing. +/// Fails rather than returning success, so `dg repo fork X && dg pr create ...` does not +/// proceed as though a fork existed. #[allow(clippy::unnecessary_wraps)] fn fork(_ctx: &Ctx, repo: &str) -> Result<()> { Err(crate::errors::reported( - UserError::new(codes::NOT_IMPLEMENTED, "dg repo fork is not implemented yet") - .cause("forking needs the fork-contract + copied-refs pipeline (PRD 02 §B)") - .fix("`dg repo create ` — mints your own repo contract (not cheap: see `dg repo create --help`)") - .fix("`git push dash:/// `") - .fix(format!( - "`dg pr create {repo} --title --source-contract --head-oid `" - )), + UserError::new( + codes::NOT_IMPLEMENTED, + "dg repo fork is not implemented yet", + ) + .cause("forking needs `repo.forkOf` and copied refs (the collaboration release)") + .fix("`dg repo create ` (a forge-v2 repo costs about 0.001 DASH)") + .fix(format!( + "`git push dash:/// `, then point reviewers of {repo} at it" + )), json!({ "status": "not_implemented", "repo": repo, - "workaround": "dg repo create , push your branch to it, then dg pr create --source-contract ", + "workaround": "dg repo create , then push your branch to it", }), )) } -/// View a repo: resolved refs, default branch, pack manifests, collaborator count. +/// View a repo: resolved refs, default branch, pack manifests, members. async fn view(ctx: &Ctx, repo: &str) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = RepoService::new(&client, &identity, &bridge); let default_branch = svc.read_default_branch(&handle).await.unwrap_or(None); let refs = svc.read_refs(&handle).await.unwrap_or_default(); let manifests = svc.read_pack_manifests(&handle).await.unwrap_or_default(); - let collaborators = TokenService::new(&client, &identity, &bridge) - .list_collaborators(&handle.repo_contract_id) - .await - .map_or(0, |c| c.len()); + let members = match handle.v1_contract_id() { + Ok(contract) => TokenService::new(&client) + .list_collaborators(contract) + .await + .map_or(0, |c| c.len()), + Err(_) => MemberReader::new(&client) + .list(&handle) + .await + .map_or(0, |m| m.len()), + }; let refs_json: Vec<_> = refs .iter() @@ -159,20 +186,26 @@ async fn view(ctx: &Ctx, repo: &str) -> Result<()> { ctx.emit( json!({ - "repoContractId": handle.repo_contract_id, - "ownerId": handle.owner_id, - "name": handle.name, - "normalizedName": handle.normalized_name, + "generation": handle.generation(), + "repoId": handle.id(), + "ownerId": handle.owner_id(), + "name": handle.name(), + "readOnly": handle.is_v1(), "defaultBranch": default_branch, "refs": refs_json, "packCount": manifests.len(), "packBytes": total_bytes, - "collaborators": collaborators, - "remoteUrl": format!("dash://{}/{}", handle.owner_id, handle.normalized_name), + "members": members, + "remoteUrl": handle.remote_url(), }), || { - println!("{}/{}", handle.owner_id, handle.normalized_name); - println!(" contract: {}", handle.repo_contract_id); + println!("{}", handle.display()); + println!( + " generation: {}{}", + handle.generation(), + if handle.is_v1() { " (read only)" } else { "" } + ); + println!(" id: {}", handle.id()); println!( " default branch: {}", default_branch.clone().unwrap_or_else(|| "(none)".into()) @@ -185,11 +218,8 @@ async fn view(ctx: &Ctx, repo: &str) -> Result<()> { " packs: {} ({total_bytes} bytes)", manifests.len() ); - println!(" collaborators: {collaborators}"); - println!( - " remote: dash://{}/{}", - handle.owner_id, handle.normalized_name - ); + println!(" members: {members}"); + println!(" remote: {}", handle.remote_url()); }, ); Ok(()) @@ -205,56 +235,34 @@ fn ref_state_short(state: &forge_core::rules::RefState) -> String { } } -/// List an owner's repositories from the registry `repoListing` index. +/// List an owner's repositories: forge-v2 repos, then v1 registry listings. async fn list(ctx: &Ctx, owner: Option<&str>) -> Result<()> { let (client, _bridge, identity) = ctx.connect_with_identity().await?; let owner_id = owner.map_or_else(|| identity.id(), str::to_string); - let owner_bytes = platform::decode_identifier(&owner_id)?; - - let registry = client.fetch_registry().await?; - // Complete: this prints "the owner's repos", so a 101st repo silently missing from the - // list would be a wrong answer, not a short one. - let docs = client - .query_all_documents( - ®istry, - "repoListing", - &[QueryFilter::eq( - "$ownerId", - FieldValue::identifier(owner_bytes), - )], - // Order by normalizedName to match the registry's `ownerName` - // `($ownerId, normalizedName)` compound index ($createdAt is not indexed here). - &[QueryOrder::asc("normalizedName")], - ) + let repos = list_owned(&client, &owner_id) .await - .context("querying the registry for repoListing docs")?; - - let repos: Vec<_> = docs + .context("listing repositories")?; + let rows: Vec<_> = repos .iter() - .map(|d| { - let repo_contract = d - .field_bytes("repoContractId") - .and_then(|b| <[u8; 32]>::try_from(b).ok()) - .map(platform::encode_identifier); + .map(|r| { json!({ - "name": d.field_str("name"), - "normalizedName": d.field_str("normalizedName"), - "repoContractId": repo_contract, - "description": d.field_str("description"), - "listingId": d.id, + "name": r.repo.name(), + "generation": r.repo.generation(), + "repoId": r.repo.id(), + "description": r.description, }) }) .collect(); - ctx.emit( - json!({ "owner": owner_id, "count": repos.len(), "repos": repos }), + json!({ "owner": owner_id, "count": rows.len(), "repos": rows }), || { - println!("{} repo(s) for {owner_id}:", docs.len()); - for d in &docs { + println!("{} repo(s) for {owner_id}:", repos.len()); + for r in &repos { println!( - " {} ({})", - d.field_str("normalizedName").unwrap_or_default(), - d.field_str("description").unwrap_or_default() + " {} [{}] {}", + r.repo.name(), + r.repo.generation(), + r.description ); } }, @@ -262,136 +270,30 @@ async fn list(ctx: &Ctx, owner: Option<&str>) -> Result<()> { Ok(()) } -/// Delete a repo's deletable storage (chunks + pack manifests → refund) and its registry -/// listing. Shows a refund estimate and prompts unless `--yes`. -async fn delete(ctx: &Ctx, repo: &str) -> Result<()> { - let repo_ref = RepoRef::parse(repo)?; - let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; - let svc = RepoService::new(&client, &identity, &bridge); - - let manifests = svc.read_pack_manifests(&handle).await.unwrap_or_default(); - let total_bytes: u64 = manifests.iter().map(|m| m.size_bytes).sum(); - let refund = prompt_delete_refund(total_bytes); - let price = dash_usd_price(); - - if !ctx.json { - println!( - "Deleting {}/{}: {} pack(s), {total_bytes} bytes — estimated {}", - handle.owner_id, - handle.normalized_name, - manifests.len(), - refund_line(refund, price) - ); - } - if !ctx.confirm(&format!( - "Delete {}/{} storage? (est. {})", - handle.owner_id, - handle.normalized_name, - refund_line(refund, price) - ))? { - return Err(crate::errors::cancelled()); - } - - let mut deleted_chunks = 0usize; - let mut deleted_manifests = 0usize; - for m in &manifests { - if let Ok(n) = svc.delete_chunks(&handle, m.pack_hash).await { - deleted_chunks += n; - } - if svc - .delete_document(&handle.repo_contract_id, "packManifest", &m.document_id) - .await - .is_ok() - { - deleted_manifests += 1; - } - } - - // Best-effort listing removal (makes the repo unresolvable, completing the delete). - let listing_removed = remove_listing(ctx, &client, &bridge, &identity, &handle) - .await - .unwrap_or(false); - - ctx.emit( - json!({ - "status": "deleted", - "repoContractId": handle.repo_contract_id, - "deletedChunks": deleted_chunks, - "deletedManifests": deleted_manifests, - "listingRemoved": listing_removed, - "refundEstimate": cost_json(refund, price), - "note": "the repo contract and its append-only audit docs are permanent by design", - }), - || { - println!("Deleted {deleted_chunks} chunk(s), {deleted_manifests} manifest(s)."); - println!("Listing removed: {listing_removed}"); - println!("Estimated refund: {}", refund_line(refund, price)); - println!("note: the repo contract itself is permanent (Platform contracts cannot be deleted)."); - }, - ); - Ok(()) -} - -/// Find and delete the registry `repoListing` for a repo, returning whether one was -/// removed. Best-effort: a failure does not fail the whole delete. -async fn remove_listing( - _ctx: &Ctx, - client: &forge_core::platform::PlatformClient, - bridge: &forge_core::keystore::BridgeIdentity, - identity: &forge_core::platform::LoadedIdentity, - handle: &forge_core::repo::RepoHandle, -) -> Result { - let registry = client.fetch_registry().await?; - let owner_bytes = platform::decode_identifier(&handle.owner_id)?; - let docs = client - .query_documents( - ®istry, - "repoListing", - &[ - QueryFilter::eq("$ownerId", FieldValue::identifier(owner_bytes)), - QueryFilter::eq( - "normalizedName", - FieldValue::text(handle.normalized_name.clone()), - ), - ], - &[], - 1, - None, - ) - .await?; - let Some(listing) = docs.into_iter().next() else { - return Ok(false); - }; - let svc = RepoService::new(client, identity, bridge); - svc.delete_document(®istry.id(), "repoListing", &listing.id) - .await?; - Ok(true) -} - -/// Set a repo's storage backend mode (appends a new `config` doc; MAINTAIN-gated). +/// Set a repo's storage backend mode (appends a new `config` doc; maintainer-gated). async fn backend_set(ctx: &Ctx, repo: &str, mode: u8, label: &str) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; + handle.require_v2()?; if !ctx.confirm(&format!( - "Set backend of {}/{} to {label}? (a small config write)", - handle.owner_id, handle.normalized_name + "Set backend of {} to {label}? (a small config write)", + handle.display() ))? { return Err(crate::errors::cancelled()); } let svc = RepoService::new(&client, &identity, &bridge); let doc_id = svc - .set_backend_mode(&handle, mode) + .set_backend(&handle, mode, None) .await - .context("set_backend_mode")?; + .context("writing the config")?; ctx.emit( json!({ "status": "backend_set", - "repoContractId": handle.repo_contract_id, + "repoId": handle.id(), "backend": label, "mode": mode, "configDocumentId": doc_id, @@ -399,8 +301,8 @@ async fn backend_set(ctx: &Ctx, repo: &str, mode: u8, label: &str) -> Result<()> }), || { println!( - "Backend of {}/{} set to {label} (config doc {doc_id}).", - handle.owner_id, handle.normalized_name + "Backend of {} set to {label} (config doc {doc_id}).", + handle.display() ); }, ); diff --git a/crates/dg/src/storage.rs b/crates/dg/src/storage.rs index 321791873..d271059a3 100644 --- a/crates/dg/src/storage.rs +++ b/crates/dg/src/storage.rs @@ -699,7 +699,7 @@ async fn advertise(ctx: &Ctx, repo: &str, remote: Option<&str>) -> Result<()> { } let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = forge_core::repo::RepoService::new(&client, &identity, &bridge); let doc = svc .set_backend(&handle, mode, Some(&uris)) @@ -723,29 +723,24 @@ async fn advertise(ctx: &Ctx, repo: &str, remote: Option<&str>) -> Result<()> { async fn status(ctx: &Ctx, repo: &str) -> Result<()> { let repo_ref = RepoRef::parse(repo)?; let (client, bridge, identity) = ctx.connect_with_identity().await?; - let handle = resolve(&client, &identity, &bridge, &repo_ref).await?; + let handle = resolve(&client, &identity, &repo_ref).await?; let svc = forge_core::repo::RepoService::new(&client, &identity, &bridge); let manifests = svc.read_pack_manifests(&handle).await.unwrap_or_default(); - // Fold in any extra availability URIs announced via `packMirror` docs (anyone can - // reseed → announce). Empty on a v1-template contract that lacks the packMirror type. - let mirrors = svc.read_pack_mirrors(&handle).await.unwrap_or_default(); + let scope = handle.scope()?; let reader = PackReader::from_user_config(); let https = forge_core::backends::HttpsBackend::with_client(forge_core::storage::http_client()); let mut packs = Vec::new(); for m in &manifests { - // The manifest's own URIs plus any packMirror-announced URIs for this pack. + // Each manifest is one uploader's copy (on v2 a pack may have several). let mut uris: Vec = m.uris.clone(); - for (mirror_hash, mirror_uris) in &mirrors { - if *mirror_hash == m.pack_hash { - uris.extend(mirror_uris.iter().cloned()); - } - } uris.sort(); uris.dedup(); - let rows = probe_rows(m, &uris, &handle.repo_contract_id, &reader, &https).await; + let locator = scope.locator(&m.owner_id, &hex::encode(m.pack_hash)); + let rows = probe_rows(m, &uris, &locator, &reader, &https).await; packs.push(json!({ "packHash": hex::encode(m.pack_hash), + "uploader": m.owner_id, "kind": m.kind, "sizeBytes": m.size_bytes, "chunkCount": m.chunk_count, @@ -756,16 +751,13 @@ async fn status(ctx: &Ctx, repo: &str) -> Result<()> { ctx.emit( json!({ - "repoContractId": handle.repo_contract_id, + "repoId": handle.id(), "packCount": manifests.len(), "ipfsGateways": reader.gateways(), "packs": packs, }), || { - println!( - "Storage status for {}/{}:", - handle.owner_id, handle.normalized_name - ); + println!("Storage status for {}:", handle.display()); if manifests.is_empty() { println!(" (no packs)"); } @@ -798,7 +790,7 @@ async fn status(ctx: &Ctx, repo: &str) -> Result<()> { async fn probe_rows( m: &forge_core::repo::PackManifestInfo, uris: &[String], - contract_id: &str, + platform_locator: &str, reader: &PackReader, https: &forge_core::backends::HttpsBackend, ) -> Vec { @@ -822,7 +814,7 @@ async fn probe_rows( // Platform tier (storage == 0) means the bytes are on-chain chunk docs. if m.storage == 0 || m.uris.is_empty() { rows.push(json!({ - "uri": format!("platform://{contract_id}/{}", hex::encode(m.pack_hash)), + "uri": platform_locator, "scheme": "platform", "ok": m.chunk_count > 0, "detail": "on-chain chunk documents", diff --git a/crates/forge-core/src/backends.rs b/crates/forge-core/src/backends.rs index f35179aa0..e3950ee59 100644 --- a/crates/forge-core/src/backends.rs +++ b/crates/forge-core/src/backends.rs @@ -43,7 +43,9 @@ mod live_tests; pub use gitmirror::{GitMirrorBackend, GITMIRROR_SCHEME}; pub use https::HttpsBackend; pub use ipfs::IpfsBackend; -pub use platform::{decode_chunk_doc, encode_chunk_doc, PlatformBackend, PLATFORM_SCHEME}; +pub use platform::{ + decode_chunk_doc, encode_chunk_doc, PlatformBackend, PlatformLocator, PLATFORM_SCHEME, +}; pub use s3::{S3Backend, S3Config}; /// A storage location for pack bytes (e.g. `ipfs://`, `s3://…`, `https://…`, diff --git a/crates/forge-core/src/backends/live_tests.rs b/crates/forge-core/src/backends/live_tests.rs index 275f21f82..ba476d49e 100644 --- a/crates/forge-core/src/backends/live_tests.rs +++ b/crates/forge-core/src/backends/live_tests.rs @@ -509,7 +509,12 @@ async fn platform_backend_live_put() { let key = bridge.doc_op_key().unwrap(); let engine = WriteEngine::new(&client, &identity, key).unwrap(); - let backend = PlatformBackend::new(&engine, &contract); + // The S0.1 throwaway contract is its own scope (a v1-shaped chunk type, no `repoId`). + let scope = crate::scope::DocScope { + contract_id: contract_id.clone(), + repo_id: None, + }; + let backend = PlatformBackend::new(&engine, &contract, &scope, identity_id.clone()); // A SMALL payload → a few chunk docs. let data: Vec = (0..20_000u32) .map(|i| u8::try_from(i % 251).unwrap()) diff --git a/crates/forge-core/src/backends/platform.rs b/crates/forge-core/src/backends/platform.rs index 6cfc53f67..b673ae6b3 100644 --- a/crates/forge-core/src/backends/platform.rs +++ b/crates/forge-core/src/backends/platform.rs @@ -7,17 +7,11 @@ //! and a window of [`PIPELINE_WINDOW`] (spike S0.1: ~4 docs/sec landing at window 8). //! Read-back is by `(packHash, seq)` range. //! -//! ## What is live here vs. deferred to M1 -//! -//! - **Write** ([`PlatformBackend::put`]) drives the real [`WriteEngine`] and is exercised -//! by the `#[ignore]`d live test (needs a repo/chunk contract + a funded identity). -//! - **The chunk-document encode/decode** ([`encode_chunk_doc`] / [`decode_chunk_doc`]) is -//! pure and covered by offline round-trip unit tests — it is the load-bearing on-chain -//! byte format. -//! - **Read-back** ([`PlatformBackend::get`]) needs a property-returning `chunk` query by -//! `(packHash, seq)`; that query helper lives in `crate::platform` and lands in M1 (the -//! SDK is confined to that module). Until then `get`/`probe` return a clear pending -//! error, and the reassembly is the pure [`crate::pack::join`] over decoded chunks. +//! Read-back ([`PlatformBackend::get`]) reads one uploader's chunks by +//! `(packHash, seq)` (with `repoId` and the uploader on forge-v2, see +//! [`crate::scope::DocScope::chunk_filters`]) and reassembles them with the pure +//! [`crate::pack::join`]. The chunk encode/decode is covered offline; the write path by the +//! `#[ignore]`d live tests. use std::collections::BTreeMap; @@ -26,7 +20,8 @@ use futures::stream::{self, StreamExt, TryStreamExt}; use super::{ByteRange, Caps, Health, PackBackend, PackMeta, Uri}; use crate::error::{Error, Result}; use crate::pack::{join, split, Chunk, FIELDS_PER_DOC}; -use crate::platform::{FieldValue, LoadedContract, QueryFilter, QueryOrder, WriteEngine}; +use crate::platform::{FieldValue, LoadedContract, QueryOrder, WriteEngine}; +use crate::scope::DocScope; /// The platform scheme label used in manifest URIs. pub const PLATFORM_SCHEME: &str = "platform"; @@ -38,10 +33,6 @@ pub const CHUNK_DOC_TYPE: &str = "chunk"; /// ~4 docs/sec landing; look-ahead caps ~24). pub const PIPELINE_WINDOW: usize = 8; -/// Page size for the chunk read-back scan. Platform caps a document query at ~100 rows, -/// so a pack with more chunks is paged via a `seq`-ordered `start_after` cursor. -const CHUNK_PAGE_LIMIT: u32 = 100; - /// The document field carrying a chunk's packHash (32-byte `byteArray`). pub const FIELD_PACK_HASH: &str = "packHash"; /// The document field carrying a chunk's zero-based sequence. @@ -112,72 +103,90 @@ pub fn chunk_documents( .collect() } -/// The Platform storage backend, bound to a [`WriteEngine`] + the repo/chunk contract it -/// writes into. +/// A parsed `platform://` locator: whose chunks, and which pack. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformLocator { + /// The uploader whose copy the chunks are. forge-v2 locators name it (a v2 chunk's key + /// includes `$ownerId`); v1 ones do not, the repo contract being the whole scope. + pub owner: Option, + /// The pack hash. + pub pack_hash: [u8; 32], +} + +impl PlatformLocator { + /// Parse `platform:///` (v1) or + /// `platform://///` (v2). + pub fn parse(uri: &Uri) -> Result { + let rest = uri + .rest() + .filter(|_| uri.scheme() == Some(PLATFORM_SCHEME)) + .ok_or_else(|| Error::Config(format!("not a platform locator: {uri}")))?; + let parts: Vec<&str> = rest.split('/').collect(); + let (owner, hex_hash) = match parts.as_slice() { + [_contract, hash] => (None, *hash), + [_core, _repo, owner, hash] => (Some((*owner).to_string()), *hash), + _ => return Err(Error::Config(format!("malformed platform locator: {uri}"))), + }; + let raw = hex::decode(hex_hash) + .map_err(|e| Error::Config(format!("platform locator packHash not hex: {e}")))?; + let pack_hash = raw + .try_into() + .map_err(|_| Error::Config("platform locator packHash is not 32 bytes".into()))?; + Ok(Self { owner, pack_hash }) + } +} + +/// The Platform storage backend, bound to a [`WriteEngine`], the contract it writes into and +/// the repository scope inside it. /// /// Holds borrows (the engine borrows a `PlatformClient`, a keystore key and an identity), /// so it is constructed per-push rather than stored long-lived or boxed `'static`. pub struct PlatformBackend<'a> { engine: &'a WriteEngine<'a>, contract: &'a LoadedContract, + scope: &'a DocScope, + /// The identity the engine writes as (a v2 chunk's key includes its uploader). + writer: String, } impl<'a> PlatformBackend<'a> { - /// Bind a backend to `engine` writing `chunk` docs into `contract`. - pub fn new(engine: &'a WriteEngine<'a>, contract: &'a LoadedContract) -> Self { - Self { engine, contract } + /// Bind a backend to `engine` (writing as `writer`) for `scope`'s chunks in `contract`. + pub fn new( + engine: &'a WriteEngine<'a>, + contract: &'a LoadedContract, + scope: &'a DocScope, + writer: impl Into, + ) -> Self { + Self { + engine, + contract, + scope, + writer: writer.into(), + } } - /// The `platform:///` locator for a stored pack. + /// The locator of a pack this backend stores. fn locator_uri(&self, pack_hash: &str) -> Uri { - Uri(format!( - "{PLATFORM_SCHEME}://{}/{}", - self.contract.id(), - pack_hash - )) + Uri(self.scope.locator(&self.writer, pack_hash)) } - /// The 32-byte packHash from a `platform:///` locator. - fn pack_hash_from_uri(uri: &Uri) -> Result<[u8; 32]> { - let rest = uri - .rest() - .ok_or_else(|| Error::Config(format!("not a platform locator: {uri}")))?; - let hex_hash = rest.rsplit_once('/').map_or(rest, |(_, h)| h); - let raw = hex::decode(hex_hash) - .map_err(|e| Error::Config(format!("platform locator packHash not hex: {e}")))?; - raw.try_into() - .map_err(|_| Error::Config("platform locator packHash is not 32 bytes".into())) - } - - /// Read every `chunk` document for `pack_hash`, ordered by `seq`, paging through the - /// ~100-row query cap with a `start_after` cursor. Decodes each to a [`Chunk`]. - async fn read_chunks(&self, pack_hash: [u8; 32]) -> Result> { - let client = self.engine.client(); - let mut chunks: Vec = Vec::new(); - let mut start_after: Option = None; - loop { - let page = client - .query_documents( - self.contract, - CHUNK_DOC_TYPE, - &[QueryFilter::eq( - FIELD_PACK_HASH, - FieldValue::bytes32(pack_hash), - )], - &[QueryOrder::asc(FIELD_SEQ)], - CHUNK_PAGE_LIMIT, - start_after.as_deref(), - ) - .await?; - let page_len = page.len(); - for doc in &page { - chunks.push(decode_chunk_doc(&doc.fields)?); - } - start_after = page.last().map(|d| d.id.clone()); - if page_len < CHUNK_PAGE_LIMIT as usize { - break; - } - } + /// Read every `chunk` document of `owner`'s copy of `pack_hash` (complete, `seq` + /// ordered) and decode each to a [`Chunk`]. `owner` is required on forge-v2. + async fn read_chunks(&self, owner: Option<&str>, pack_hash: [u8; 32]) -> Result> { + let docs = self + .engine + .client() + .query_all_documents( + self.contract, + CHUNK_DOC_TYPE, + &self.scope.chunk_filters(owner, pack_hash)?, + &[QueryOrder::asc(FIELD_SEQ)], + ) + .await?; + let mut chunks = docs + .iter() + .map(|d| decode_chunk_doc(&d.fields)) + .collect::>>()?; // The (packHash, seq) index already returns seq-ordered, but sort defensively so // reassembly never depends on traversal order. chunks.sort_by_key(|c| c.seq); @@ -206,7 +215,7 @@ impl PackBackend for PlatformBackend<'_> { } fn caps(&self) -> Caps { - // On-chain: CLI write (holds the WRITE token + signing key); reads available to + // On-chain: CLI write (a writer/maintainer membership + signing key); reads available to // CLI and browser via DAPI. Browser writes need the identity's key — CLI-shaped. Caps { read_cli: true, @@ -225,7 +234,7 @@ impl PackBackend for PlatformBackend<'_> { // engine's sequential-nonce + idempotent re-broadcast handles landing order. stream::iter(docs.into_iter().map(|(_seq, props)| { self.engine - .create_document(self.contract, CHUNK_DOC_TYPE, props) + .create_document(self.contract, CHUNK_DOC_TYPE, self.scope.scoped(props)) })) .buffered(PIPELINE_WINDOW) .try_collect::>() @@ -239,8 +248,10 @@ impl PackBackend for PlatformBackend<'_> { // the pure `crate::pack::join`. A ranged read slices the reassembled bytes — the // platform tier serves whole chunks, so partial fetch is a post-join slice (the // browse plane's per-object ranged reads run over the locator, not raw chunks). - let pack_hash = Self::pack_hash_from_uri(uri)?; - let chunks = self.read_chunks(pack_hash).await?; + let loc = PlatformLocator::parse(uri)?; + let chunks = self + .read_chunks(loc.owner.as_deref(), loc.pack_hash) + .await?; if chunks.is_empty() { return Err(Error::NotFound); } @@ -265,7 +276,7 @@ impl PackBackend for PlatformBackend<'_> { // A cheap presence check: seek the first chunk (`limit 1`) for the pack. `ok` is // whether any chunk is stored; size is left unknown (a whole-pack size would // require reading every chunk — that's `get`'s job, not a probe's). - let pack_hash = Self::pack_hash_from_uri(uri)?; + let loc = PlatformLocator::parse(uri)?; let started = std::time::Instant::now(); let page = self .engine @@ -273,10 +284,9 @@ impl PackBackend for PlatformBackend<'_> { .query_documents( self.contract, CHUNK_DOC_TYPE, - &[QueryFilter::eq( - FIELD_PACK_HASH, - FieldValue::bytes32(pack_hash), - )], + &self + .scope + .chunk_filters(loc.owner.as_deref(), loc.pack_hash)?, &[QueryOrder::asc(FIELD_SEQ)], 1, None, @@ -374,4 +384,16 @@ mod tests { .collect(); assert_eq!(join(&rebuilt), data); } + + #[test] + fn locators_parse_in_both_generations() { + let h = "cd".repeat(32); + let v1 = PlatformLocator::parse(&Uri(format!("platform://C/{h}"))).unwrap(); + assert_eq!(v1.owner, None); + assert_eq!(v1.pack_hash, [0xcd; 32]); + let v2 = PlatformLocator::parse(&Uri(format!("platform://C/R/OWNER/{h}"))).unwrap(); + assert_eq!(v2.owner.as_deref(), Some("OWNER")); + assert!(PlatformLocator::parse(&Uri(format!("platform://C/R/{h}"))).is_err()); + assert!(PlatformLocator::parse(&Uri(format!("https://C/{h}"))).is_err()); + } } diff --git a/crates/forge-core/src/collab.rs b/crates/forge-core/src/collab.rs index 063ed4ef6..f33f7dfec 100644 --- a/crates/forge-core/src/collab.rs +++ b/crates/forge-core/src/collab.rs @@ -516,7 +516,7 @@ impl<'a> IssueService<'a> { /// Build the as-of-time authorization resolver from the repo's token history. async fn authz(&self, repo_contract_id: &str) -> Result { - let records = TokenService::new(self.client, self.identity, self.bridge) + let records = TokenService::new(self.client) .token_history(repo_contract_id) .await?; Ok(AuthzResolver::new(records)) @@ -905,7 +905,7 @@ impl<'a> PullRequestService<'a> { }; let contract = self.client.fetch_contract(repo_contract_id).await?; let events = fetch_events(self.client, &contract, &pr.document_id).await?; - let records = TokenService::new(self.client, self.identity, self.bridge) + let records = TokenService::new(self.client) .token_history(repo_contract_id) .await?; let authz = AuthzResolver::new(records); @@ -934,7 +934,13 @@ impl<'a> PullRequestService<'a> { base_ref_name: &str, ) -> Result<(std::collections::BTreeSet, Option)> { let ref_name_hash = sha256(base_ref_name.as_bytes()); - let updates = crate::refs::read_ref_history(self.client, contract, ref_name_hash).await?; + // Issues and PRs still live in v1 repo contracts: the contract is the whole scope. + let scope = crate::scope::DocScope { + contract_id: contract.id(), + repo_id: None, + }; + let updates = + crate::refs::read_ref_history(self.client, contract, &scope, ref_name_hash).await?; let mut tips: std::collections::BTreeSet = std::collections::BTreeSet::new(); let mut newest: Option<(u64, String, String)> = None; // (created_at, id, oid) for u in updates { @@ -1067,13 +1073,11 @@ fn pr_from_doc(d: &platform::FetchedDocument) -> PullRequest { // `sourceContractId` / `sourceListingId` are identifier byteArrays; base58 is the // form every Platform contract/document API takes. source_contract_id: d - .field_bytes("sourceContractId") - .and_then(|b| <[u8; 32]>::try_from(b).ok()) + .field_bytes32("sourceContractId") .map(platform::encode_identifier) .unwrap_or_default(), source_listing_id: d - .field_bytes("sourceListingId") - .and_then(|b| <[u8; 32]>::try_from(b).ok()) + .field_bytes32("sourceListingId") .map(platform::encode_identifier), source_ref_name: d.field_str("sourceRefName"), patch_manifest_hash: d.field_hex("patchManifestHash"), diff --git a/crates/forge-core/src/create.rs b/crates/forge-core/src/create.rs new file mode 100644 index 000000000..063bcf9ef --- /dev/null +++ b/crates/forge-core/src/create.rs @@ -0,0 +1,568 @@ +//! Creating a forge-v2 repository: one journaled, resumable session. +//! +//! A new repository is three documents in the network's forge-core contract, written in +//! this order because each one's consensus gate needs the one before it: +//! +//! 1. `repo` — the name slug, display name, description, default branch and visibility +//! (anyone may create one; `(owner, name)` is unique). +//! 2. the owner's own `maintainer` document — only the repo's owner may create it, and +//! without it the owner could not write the M-gated `config` (or push to a protected +//! ref). This is the v2 form of v1's "the owner is credited both tokens at creation". +//! 3. the initial `config` — default branch and storage backend. +//! +//! **Resumable, never double-paying.** Before each document is broadcast, its signed +//! transition is saved to a journal (`$XDG_STATE_HOME/dash-forge/journals/`). A session +//! that dies anywhere — before a broadcast, mid-broadcast, between steps — is resumed by +//! running the same create again: a step with a saved transition re-broadcasts those exact +//! bytes (which land at most once) and is then confirmed by fetching the document; a step +//! without one first checks whether its document already exists. Only a step that provably +//! has no document and no pending transition signs a new one. The journal is deleted once +//! all three documents exist. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; +use crate::keystore::BridgeIdentity; +use crate::members::{doc_type, MemberReader}; +use crate::network::ForgeIds; +use crate::platform::{ + self, FieldValue, LoadedContract, LoadedIdentity, PlatformClient, QueryFilter, WriteEngine, + WriteIntent, +}; +use crate::resolve::{find_v2, repo_slug, DOC_REPO}; +use crate::rules::v2::{Role, Visibility}; +use crate::scope::RepoRef; + +/// The initial `config` document type. +const DOC_CONFIG: &str = "config"; + +/// What to create. +#[derive(Debug, Clone)] +pub struct CreateRepoOpts { + /// The URL slug (normalized: ASCII letters are lower-cased). + pub name: String, + /// The display name (`repo.displayName`); empty = none. + pub display_name: String, + /// The description (`repo.description`); empty = none. + pub description: String, + /// The default branch (`repo.defaultBranch` and `config.defaultBranch`), e.g. `main`. + pub default_branch: String, + /// `config.backend.mode` (`0` platform, `1` ipfs, `2` s3, `3` https, `4` mixed). + pub backend_mode: u8, + /// The visibility. Only public is supported until the private-repo release. + pub visibility: Visibility, +} + +impl CreateRepoOpts { + /// A public repository named `name` on the Platform backend, default branch `main`. + pub fn public(name: impl Into) -> Self { + Self { + name: name.into(), + display_name: String::new(), + description: String::new(), + default_branch: "main".into(), + backend_mode: 0, + visibility: Visibility::Public, + } + } +} + +/// How one step of the session ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum StepOutcome { + /// Written by this run. + Created, + /// A transition saved by an interrupted run was re-broadcast and confirmed. + Resumed, + /// The document already existed; nothing was written. + Existed, +} + +/// The result of [`create_repo`]. +#[derive(Debug, Clone)] +pub struct CreateRepoResult { + /// The repository. + pub repo: RepoRef, + /// `repo`, `maintainer` and `config`, in order, with how each ended. + pub steps: Vec<(&'static str, StepOutcome)>, + /// The owner's balance change across the session, in credits (what it cost). + pub cost_credits: u64, +} + +impl CreateRepoResult { + /// Whether every document already existed (a re-run of a finished create). + pub fn already_existed(&self) -> bool { + self.steps.iter().all(|(_, o)| *o == StepOutcome::Existed) + } +} + +/// The on-disk session record. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateJournal { + /// The forge-core contract the session writes into. + core: String, + /// The owner identity. + owner: String, + /// The repo slug. + name: String, + /// The saved `repo` create, once signed. + #[serde(default)] + repo: Option, + /// The saved owner `maintainer` create, once signed. + #[serde(default)] + maintainer: Option, + /// The saved initial `config` create, once signed. + #[serde(default)] + config: Option, +} + +/// The directory create journals live in: `$XDG_STATE_HOME/dash-forge/journals`, else +/// `~/.local/state/dash-forge/journals`. +pub fn default_journal_dir() -> Result { + if let Some(state) = std::env::var_os("XDG_STATE_HOME").filter(|s| !s.is_empty()) { + return Ok(PathBuf::from(state).join("dash-forge/journals")); + } + let home = std::env::var_os("HOME").ok_or_else(|| { + Error::Config("HOME is not set; cannot locate the journal directory".into()) + })?; + Ok(PathBuf::from(home).join(".local/state/dash-forge/journals")) +} + +/// The journal file of one create session. +fn journal_path(dir: &Path, network: &str, owner: &str, name: &str) -> PathBuf { + dir.join(format!("create-{network}-{owner}-{name}.json")) +} + +struct Journal { + path: PathBuf, + state: CreateJournal, +} + +impl Journal { + /// Load the session's journal, or start one. A journal for another contract (a + /// re-registered forge-core) is ignored: its transitions target a contract that is not + /// this network's forge any more. + fn open(path: PathBuf, core: &str, owner: &str, name: &str) -> Self { + let fresh = CreateJournal { + core: core.into(), + owner: owner.into(), + name: name.into(), + ..CreateJournal::default() + }; + let state = std::fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .filter(|j| j.core == core && j.owner == owner && j.name == name) + .unwrap_or(fresh); + Self { path, state } + } + + fn save(&self) -> Result<()> { + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir).map_err(|e| Error::Io(e.to_string()))?; + } + let tmp = self.path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(&self.state)?) + .map_err(|e| Error::Io(e.to_string()))?; + std::fs::rename(&tmp, &self.path).map_err(|e| Error::Io(e.to_string())) + } + + fn slot(&mut self, step: Step) -> &mut Option { + match step { + Step::Repo => &mut self.state.repo, + Step::Maintainer => &mut self.state.maintainer, + Step::Config => &mut self.state.config, + } + } + + fn finish(self) { + let _ = std::fs::remove_file(&self.path); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Step { + Repo, + Maintainer, + Config, +} + +impl Step { + fn doc_type(self) -> &'static str { + match self { + Step::Repo => DOC_REPO, + Step::Maintainer => doc_type(Role::Maintainer), + Step::Config => DOC_CONFIG, + } + } +} + +/// The `repo` document's properties. +fn repo_props(opts: &CreateRepoOpts) -> BTreeMap { + let mut p = BTreeMap::new(); + p.insert("name".into(), FieldValue::text(&opts.name)); + p.insert("visibility".into(), FieldValue::text("public")); + p.insert( + "defaultBranch".into(), + FieldValue::text(&opts.default_branch), + ); + if !opts.description.is_empty() { + p.insert("description".into(), FieldValue::text(&opts.description)); + } + if !opts.display_name.is_empty() { + p.insert("displayName".into(), FieldValue::text(&opts.display_name)); + } + p +} + +/// The initial `config` document's properties (no protected patterns: an empty list is the +/// same as none, and omitting it keeps the document small). +fn config_props(repo_id: [u8; 32], opts: &CreateRepoOpts) -> BTreeMap { + let mut backend = BTreeMap::new(); + backend.insert( + "mode".into(), + FieldValue::integer(u64::from(opts.backend_mode)), + ); + let mut p = BTreeMap::new(); + p.insert("repoId".into(), FieldValue::identifier(repo_id)); + p.insert( + "defaultBranch".into(), + FieldValue::text(&opts.default_branch), + ); + p.insert("backend".into(), FieldValue::Object(backend)); + p.insert("archived".into(), FieldValue::boolean(false)); + p +} + +/// Re-broadcast a saved transition and confirm its document exists. `false` means the +/// transition never landed and never will (its nonce went to another write): the caller +/// discards it and decides afresh. +async fn replay_confirmed( + client: &PlatformClient, + engine: &WriteEngine<'_>, + core: &LoadedContract, + step: Step, + intent: &WriteIntent, +) -> Result { + engine.replay(step.doc_type(), intent).await?; + // A proved read right after the landing proof can lag by a block; poll briefly. + for attempt in 0..6 { + if client + .document_exists(core, step.doc_type(), &intent.document_id) + .await? + { + return Ok(true); + } + if attempt < 5 { + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + } + } + Ok(false) +} + +/// Create (or finish creating) the repository `opts` describes, owned by `identity`. +/// +/// Idempotent: re-running a create that finished returns the existing repository with every +/// step [`StepOutcome::Existed`] and a cost of zero. `journal_dir` is where the session +/// record lives ([`default_journal_dir`] for the CLI). +pub async fn create_repo( + client: &PlatformClient, + identity: &LoadedIdentity, + bridge: &BridgeIdentity, + opts: &CreateRepoOpts, + journal_dir: &Path, +) -> Result { + let target = client.target(); + let forge: ForgeIds = target.v2.clone().ok_or_else(|| Error::V2NotDeployed { + network: target.network.key(), + })?; + let opts = validated(opts)?; + let owner = identity.id(); + let owner_bytes = platform::decode_identifier(&owner)?; + let core = client.fetch_contract(&forge.core).await?; + let engine = WriteEngine::new(client, identity, bridge.doc_op_key()?)?; + let mut journal = Journal::open( + journal_path(journal_dir, &target.network.key(), &owner, &opts.name), + &forge.core, + &owner, + &opts.name, + ); + let balance_before = client.get_balance(&owner).await?; + let mut steps = Vec::with_capacity(3); + + // 1. repo + let (repo_doc_id, outcome) = run_step( + client, + &engine, + &core, + &mut journal, + Step::Repo, + || async { + Ok(find_v2(client, &forge, owner_bytes, &opts.name) + .await? + .map(|r| r.id().to_string())) + }, + || repo_props(&opts), + ) + .await?; + steps.push(("repo", outcome)); + let repo = + find_repo_after_create(client, &forge, owner_bytes, &opts.name, &repo_doc_id).await?; + let repo_id = platform::decode_identifier(repo.id())?; + + // 2. the owner's maintainer document + let (_, outcome) = run_step( + client, + &engine, + &core, + &mut journal, + Step::Maintainer, + || async { + Ok(MemberReader::new(client) + .roles_of(&repo, &owner) + .await? + .into_iter() + .find(|m| m.role == Role::Maintainer) + .map(|m| m.document_id)) + }, + || { + repo.scope() + .map(|s| s.props([("memberId", FieldValue::identifier(owner_bytes))])) + .unwrap_or_default() + }, + ) + .await?; + steps.push(("maintainer", outcome)); + + // 3. the initial config + let (_, outcome) = run_step( + client, + &engine, + &core, + &mut journal, + Step::Config, + || async { + Ok(client + .query_documents( + &core, + DOC_CONFIG, + &[QueryFilter::eq("repoId", FieldValue::identifier(repo_id))], + &[], + 1, + None, + ) + .await? + .into_iter() + .next() + .map(|d| d.id)) + }, + || config_props(repo_id, &opts), + ) + .await?; + steps.push(("config", outcome)); + + journal.finish(); + let balance_after = client.get_balance(&owner).await.unwrap_or(balance_before); + Ok(CreateRepoResult { + repo, + steps, + cost_credits: balance_before.saturating_sub(balance_after), + }) +} + +/// `opts` with the name normalized to its slug, or why it cannot be created. +fn validated(opts: &CreateRepoOpts) -> Result { + if opts.visibility == Visibility::Private { + return Err(Error::Config( + "private repositories are not supported by this version yet; create a public \ + repository" + .into(), + )); + } + if !crate::rules::is_legal_ref_name(&format!("refs/heads/{}", opts.default_branch)) { + return Err(Error::Config(format!( + "invalid default branch {:?}", + opts.default_branch + ))); + } + let mut opts = opts.clone(); + opts.name = repo_slug(&opts.name)?; + Ok(opts) +} + +/// One step: replay a saved transition, else adopt an existing document, else sign, save +/// and broadcast a new one. Returns the document id and how the step ended. +async fn run_step( + client: &PlatformClient, + engine: &WriteEngine<'_>, + core: &LoadedContract, + journal: &mut Journal, + step: Step, + existing: E, + props: P, +) -> Result<(String, StepOutcome)> +where + E: FnOnce() -> EFut, + EFut: std::future::Future>>, + P: FnOnce() -> BTreeMap, +{ + if let Some(intent) = journal.slot(step).clone() { + if replay_confirmed(client, engine, core, step, &intent).await? { + return Ok((intent.document_id, StepOutcome::Resumed)); + } + tracing::warn!( + step = step.doc_type(), + document = %intent.document_id, + "a saved transition from an interrupted create never landed; discarding it" + ); + *journal.slot(step) = None; + journal.save()?; + } + if let Some(id) = existing().await? { + return Ok((id, StepOutcome::Existed)); + } + let prepared = engine + .create_journaled(core, step.doc_type(), props(), |p| { + *journal.slot(step) = Some(WriteIntent::for_prepared(0, p)); + journal.save() + }) + .await?; + Ok((prepared.document_id().to_string(), StepOutcome::Created)) +} + +/// The repository just created (or adopted), read back through the `(owner, name)` index so +/// the caller holds exactly what every other client resolves. Polls briefly for the proved +/// read to catch up with the write. +async fn find_repo_after_create( + client: &PlatformClient, + forge: &ForgeIds, + owner: [u8; 32], + name: &str, + expected_id: &str, +) -> Result { + for attempt in 0..6 { + if let Some(repo) = find_v2(client, forge, owner, name).await? { + if repo.id() != expected_id { + return Err(Error::Platform(format!( + "repo {name} resolves to {} but this session wrote {expected_id}", + repo.id() + ))); + } + return Ok(repo); + } + if attempt < 5 { + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + } + } + Err(Error::Platform(format!( + "repo {expected_id} landed but is not yet readable through the (owner, name) index; \ + run the create again to finish it" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn intent(id: &str) -> WriteIntent { + WriteIntent { + seq: 0, + document_id: id.into(), + operation: platform::WriteOp::Create, + transition: platform::SignedTransition { + bytes: vec![1, 2, 3], + nonce: 7, + }, + } + } + + #[test] + fn a_journal_round_trips_and_resumes_the_same_intents() { + let dir = tempfile::tempdir().unwrap(); + let path = journal_path(dir.path(), "devnet-moutai", "OWNER", "proj"); + let mut j = Journal::open(path.clone(), "CORE", "OWNER", "proj"); + assert!(j.state.repo.is_none()); + *j.slot(Step::Repo) = Some(intent("R1")); + *j.slot(Step::Maintainer) = Some(intent("M1")); + j.save().unwrap(); + + let again = Journal::open(path.clone(), "CORE", "OWNER", "proj"); + assert_eq!(again.state.repo.as_ref().unwrap().document_id, "R1"); + assert_eq!(again.state.maintainer.as_ref().unwrap().document_id, "M1"); + assert!(again.state.config.is_none()); + assert_eq!( + again.state.repo.as_ref().unwrap().transition.bytes, + vec![1, 2, 3] + ); + + again.finish(); + assert!(!path.exists(), "a finished session removes its journal"); + } + + #[test] + fn a_journal_for_another_forge_or_repo_is_not_resumed() { + let dir = tempfile::tempdir().unwrap(); + let path = journal_path(dir.path(), "devnet-moutai", "OWNER", "proj"); + let mut j = Journal::open(path.clone(), "CORE", "OWNER", "proj"); + *j.slot(Step::Repo) = Some(intent("R1")); + j.save().unwrap(); + // forge-core re-registered: the saved transition targets a dead contract. + assert!(Journal::open(path.clone(), "CORE2", "OWNER", "proj") + .state + .repo + .is_none()); + // A corrupt file is a fresh session, not an error. + std::fs::write(&path, b"{not json").unwrap(); + assert!(Journal::open(path, "CORE", "OWNER", "proj") + .state + .repo + .is_none()); + } + + #[test] + fn journals_are_per_network_owner_and_name() { + let d = Path::new("/j"); + assert_ne!( + journal_path(d, "devnet-moutai", "A", "x"), + journal_path(d, "testnet", "A", "x") + ); + assert_ne!( + journal_path(d, "n", "A", "x"), + journal_path(d, "n", "B", "x") + ); + assert_ne!( + journal_path(d, "n", "A", "x"), + journal_path(d, "n", "A", "y") + ); + } + + #[test] + fn documents_are_public_and_carry_only_set_fields() { + let mut opts = CreateRepoOpts::public("proj"); + let p = repo_props(&opts); + assert_eq!(p.get("visibility"), Some(&FieldValue::text("public"))); + assert_eq!(p.get("defaultBranch"), Some(&FieldValue::text("main"))); + assert!(!p.contains_key("description") && !p.contains_key("displayName")); + opts.description = "d".into(); + opts.display_name = "Proj".into(); + let p = repo_props(&opts); + assert!(p.contains_key("description") && p.contains_key("displayName")); + + let c = config_props([9; 32], &opts); + assert_eq!(c.get("repoId"), Some(&FieldValue::identifier([9; 32]))); + assert!(!c.contains_key("protectedPatterns")); + assert!(matches!(c.get("backend"), Some(FieldValue::Object(b)) if b.contains_key("mode"))); + } + + #[test] + fn step_document_types_match_the_contract() { + assert_eq!(Step::Repo.doc_type(), "repo"); + assert_eq!(Step::Maintainer.doc_type(), "maintainer"); + assert_eq!(Step::Config.doc_type(), "config"); + } +} diff --git a/crates/forge-core/src/error.rs b/crates/forge-core/src/error.rs index 1a464e30f..43eb14be1 100644 --- a/crates/forge-core/src/error.rs +++ b/crates/forge-core/src/error.rs @@ -107,6 +107,33 @@ pub enum Error { network: String, }, + /// The selected network has no forge-v2 deployment (no fully registered `v2` record in + /// its deployment file). v2 writes and v2 lookups fail with this; v1 reads still work. + #[error( + "forge-v2 isn't deployed on {network} yet; use --network devnet --devnet-name moutai \ + (existing v1 repos there stay readable)" + )] + V2NotDeployed { + /// The network key (`testnet`, `mainnet`). + network: String, + }, + + /// A write was attempted on a forge-v1 repository. v1 (one contract per repo) is read + /// only now; new writes go to forge-v2. + #[error( + "{repo} is a v1 repo (read-only); run `dg migrate` (coming soon) to move it to forge-v2" + )] + V1ReadOnly { + /// The repo as the user named it (`owner/name` or a contract id). + repo: String, + }, + + /// Consensus refused a write because the writer has no membership document for the + /// repository (protocol 14 `ownerRefersTo`, consensus code 40120). On forge-v2 this is + /// "not a writer/maintainer": never granted, or revoked. + #[error("not a member: {0}")] + NotAMember(String), + /// An error surfaced by the Dash Platform SDK (connect, fetch, sign, broadcast). /// /// The SDK's rich error type is flattened to a message here so the SDK stays diff --git a/crates/forge-core/src/lib.rs b/crates/forge-core/src/lib.rs index 1fbfd38e9..70ef720f9 100644 --- a/crates/forge-core/src/lib.rs +++ b/crates/forge-core/src/lib.rs @@ -31,14 +31,19 @@ pub mod backends; pub mod collab; pub mod cost; +pub mod create; pub mod error; pub mod keystore; +pub mod members; pub mod network; pub mod pack; pub mod platform; +pub mod private; pub mod refs; pub mod repo; +pub mod resolve; pub mod rules; +pub mod scope; pub mod storage; pub mod tokens; pub mod user_error; diff --git a/crates/forge-core/src/members.rs b/crates/forge-core/src/members.rs new file mode 100644 index 000000000..885f41f19 --- /dev/null +++ b/crates/forge-core/src/members.rs @@ -0,0 +1,333 @@ +//! forge-v2 membership: the `maintainer` and `writer` documents of a repository. +//! +//! On forge-v2 access is a document, not a token (`docs/contracts/forge-v2.md` §2): +//! +//! * **grant** = the repo owner creates a `writer` or `maintainer` document +//! `{repoId, memberId}`. Consensus admits it only from the repo's owner +//! (`repoId → repo` with `propertyAgreement {"$ownerId": "$ownerId"}`), and the +//! `(repoId, memberId)` index is unique, so each role is held at most once. +//! * **revoke** = the owner deletes that document. The member's next gated write +//! (`refUpdate`, `packManifest`, `chunk`, ...) is refused at consensus with 40120. +//! * **list** = the repo's current documents of both types. A revoked member's document is +//! gone, so the list is exactly who can write now ([`crate::rules::v2::RoleOracle`]). +//! +//! There is no suspend: revocation takes effect immediately, and re-adding creates a new +//! document whose membership starts at its own `$createdAt`. + +use crate::error::{Error, Result}; +use crate::keystore::BridgeIdentity; +use crate::platform::{ + self, FetchedDocument, FieldValue, LoadedContract, LoadedIdentity, PlatformClient, QueryFilter, + QueryOrder, WriteEngine, +}; +use crate::rules::v2::{Membership, Role, RoleOracle}; +use crate::scope::RepoRef; + +/// The document type that grants `role`. +pub fn doc_type(role: Role) -> &'static str { + match role { + Role::Maintainer => "maintainer", + Role::Writer => "writer", + } +} + +/// One current membership document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Member { + /// The member identity (base58). + pub identity_id: String, + /// The role the document grants. + pub role: Role, + /// The membership document's id (what a revoke deletes). + pub document_id: String, + /// Consensus `$createdAt` (ms). + pub created_at: u64, +} + +impl Member { + fn from_doc(doc: &FetchedDocument, role: Role) -> Option { + let member = doc.field_bytes32("memberId")?; + Some(Self { + identity_id: platform::encode_identifier(member), + role, + document_id: doc.id.clone(), + created_at: doc.created_at.unwrap_or(0), + }) + } +} + +/// The oracle over a repository's current members (the input the v2 rules take). +pub fn oracle(members: &[Member]) -> RoleOracle { + RoleOracle::new( + members + .iter() + .map(|m| Membership { + identity: m.identity_id.clone(), + role: m.role, + created_at: m.created_at, + }) + .collect(), + ) +} + +/// `repo`'s document scope and the forge-core contract its membership lives in; refuses v1. +async fn core( + client: &PlatformClient, + repo: &RepoRef, +) -> Result<(crate::scope::DocScope, LoadedContract)> { + let forge = repo.require_v2()?; + Ok((repo.scope()?, client.fetch_contract(&forge.core).await?)) +} + +/// Read access to forge-v2 membership: needs only a client. +pub struct MemberReader<'a> { + client: &'a PlatformClient, +} + +impl<'a> MemberReader<'a> { + /// A reader over `client`. + pub fn new(client: &'a PlatformClient) -> Self { + Self { client } + } + + /// Every current `maintainer` and `writer` document of `repo`, complete. + pub async fn list(&self, repo: &RepoRef) -> Result> { + let (scope, core) = core(self.client, repo).await?; + let mut out = Vec::new(); + for role in [Role::Maintainer, Role::Writer] { + let docs = self + .client + .query_all_documents( + &core, + doc_type(role), + &scope.filters([]), + &[QueryOrder::asc("memberId")], + ) + .await?; + out.extend(docs.iter().filter_map(|d| Member::from_doc(d, role))); + } + Ok(out) + } + + /// `identity`'s current `role` document in `repo`, if any (the index is unique). + pub async fn role_doc( + &self, + repo: &RepoRef, + identity: &str, + role: Role, + ) -> Result> { + let (scope, core) = core(self.client, repo).await?; + let member = FieldValue::identifier(platform::decode_identifier(identity)?); + let docs = self + .client + .query_documents( + &core, + doc_type(role), + &scope.filters([QueryFilter::eq("memberId", member)]), + &[], + 1, + None, + ) + .await?; + Ok(docs.iter().find_map(|d| Member::from_doc(d, role))) + } + + /// `identity`'s current membership documents in `repo` (at most one per role). + pub async fn roles_of(&self, repo: &RepoRef, identity: &str) -> Result> { + let mut out = Vec::new(); + for role in [Role::Maintainer, Role::Writer] { + out.extend(self.role_doc(repo, identity, role).await?); + } + Ok(out) + } +} + +/// Membership writes, signed by the repository owner. +pub struct MemberService<'a> { + client: &'a PlatformClient, + identity: &'a LoadedIdentity, + bridge: &'a BridgeIdentity, +} + +impl<'a> MemberService<'a> { + /// Bind to the owner identity and its keys. + pub fn new( + client: &'a PlatformClient, + identity: &'a LoadedIdentity, + bridge: &'a BridgeIdentity, + ) -> Self { + Self { + client, + identity, + bridge, + } + } + + fn engine(&self) -> Result> { + WriteEngine::new(self.client, self.identity, self.bridge.doc_op_key()?) + } + + /// Only the repo owner can grant or revoke (consensus enforces it; this fails first, + /// with a message instead of a consensus code). + fn require_owner(&self, repo: &RepoRef) -> Result<()> { + repo.require_v2()?; + if repo.owner_id() != self.identity.id() { + return Err(Error::Config(format!( + "only the owner of {} ({}) can change its members", + repo.display(), + repo.owner_id() + ))); + } + Ok(()) + } + + /// Grant `role` to `member`. Idempotent: when the member already holds the role, the + /// existing document is returned and nothing is written. + pub async fn grant(&self, repo: &RepoRef, member: &str, role: Role) -> Result { + self.require_owner(repo)?; + // Consensus checks `memberId` is an identity; checking here gives a clear error. + self.client + .fetch_identity(member) + .await + .map_err(|e| match e { + Error::NotFound => { + Error::Config(format!("{member} is not an identity on this network")) + } + other => other, + })?; + let reader = MemberReader::new(self.client); + if let Some(existing) = reader.role_doc(repo, member, role).await? { + return Ok(existing); + } + let (scope, core) = core(self.client, repo).await?; + let props = scope.props([( + "memberId", + FieldValue::identifier(platform::decode_identifier(member)?), + )]); + let document_id = match self + .engine()? + .create_document(&core, doc_type(role), props) + .await + { + Ok(id) => id, + // A concurrent grant of the same role won the unique index; read it back. + Err(Error::DuplicateUniqueIndex(_)) => { + return reader + .role_doc(repo, member, role) + .await? + .ok_or(Error::NotFound) + } + Err(e) => return Err(e), + }; + Ok(Member { + identity_id: member.to_string(), + role, + document_id, + created_at: 0, + }) + } + + /// Revoke `role` from `member` by deleting its document. Returns whether one existed. + pub async fn revoke(&self, repo: &RepoRef, member: &str, role: Role) -> Result { + self.require_owner(repo)?; + let Some(existing) = MemberReader::new(self.client) + .role_doc(repo, member, role) + .await? + else { + return Ok(false); + }; + let (_, core) = core(self.client, repo).await?; + self.engine()? + .delete_document(&core, doc_type(role), &existing.document_id) + .await?; + Ok(true) + } +} + +/// The advisory push pre-check's verdict for `pusher` against `members`: `None` when the +/// pusher holds a role (consensus will admit the write), else the refusal to show. +/// +/// Advisory only: consensus (`ownerRefersTo`) is the authority. This exists so a +/// non-member learns why before a pack is built and chunk writes are refused one by one. +pub fn push_denied_reason(members: &[Member], pusher: &str, repo: &RepoRef) -> Option { + if members.iter().any(|m| m.identity_id == pusher) { + return None; + } + Some(format!( + "you are not a writer of {repo} — ask its owner to run `dg collab add {repo} {pusher} \ + --role writer`, or push to your own repo (`dg repo create `) and open a pull \ + request", + repo = repo.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::network::ForgeIds; + use crate::rules::v2::Visibility; + + fn repo() -> RepoRef { + RepoRef::V2 { + forge: ForgeIds { + core: "C".into(), + collab: "L".into(), + group: "G".into(), + }, + repo_id: "R".into(), + owner_id: "alice".into(), + name: "proj".into(), + visibility: Visibility::Public, + } + } + + fn member(id: &str, role: Role, at: u64) -> Member { + Member { + identity_id: id.into(), + role, + document_id: format!("doc-{id}-{at}"), + created_at: at, + } + } + + #[test] + fn a_member_passes_the_precheck_and_a_stranger_is_pointed_at_collab_add() { + let members = [ + member("bob", Role::Writer, 10), + member("alice", Role::Maintainer, 1), + ]; + assert_eq!(push_denied_reason(&members, "bob", &repo()), None); + assert_eq!(push_denied_reason(&members, "alice", &repo()), None); + let why = push_denied_reason(&members, "carol", &repo()).unwrap(); + assert!(why.contains("not a writer of alice/proj"), "{why}"); + assert!( + why.contains("dg collab add alice/proj carol --role writer"), + "{why}" + ); + } + + #[test] + fn a_revoked_member_is_simply_absent() { + // Revoke deletes the document, so the list no longer names them. + let members = [member("alice", Role::Maintainer, 1)]; + assert!(push_denied_reason(&members, "bob", &repo()).is_some()); + } + + #[test] + fn oracle_ranks_maintainer_over_writer() { + let members = [ + member("bob", Role::Writer, 10), + member("bob", Role::Maintainer, 20), + ]; + let o = oracle(&members); + assert_eq!(o.current_role("bob"), Some(Role::Maintainer)); + assert_eq!(o.role_at("bob", 15), Some(Role::Writer)); + assert_eq!(o.current_role("carol"), None); + } + + #[test] + fn doc_types_match_the_contract() { + assert_eq!(doc_type(Role::Maintainer), "maintainer"); + assert_eq!(doc_type(Role::Writer), "writer"); + } +} diff --git a/crates/forge-core/src/platform.rs b/crates/forge-core/src/platform.rs index 9ec2d8db8..f7e040ded 100644 --- a/crates/forge-core/src/platform.rs +++ b/crates/forge-core/src/platform.rs @@ -41,20 +41,17 @@ use dash_sdk::dpp::consensus::ConsensusError; use dash_sdk::dpp::dashcore::secp256k1::rand::{rngs::StdRng, Rng, SeedableRng}; use dash_sdk::dpp::dashcore::Network as DashcoreNetwork; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::conversion::json::DataContractJsonConversionMethodsV0; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; use dash_sdk::dpp::document::{Document, DocumentV0, DocumentV0Getters, INITIAL_REVISION}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::signer::Signer; -use dash_sdk::dpp::identity::{KeyType, PartialIdentity, Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; use dash_sdk::dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; -use dash_sdk::dpp::state_transition::data_contract_create_transition::methods::DataContractCreateTransitionMethodsV0; -use dash_sdk::dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::dpp::tokens::calculate_token_id; @@ -67,10 +64,6 @@ use dash_sdk::drive::query::{OrderClause, SelectProjection, WhereClause, WhereOp use dash_sdk::platform::contract_groups::ContractGroupMembershipsForContract; use dash_sdk::platform::documents::document_query::DocumentQuery; use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; -use dash_sdk::platform::tokens::builders::destroy::TokenDestroyFrozenFundsTransitionBuilder; -use dash_sdk::platform::tokens::builders::freeze::TokenFreezeTransitionBuilder; -use dash_sdk::platform::tokens::builders::mint::TokenMintTransitionBuilder; -use dash_sdk::platform::tokens::builders::unfreeze::TokenUnfreezeTransitionBuilder; use dash_sdk::platform::tokens::identity_token_balances::IdentitiesTokenBalancesQuery; use dash_sdk::platform::tokens::token_info::IdentitiesTokenInfosQuery; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; @@ -395,25 +388,6 @@ impl PlatformClient { Ok(self.fetch_identity(identity_id).await?.balance()) } - /// The identity's current nonce (the value contract-create id derivation uses), - /// fetched without bumping — for diagnostics / orphan-contract recovery. - pub async fn identity_nonce(&self, identity_id: &str) -> Result { - let id = parse_id(identity_id, "identity id")?; - self.sdk - .get_identity_nonce(id, false, None) - .await - .map_err(|e| Error::Platform(format!("fetching identity nonce: {e}"))) - } - - /// Derive the deterministic contract id `hash(ownerId || nonce)` for a given owner + - /// identity nonce — the same id [`PlatformClient::contract_create`] produces, exposed - /// so a create whose follow-on writes failed can locate its (already paid-for) orphan - /// contract without re-creating it. - pub fn derive_contract_id(&self, owner_id: &str, nonce: u64) -> Result { - let owner = parse_id(owner_id, "owner id")?; - Ok(DataContract::generate_data_contract_id_v0(owner, nonce).to_string(Encoding::Base58)) - } - /// The identity-contract nonce, DIP-30 masked to the low 40 bits. /// /// This reads the *current* nonce (no bump) for reporting/diagnostics; the write @@ -442,6 +416,20 @@ impl PlatformClient { document_type: &str, document_id: &str, ) -> Result { + Ok(self + .fetch_document(contract, document_type, document_id) + .await? + .is_some()) + } + + /// The document of `document_type` with base58 `document_id` in `contract`, or `None` + /// when it provably does not exist (proof-verified single-document fetch). + pub async fn fetch_document( + &self, + contract: &LoadedContract, + document_type: &str, + document_id: &str, + ) -> Result> { let doc_id = parse_id(document_id, "document id")?; let query = DocumentQuery::new(Arc::clone(&contract.0), document_type) .map_err(|e| Error::Platform(format!("building document query: {e}")))? @@ -451,7 +439,7 @@ impl PlatformClient { }) .await .map_err(|e| Error::Platform(format!("fetching document {document_id}: {e}")))?; - Ok(found.is_some()) + Ok(found.as_ref().map(FetchedDocument::from_document)) } /// Query **one page** of `document_type` in `contract`, applying `filters` (AND-ed @@ -620,154 +608,12 @@ impl PlatformClient { Ok(documents) } - /// Create a data contract (WITH tokens) from a JSON template, signing with `key` - /// (must be a **CRITICAL** AUTHENTICATION key — token-bearing contracts are rejected - /// for HIGH, spike S0.7). - /// - /// The contract id is derived from `owner` + a freshly bumped identity nonce - /// (`hash(ownerId || nonce)`); the same nonce is baked into the create transition, so - /// the id is deterministic and known before broadcast. `template` must contain - /// `documentSchemas` and (optionally) `tokens` / `keywords` / `description`; any `id`, - /// `ownerId` or `version` in it are ignored and re-synthesized here. - /// - /// ## Native rs-dpp accepts tokens from JSON - /// - /// Unlike the wasm `DataContract` constructor (which needs `TokenConfiguration` - /// instances and drove the S0.7 `DataContract.fromJSON` workaround), native - /// [`DataContract::from_json`] deserializes a plain-JSON `tokens` map directly — the - /// full repo-v1 template (2 tokens + 15 doc types) round-trips with no per-token - /// object construction. - /// - /// Returns `(contractId, cost_credits)` where `cost_credits` is the identity balance - /// delta across the broadcast (the measured DataContractCreate cost). - pub async fn contract_create( - &self, - template: &serde_json::Value, - owner: &LoadedIdentity, - key: &IdentityKey, - ) -> Result<(String, u64)> { - let signer = signer_from_key(key)?; - // Token-bearing contract create requires a CRITICAL auth key (S0.7). - let signing_key = select_key_at_level(&owner.0, &signer, SecurityLevel::CRITICAL)?; - let owner_id = owner.0.id(); - - // One nonce fetch, bumped, reused for both id-derivation and the transition — - // `new_from_data_contract` re-derives the id from (owner, nonce) internally, so - // any double-bump would desync the id from the signed nonce. - let nonce = self - .sdk - .get_identity_nonce(owner_id, true, None) - .await - .map_err(|e| Error::Platform(format!("fetching identity nonce: {e}")))?; - let contract_id = DataContract::generate_data_contract_id_v0(owner_id, nonce); - - let schemas = template - .get("documentSchemas") - .ok_or_else(|| Error::Config("contract template missing 'documentSchemas'".into()))?; - let mut full = serde_json::json!({ - "$formatVersion": "1", - "id": contract_id.to_string(Encoding::Base58), - "ownerId": owner_id.to_string(Encoding::Base58), - "version": 1, - "documentSchemas": schemas, - }); - let obj = full.as_object_mut().expect("json object"); - for k in ["tokens", "keywords", "description", "groups"] { - if let Some(v) = template.get(k) { - obj.insert(k.to_string(), v.clone()); - } - } - - let contract = DataContract::from_json(full, true, self.sdk.version()) - .map_err(|e| Error::Platform(format!("deserializing data contract JSON: {e}")))?; - - let key_id = signing_key.id(); - let partial_identity = PartialIdentity { - id: owner_id, - loaded_public_keys: BTreeMap::from([(key_id, signing_key)]), - balance: None, - revision: None, - not_found_public_keys: std::collections::BTreeSet::new(), - }; - - let state_transition = DataContractCreateTransition::new_from_data_contract( - contract, - nonce, - &partial_identity, - key_id, - &signer, - self.sdk.version(), - None, - ) - .await - .map_err(|e| Error::Platform(format!("signing contract-create transition: {e}")))?; - - let balance_before = owner.balance(); - // The contract id is deterministic (`hash(ownerId || nonce)`) and the nonce is - // baked into the signed transition, so a broadcast that errors *ambiguously* — a - // transient reset that makes the SDK re-broadcast and hit its own cached tx - // ("AlreadyExists"), a wait timeout after the tx landed — has NOT necessarily - // failed. Retrying with a fresh nonce would mint (and pay ~1 DASH for) a SECOND - // contract, orphaning the first. So on any broadcast error, verify by fetching the - // derived id before surfacing an error: if the contract landed, this is success. - match state_transition - .broadcast_and_wait::(&self.sdk, None) - .await - { - Ok(result) => { - // Register the freshly created contract with the context provider so - // subsequent proof-verified writes/reads against it resolve (see field - // docs on `context_provider`). - if let StateTransitionProofResult::VerifiedDataContract(created) = &result { - self.context_provider.add_known_contract(created.clone()); - } - } - Err(e) => match DataContract::fetch(&self.sdk, contract_id).await { - Ok(Some(existing)) => { - // The create actually landed — idempotent success, not a double-pay. - self.context_provider.add_known_contract(existing); - tracing::warn!( - error = %e, - contract_id = %contract_id.to_string(Encoding::Base58), - "contract-create broadcast errored but the contract is on-chain; treating as success (idempotent — no second create)" - ); - } - _ => { - return Err(Error::Platform(format!( - "broadcasting contract create: {e}" - ))) - } - }, - } - - let balance_after = self - .fetch_identity(&owner_id.to_string(Encoding::Base58)) - .await? - .balance(); - let cost = balance_before.saturating_sub(balance_after); - - Ok((contract_id.to_string(Encoding::Base58), cost)) - } - - // === Token administration (collaborator ACL) ========================= - // - // Token mint/freeze/unfreeze/destroy are the on-chain ACL: minting the WRITE - // (position 0) or MAINTAIN (position 1) token to an identity grants it, freezing - // suspends it (a frozen identity cannot spend the token → every gated create/delete - // fails at consensus, S0.7), and destroying the frozen balance revokes it. - // - // All four require a **CRITICAL** AUTHENTICATION key (S0.7: HIGH is rejected for - // token admin). They are signed by the token authority — for a solo-owner repo the - // `ContractOwner`, i.e. the repo owner identity, which holds the mint/freeze/destroy - // authority via the solo-owner token rules. + // === Token reads (the v1 collaborator ACL, read-only) ================== // - // The **keepsHistory mint() return-value bug** (S0.7): on a history-keeping token the - // wasm SDK's result parser throws `'platformVersion' string value ''` *after* the - // transition already landed at consensus. The native rs-sdk `token_*` helpers here - // parse the `VerifiedTokenActionWithDocument` proof correctly, so the bug does not - // fire — but [`finish_token_op`] still treats that exact string as "landed; verify via - // query" defensively, and the [`crate::tokens`] service always re-reads the balance / - // frozen status after every op rather than trusting the return value. + // forge-v1 repositories granted access with two tokens per repo contract (WRITE at + // position 0, MAINTAIN at position 1). v1 is read-only now: these reads remain so a v1 + // repo's collaborators and token history still render, but nothing mints, freezes or + // destroys any more. forge-v2 membership is documents (`crate::members`). /// The base58 token id for `position` (0 = WRITE, 1 = MAINTAIN) of `contract`, /// derived as `hash("dash_token" || contractId || position)` (rs-dpp `calculate_token_id`). @@ -776,121 +622,6 @@ impl PlatformClient { Identifier::from(raw).to_string(Encoding::Base58) } - /// Mint `amount` of the token at `position` to `recipient` (base58) — a **grant**. - /// Signs with the owner's CRITICAL key. Verify success via a balance query (the mint - /// return value is not trusted — see the module note). - pub async fn token_mint( - &self, - contract: &LoadedContract, - owner: &LoadedIdentity, - key: &IdentityKey, - position: u16, - amount: u64, - recipient: &str, - ) -> Result<()> { - let signer = signer_from_key(key)?; - let signing_key = select_key_at_level(&owner.0, &signer, SecurityLevel::CRITICAL)?; - let recipient_id = parse_id(recipient, "recipient id")?; - let builder = TokenMintTransitionBuilder::new( - Arc::clone(&contract.0), - position, - owner.0.id(), - amount, - ) - .issued_to_identity_id(recipient_id) - .with_public_note("dash-forge grant".to_string()); - let outcome = self - .sdk - .token_mint(builder, &signing_key, &signer) - .await - .map(|_| ()); - finish_token_op("mint", outcome) - } - - /// Freeze the token at `position` for `target` (base58) — a **suspend**. A frozen - /// identity keeps its balance but cannot spend it, so every gated action fails. - pub async fn token_freeze( - &self, - contract: &LoadedContract, - owner: &LoadedIdentity, - key: &IdentityKey, - position: u16, - target: &str, - ) -> Result<()> { - let signer = signer_from_key(key)?; - let signing_key = select_key_at_level(&owner.0, &signer, SecurityLevel::CRITICAL)?; - let target_id = parse_id(target, "target id")?; - let builder = TokenFreezeTransitionBuilder::new( - Arc::clone(&contract.0), - position, - owner.0.id(), - target_id, - ) - .with_public_note("dash-forge suspend".to_string()); - let outcome = self - .sdk - .token_freeze(builder, &signing_key, &signer) - .await - .map(|_| ()); - finish_token_op("freeze", outcome) - } - - /// Unfreeze the token at `position` for `target` (base58) — lift a suspension. - pub async fn token_unfreeze( - &self, - contract: &LoadedContract, - owner: &LoadedIdentity, - key: &IdentityKey, - position: u16, - target: &str, - ) -> Result<()> { - let signer = signer_from_key(key)?; - let signing_key = select_key_at_level(&owner.0, &signer, SecurityLevel::CRITICAL)?; - let target_id = parse_id(target, "target id")?; - let builder = TokenUnfreezeTransitionBuilder::new( - Arc::clone(&contract.0), - position, - owner.0.id(), - target_id, - ) - .with_public_note("dash-forge unsuspend".to_string()); - let outcome = self - .sdk - .token_unfreeze_identity(builder, &signing_key, &signer) - .await - .map(|_| ()); - finish_token_op("unfreeze", outcome) - } - - /// Destroy the **frozen** balance of the token at `position` held by `target` (base58) - /// — a **revoke**. The identity must already be frozen; its balance is zeroed and - /// removed from supply, so it is no longer an on-chain collaborator. - pub async fn token_destroy_frozen( - &self, - contract: &LoadedContract, - owner: &LoadedIdentity, - key: &IdentityKey, - position: u16, - target: &str, - ) -> Result<()> { - let signer = signer_from_key(key)?; - let signing_key = select_key_at_level(&owner.0, &signer, SecurityLevel::CRITICAL)?; - let target_id = parse_id(target, "target id")?; - let builder = TokenDestroyFrozenFundsTransitionBuilder::new( - Arc::clone(&contract.0), - position, - owner.0.id(), - target_id, - ) - .with_public_note("dash-forge revoke".to_string()); - let outcome = self - .sdk - .token_destroy_frozen_funds(builder, &signing_key, &signer) - .await - .map(|_| ()); - finish_token_op("destroy_frozen", outcome) - } - /// The token balances (`identity → amount`, absent = 0) of `token_id_b58` across /// `identities` (base58). This is the authoritative on-chain collaborator holding /// check — a positive balance means the token is held. @@ -1131,6 +862,12 @@ impl FetchedDocument { self.fields.get(name).and_then(FieldValue::as_bytes) } + /// A 32-byte field (an identifier or a hash), if present and exactly 32 bytes. + pub fn field_bytes32(&self, name: &str) -> Option<[u8; 32]> { + self.field_bytes(name) + .and_then(|b| <[u8; 32]>::try_from(b).ok()) + } + /// A `byteArray` field as lowercase hex (the form `crate::rules` oids/hashes use). pub fn field_hex(&self, name: &str) -> Option { self.field_bytes(name).map(hex::encode) @@ -1532,22 +1269,37 @@ impl<'a> WriteEngine<'a> { contract: &LoadedContract, document_type: &str, properties: BTreeMap, + ) -> Result { + self.create_journaled(contract, document_type, properties, |_| Ok(())) + .await + } + + /// A create whose signed bytes are handed to `persist` BEFORE the first broadcast, so a + /// caller that dies mid-broadcast can later [`Self::replay`] the identical transition + /// instead of signing a second, different write (the resumable repo-create session). + /// + /// A refusal for a stale protocol version (nothing landed) re-prepares once, persisting + /// the replacement before broadcasting it, as [`Self::create_landed`] does. + pub async fn create_journaled( + &self, + contract: &LoadedContract, + document_type: &str, + properties: BTreeMap, + mut persist: impl FnMut(&PreparedWrite) -> Result<()>, ) -> Result { let prepared = self .prepare_create(contract, document_type, properties.clone()) .await?; + persist(&prepared)?; match self.execute(&prepared).await { Ok(_) => Ok(prepared), Err(Error::StaleProtocolVersion(reason)) => { let version = self.client.refresh_protocol_version().await?; - tracing::warn!( - %reason, - version, - "document id was derived at a stale protocol version; re-preparing once" - ); + tracing::warn!(%reason, version, "stale protocol version; re-preparing once"); let prepared = self .prepare_create(contract, document_type, properties) .await?; + persist(&prepared)?; self.execute(&prepared).await?; Ok(prepared) } @@ -1555,6 +1307,23 @@ impl<'a> WriteEngine<'a> { } } + /// Re-broadcast a write captured earlier by [`Self::create_journaled`]. The identical + /// signed bytes land at most once: a transition that already landed reports + /// [`BroadcastOutcome::AlreadyExists`]. + pub async fn replay( + &self, + document_type: &str, + intent: &WriteIntent, + ) -> Result { + self.execute(&PreparedWrite { + document_id: intent.document_id.clone(), + document_type: document_type.to_string(), + op: intent.operation, + signed: intent.transition.clone(), + }) + .await + } + /// Convenience: prepare + execute a document delete. pub async fn delete_document( &self, @@ -1601,6 +1370,9 @@ pub enum FieldValue { Bool(bool), /// A nested object field (e.g. `config.backend`), keyed by property name. Object(BTreeMap), + /// A typed array of non-byte items (forge-v2 `uris`, `protectedPatterns`, `topics`: + /// arrays of strings). Byte arrays stay [`FieldValue::Bytes`]. + List(Vec), } impl FieldValue { @@ -1657,6 +1429,29 @@ impl FieldValue { } } + /// A string list: the items of a [`FieldValue::List`] of `Text`. An empty array reads + /// back as empty bytes (the item type is not on the wire), so that is an empty list too. + pub fn as_text_list(&self) -> Option> { + match self { + FieldValue::List(items) => items + .iter() + .map(|i| i.as_str().map(str::to_string)) + .collect(), + FieldValue::Bytes(b) if b.is_empty() => Some(Vec::new()), + _ => None, + } + } + + /// A typed string-array field. + pub fn text_list>(items: impl IntoIterator) -> Self { + FieldValue::List( + items + .into_iter() + .map(|s| FieldValue::Text(s.into())) + .collect(), + ) + } + /// The string of a `Text` field, if this is one. pub fn as_str(&self) -> Option<&str> { match self { @@ -1688,6 +1483,9 @@ impl FieldValue { .map(|(k, v)| (Value::Text(k), v.into_value())) .collect(), ), + FieldValue::List(items) => { + Value::Array(items.into_iter().map(FieldValue::into_value).collect()) + } } } @@ -1711,17 +1509,25 @@ impl FieldValue { Value::I32(n) => FieldValue::Integer(u64::try_from(*n).ok()?), Value::U16(n) => FieldValue::Integer(u64::from(*n)), Value::U8(n) => FieldValue::Integer(u64::from(*n)), - Value::Array(items) => { - // A byteArray that came back as an array of U8 → repack to bytes. - let mut bytes = Vec::with_capacity(items.len()); - for item in items { - match item { - Value::U8(b) => bytes.push(*b), - _ => return None, - } - } - FieldValue::Bytes(bytes) + // A byteArray that came back as an array of U8 → repack to bytes. Anything else + // is a typed array (forge-v2 string lists). + Value::Array(items) if items.iter().all(|i| matches!(i, Value::U8(_))) => { + FieldValue::Bytes( + items + .iter() + .filter_map(|i| match i { + Value::U8(b) => Some(*b), + _ => None, + }) + .collect(), + ) } + Value::Array(items) => FieldValue::List( + items + .iter() + .map(FieldValue::from_value) + .collect::>>()?, + ), Value::Map(entries) => { let mut map = BTreeMap::new(); for (k, v) in entries { @@ -1961,30 +1767,6 @@ fn token_payment_for(cost: Option) -> Option Result { - for public_key in identity.public_keys().values() { - if public_key.is_disabled() || !signer.can_sign_with(public_key) { - continue; - } - if public_key.purpose() == Purpose::AUTHENTICATION - && public_key.key_type() == KeyType::ECDSA_SECP256K1 - && public_key.security_level() == level - { - return Ok(public_key.clone()); - } - } - Err(Error::Config(format!( - "no usable {level:?} AUTHENTICATION key on the identity matches the keystore key" - ))) -} - /// Select the identity's on-chain AUTHENTICATION key that (a) the signer can sign with /// and (b) is a usable ECDSA_SECP256K1 authentication key at HIGH or CRITICAL — the /// levels document create/delete accept (spike S0.7). @@ -2021,43 +1803,6 @@ enum WriteFailure { Fatal(Error), } -/// The exact wasm-SDK keepsHistory result-parse error (S0.7). Native rs-sdk parses the -/// token-action proof correctly, so this should never fire on this path; matched as a -/// defensive net so that, if it ever did, a transition that already landed at consensus -/// is treated as success (the [`crate::tokens`] service re-verifies via query regardless). -const TOKEN_HISTORY_PARSE_BUG: &str = "'platformVersion' string value ''"; - -/// Finish a token admin op: map `Ok` to success, translate a frozen / unauthorized -/// consensus rejection into the typed crate error, swallow the keepsHistory parse bug as -/// "landed", and surface anything else as a platform error. Token ops are NOT blindly -/// re-broadcast (a second mint would double-mint) — ambiguity is resolved by the caller's -/// post-op query, not a retry. -fn finish_token_op(label: &str, outcome: std::result::Result<(), dash_sdk::Error>) -> Result<()> { - match outcome { - Ok(()) => Ok(()), - Err(e) => { - if e.to_string().contains(TOKEN_HISTORY_PARSE_BUG) { - tracing::warn!( - op = label, - error = %e, - "token op landed at consensus; SDK result-parse hit the keepsHistory bug (verify via query)" - ); - return Ok(()); - } - if let Some(ConsensusError::StateError(state_error)) = consensus_error_of(&e) { - match state_error { - StateError::IdentityTokenAccountFrozenError(_) => { - return Err(Error::TokenFrozen) - } - StateError::UnauthorizedTokenActionError(_) => return Err(Error::Unauthorized), - _ => {} - } - } - Err(Error::Platform(format!("token {label} failed: {e}"))) - } - } -} - /// Pull the consensus error out of whichever SDK error variant carries it (a broadcast /// error's `cause`, or a protocol error). Structured — never string-matched. fn consensus_error_of(e: &dash_sdk::Error) -> Option<&ConsensusError> { @@ -2126,6 +1871,13 @@ fn classify_write_error(e: &dash_sdk::Error, document_type: &str) -> WriteFailur StateError::UnauthorizedTokenActionError(_) => { return WriteFailure::Fatal(Error::Unauthorized) } + // 40120 on the writer path: a protocol-14 `ownerRefersTo` gate found no + // membership document for the writer (forge-v2: never granted, or revoked). + StateError::ReferencedEntityNotFoundError(err) if err.path() == "$ownerId" => { + return WriteFailure::Fatal(Error::NotAMember(format!( + "consensus refused {document_type} (40120: {err})" + ))) + } _ => {} } } diff --git a/crates/forge-core/src/private.rs b/crates/forge-core/src/private.rs new file mode 100644 index 000000000..b7922dacf --- /dev/null +++ b/crates/forge-core/src/private.rs @@ -0,0 +1,111 @@ +//! Private-repository seams (`docs/contracts/forge-v2.md` §5), as no-op interfaces. +//! +//! Public repositories are all this release writes and reads. The places a private +//! repository will differ already go through these traits, so the private-repo release fills +//! them in rather than re-plumbing the data plane: +//! +//! * [`RefNameHasher`]: `refNameHash` is `sha256(refName)` in a public repo and +//! `HMAC-SHA256(epoch key, refName)` in a private one. +//! * [`RepoCodec`]: a private repo's content fields travel inside `enc`; a public repo's are +//! plaintext. +//! * [`PackCipher`]: a private repo's packs are encrypted before upload. +//! * [`RepoKeyReader`]: the epoch key comes from the member's `repoKey` wrap. +//! +//! [`for_visibility`] hands out the implementations; for a private repository it refuses, +//! so nothing can write a private repo's data in the clear by accident. + +use crate::error::{Error, Result}; +use crate::rules::v2::Visibility; + +/// How a ref name becomes its indexed `refNameHash`. +pub trait RefNameHasher: Send + Sync { + /// The 32-byte `refNameHash` of `ref_name`. + fn hash(&self, ref_name: &str) -> [u8; 32]; +} + +/// How a document's content fields are carried: plaintext, or sealed in `enc`. +pub trait RepoCodec: Send + Sync { + /// Whether content is written as plaintext fields (public) or in `enc` (private). + fn plaintext(&self) -> bool; +} + +/// How pack bytes are protected before they leave the machine. +pub trait PackCipher: Send + Sync { + /// The bytes to upload for `pack`. + fn seal(&self, pack: Vec) -> Result>; + /// The pack bytes from uploaded `sealed` bytes. + fn open(&self, sealed: Vec) -> Result>; +} + +/// Where a member's repository key for an epoch comes from. +pub trait RepoKeyReader: Send + Sync { + /// The 32-byte content key of `epoch`. + fn epoch_key(&self, epoch: u32) -> Result<[u8; 32]>; +} + +/// The public-repository implementation of every seam: sha256 ref hashes, plaintext +/// fields, packs as-is, and no keys. +#[derive(Debug, Clone, Copy, Default)] +pub struct Public; + +impl RefNameHasher for Public { + fn hash(&self, ref_name: &str) -> [u8; 32] { + crate::backends::sha256(ref_name.as_bytes()) + } +} + +impl RepoCodec for Public { + fn plaintext(&self) -> bool { + true + } +} + +impl PackCipher for Public { + fn seal(&self, pack: Vec) -> Result> { + Ok(pack) + } + fn open(&self, sealed: Vec) -> Result> { + Ok(sealed) + } +} + +impl RepoKeyReader for Public { + fn epoch_key(&self, _epoch: u32) -> Result<[u8; 32]> { + Err(not_supported()) + } +} + +fn not_supported() -> Error { + Error::Config("private repositories are not supported by this version of the CLI yet".into()) +} + +/// The seams for a repository of `visibility`. Private repositories are refused until the +/// private-repo release implements them. +pub fn for_visibility(visibility: Visibility) -> Result { + match visibility { + Visibility::Public => Ok(Public), + Visibility::Private => Err(not_supported()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_seams_are_the_identity_and_sha256() { + let p = for_visibility(Visibility::Public).unwrap(); + assert_eq!( + hex::encode(p.hash("refs/heads/main")), + hex::encode(crate::backends::sha256(b"refs/heads/main")) + ); + assert!(p.plaintext()); + assert_eq!(p.open(p.seal(b"pack".to_vec()).unwrap()).unwrap(), b"pack"); + assert!(p.epoch_key(0).is_err()); + } + + #[test] + fn private_repositories_are_refused() { + assert!(for_visibility(Visibility::Private).is_err()); + } +} diff --git a/crates/forge-core/src/refs.rs b/crates/forge-core/src/refs.rs index f34d54fdb..0a1a2e121 100644 --- a/crates/forge-core/src/refs.rs +++ b/crates/forge-core/src/refs.rs @@ -68,6 +68,7 @@ use crate::platform::{ FetchedDocument, FieldValue, LoadedContract, PlatformClient, QueryFilter, QueryOrder, }; use crate::rules::RefUpdate; +use crate::scope::DocScope; /// The plain ref-update document type. pub(crate) const DOC_REF_UPDATE: &str = "refUpdate"; @@ -108,10 +109,14 @@ pub(crate) trait RefDocSource { async fn full_scan(&self, doc_type: &str) -> Result>; } -/// [`RefDocSource`] over a live Platform connection. +/// [`RefDocSource`] over a live Platform connection, inside one repository's scope: on +/// forge-v2 every query leads with `repoId == R` (the `refState` and `reflog` indexes are +/// `(repoId, refNameHash, $createdAt)` and `(repoId, $createdAt)`), on v1 the repo +/// contract is the scope. pub(crate) struct PlatformRefSource<'a> { pub(crate) client: &'a PlatformClient, pub(crate) contract: &'a LoadedContract, + pub(crate) scope: &'a DocScope, } impl RefDocSource for PlatformRefSource<'_> { @@ -120,10 +125,9 @@ impl RefDocSource for PlatformRefSource<'_> { doc_type: &str, after: Option<[u8; 32]>, ) -> Result> { - let filters: Vec = after - .map(|h| QueryFilter::gt("refNameHash", FieldValue::bytes32(h))) - .into_iter() - .collect(); + let filters = self + .scope + .filters(after.map(|h| QueryFilter::gt("refNameHash", FieldValue::bytes32(h)))); self.client .query_documents( self.contract, @@ -144,7 +148,9 @@ impl RefDocSource for PlatformRefSource<'_> { .query_all_documents( self.contract, doc_type, - &[QueryFilter::eq("refNameHash", FieldValue::bytes32(hash))], + &self + .scope + .filters([QueryFilter::eq("refNameHash", FieldValue::bytes32(hash))]), &[QueryOrder::asc("$createdAt")], ) .await @@ -155,28 +161,44 @@ impl RefDocSource for PlatformRefSource<'_> { .query_all_documents( self.contract, doc_type, - &[], + &self.scope.filters([]), &[QueryOrder::asc("$createdAt")], ) .await } } -/// Read every ref's complete history from a live repo contract. See the module docs. +/// Read every ref's complete history of the repository `scope` names, from `contract` +/// (forge-core on v2, the repo contract on v1). See the module docs. pub async fn read_all_ref_updates( client: &PlatformClient, contract: &LoadedContract, + scope: &DocScope, ) -> Result { - read_all_with(&PlatformRefSource { client, contract }).await + read_all_with(&PlatformRefSource { + client, + contract, + scope, + }) + .await } -/// Read one ref's complete history (both types) from a live repo contract. +/// Read one ref's complete history (both types) of the repository `scope` names. pub async fn read_ref_history( client: &PlatformClient, contract: &LoadedContract, + scope: &DocScope, ref_name_hash: [u8; 32], ) -> Result> { - ref_history_with(&PlatformRefSource { client, contract }, ref_name_hash).await + ref_history_with( + &PlatformRefSource { + client, + contract, + scope, + }, + ref_name_hash, + ) + .await } pub(crate) async fn ref_history_with( diff --git a/crates/forge-core/src/repo.rs b/crates/forge-core/src/repo.rs index 1628f1421..807c3f059 100644 --- a/crates/forge-core/src/repo.rs +++ b/crates/forge-core/src/repo.rs @@ -1,108 +1,74 @@ -//! [`RepoService`] — the repo-lifecycle API `git-remote-dash` calls. +//! [`RepoService`] — the git data plane `git-remote-dash` and `dg` drive. //! -//! This is the orchestration layer that turns the [`crate::platform`] SDK primitives -//! (contract create, document create/delete, document query) and the pure -//! [`crate::rules`] resolver into the operations a git remote helper needs: +//! Every operation takes a resolved [`RepoRef`] and reaches its documents through the +//! repo's [`DocScope`]: on forge-v2 that is the network's shared forge-core contract with +//! `repoId == R` on every query and write; on forge-v1 it is the repo's own contract. v1 +//! repositories are **read only**: every write refuses them with [`Error::V1ReadOnly`] +//! before signing anything. //! -//! - [`RepoService::create_repo`] — instantiate a repo-v1 contract (2 tokens + 15 doc -//! types, `baseSupply` auto-crediting the owner WRITE+MAINTAIN), write the initial -//! `config`, and publish the `repoListing` into the global registry. Returns the -//! measured DataContractCreate cost (the repo-v1 instantiation number). -//! - [`RepoService::resolve_repo`] — registry lookup `(ownerId, normalizedName)` → -//! repo contract id. -//! - [`RepoService::write_ref_update`] / [`RepoService::read_refs`] — append a WRITE- (or -//! MAINTAIN-, for protected refs) gated ref update, and fold a repo's ref history into -//! resolved [`RefState`]s via [`crate::rules::resolve_ref`]. +//! - [`RepoService::write_ref_update`] / [`RepoService::read_refs`] — append a ref update +//! (`protectedRefUpdate` for a protected ref, routed by the as-of config rule) and fold a +//! repo's ref history into [`RefState`]s via [`crate::rules::resolve_ref`]. //! - [`RepoService::write_pack_manifest`] / [`RepoService::read_pack_manifests`] and the -//! chunk put/get, delegating pack-byte storage to [`crate::backends::PlatformBackend`]. +//! chunk tier ([`PlatformChunkTarget`]). +//! - [`RepoService::fetch_artifact`] — read a pack: external copies first, then Platform +//! chunks. On v2 each uploader has its own copy of a pack; readers try them in the +//! `FORGE_RULES_V2` order (maintainers', then writers', then former members'), and use +//! the first that hash-verifies ([`crate::rules::v2::order_pack_copies`]). +//! - [`RepoService::repack`] — consolidate the live packs into one superseding pack. On v2 +//! nothing is deleted: chunks and manifests are permanent (forge-v2 §4). //! -//! Everything SDK-shaped is reached through [`crate::platform`]; this module names no -//! rs-sdk / rs-dpp type (style guide §B). +//! Repository creation is [`crate::create`]; resolution is [`crate::resolve`]; membership +//! is [`crate::members`]. This module names no rs-sdk type (style guide §B). use std::collections::{BTreeMap, BTreeSet}; -use crate::backends::{ByteRange, PackBackend, PackMeta, PlatformBackend, Uri}; +use crate::backends::{PackBackend, PackMeta, PlatformBackend, Uri}; use crate::error::{Error, Result}; use crate::keystore::BridgeIdentity; use crate::platform::{ - self, FieldValue, JournalStore, LoadedContract, LoadedIdentity, PlatformClient, PushJournal, - QueryFilter, QueryOrder, WriteEngine, WriteIntent, + FetchedDocument, FieldValue, JournalStore, LoadedContract, LoadedIdentity, PlatformClient, + PushJournal, QueryFilter, QueryOrder, WriteEngine, WriteIntent, }; +use crate::private::RefNameHasher; +use crate::rules::v2::{PackCopy, Role}; use crate::rules::{self, ConfigDoc, RefState}; -use crate::storage::{PackReader, Replication, StorageTarget}; +use crate::scope::{self, DocScope, RepoRef}; +use crate::storage::{PackReader, Replication, StorageTarget, UriBudget}; -/// The repo-v1 contract template (2 tokens + 15 doc types), embedded at build time. -/// -/// The template's `id` / `ownerId` are placeholders — [`PlatformClient::contract_create`] -/// derives the real id from the owner + nonce and stamps the real `ownerId`. Its token -/// admin rules target an org control group; [`apply_solo_owner_token_rules`] rewrites them -/// to `ContractOwner` for the solo-owner instantiation (see there for why). -const REPO_V1_TEMPLATE: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../forge-contracts/templates/repo-v1.json" -)); - -// Document type names (repo contract). +// Document type names (the git data plane; the same names in forge-core and repo-v1). const DOC_CONFIG: &str = "config"; use crate::refs::{DOC_PROTECTED_REF_UPDATE, DOC_REF_UPDATE}; const DOC_PACK_MANIFEST: &str = "packManifest"; -const DOC_MANIFEST_PART: &str = "manifestPart"; -const DOC_CHUNK: &str = "chunk"; -/// The template-v2 `packMirror` type (repoId, packHash, uris) — anyone announces extra -/// availability URIs. Absent from the v1 template (feature-detected at runtime). -const DOC_PACK_MIRROR: &str = "packMirror"; -// Document type name (registry contract). -const DOC_REPO_LISTING: &str = "repoListing"; - -/// Options for [`RepoService::create_repo`]. -#[derive(Debug, Clone)] -pub struct CreateRepoOpts { - /// The default branch recorded in `config` (e.g. `main`). - pub default_branch: String, - /// The writer-side backend mode (`0` platform, `1` ipfs, `2` s3, `3` https, `4` - /// mixed) recorded in `config.backend.mode`. - pub backend_mode: u8, - /// The listing description (registry `repoListing.description`, ≤ 500 chars). - pub description: String, - /// The template version stamped into the listing (migration tracking). - pub template_version: u32, -} - -impl Default for CreateRepoOpts { - fn default() -> Self { - Self { - default_branch: "main".to_string(), - backend_mode: 0, - description: String::new(), - template_version: 1, - } - } -} -/// A handle to an instantiated repository. +/// A `packManifest` document read back from a repository. #[derive(Debug, Clone)] -pub struct RepoHandle { - /// Base58 id of the per-repo data contract. - pub repo_contract_id: String, - /// Base58 id of the repo owner identity. +pub struct PackManifestInfo { + /// The manifest document id. + pub document_id: String, + /// `$createdAt` (ms). With `document_id` this is the platform total order that + /// [`locator_pack_space`] turns into the locator's `packRef` space. + pub created_at: u64, + /// The base58 `$ownerId` of the uploader. On v2 its chunks are keyed by it. pub owner_id: String, - /// The display name. - pub name: String, - /// The normalized (lowercased, validated) name used for resolution. - pub normalized_name: String, -} - -/// The result of [`RepoService::create_repo`], carrying the measured instantiation cost. -#[derive(Debug, Clone)] -pub struct CreateRepoResult { - /// The repo handle. - pub handle: RepoHandle, - /// The measured DataContractCreate cost, in credits (÷ 1e11 = DASH). **This is the - /// repo-v1 instantiation cost** the economics docs reconcile against. - pub repo_v1_instantiation_cost_credits: u64, - /// The registry `repoListing` document id (needed to delete the listing on repo - /// teardown — the contract itself is permanent). - pub listing_document_id: String, + /// SHA-256 pack hash. + pub pack_hash: [u8; 32], + /// Artifact kind. + pub kind: u64, + /// Pack size in bytes. + pub size_bytes: u64, + /// Object count. + pub object_count: u64, + /// Chunk count. + pub chunk_count: u64, + /// Storage tier. + pub storage: u64, + /// Offset-index part count. + pub offset_index_parts: u64, + /// External copies (`uris`: a typed array on v2, a JSON string on v1). + pub uris: Vec, + /// Prior `packHash`es this manifest supersedes (parsed from the packed `byteArray`). + pub supersedes: Vec<[u8; 32]>, } /// Input for [`RepoService::write_pack_manifest`]. @@ -122,7 +88,7 @@ pub struct PackManifestInput { pub storage: u64, /// Offset-index part count (`≥ 1` for kind-0 packs, `0` for artifacts). pub offset_index_parts: u64, - /// External mirror URIs (serialized to the `uris` JSON-string field). + /// External mirror URIs. pub uris: Vec, /// Prior artifact `packHash`es this manifest makes redundant (repack supersedes plan). /// Serialized as one packed `byteArray` = concatenated 32-byte hashes. @@ -132,36 +98,6 @@ pub struct PackManifestInput { pub tips: Vec>, } -/// A `packManifest` document read back from a repo contract. -#[derive(Debug, Clone)] -pub struct PackManifestInfo { - /// The manifest document id. - pub document_id: String, - /// `$createdAt` (ms). With `document_id` this is the platform total order that - /// [`locator_pack_space`] turns into the locator's `packRef` space. - pub created_at: u64, - /// The base58 `$ownerId` of the manifest's creator (whose own docs are deletable). - pub owner_id: String, - /// SHA-256 pack hash. - pub pack_hash: [u8; 32], - /// Artifact kind. - pub kind: u64, - /// Pack size in bytes. - pub size_bytes: u64, - /// Object count. - pub object_count: u64, - /// Chunk count. - pub chunk_count: u64, - /// Storage tier. - pub storage: u64, - /// Offset-index part count. - pub offset_index_parts: u64, - /// External mirror URIs (parsed from the `uris` JSON-string field). - pub uris: Vec, - /// Prior `packHash`es this manifest supersedes (parsed from the packed `byteArray`). - pub supersedes: Vec<[u8; 32]>, -} - /// Live index fragments tolerated before a push folds them into one locator. /// /// Trades writer cost against reader fan-out: each fragment is one more artifact a browser @@ -173,12 +109,11 @@ const MAX_LOCATOR_FRAGMENTS: usize = 16; /// Where a repack writes the consolidated pack. #[derive(Clone, Copy, Default)] pub enum RepackTarget<'a> { - /// On-chain `chunk` docs (the default; the tier repack refunds reclaim from). + /// On-chain `chunk` docs (the default). #[default] Platform, /// An external backend (ipfs/s3/https) — migrates cold history outward. The pack is - /// hash-verifiable at its URIs; the on-chain refund still comes from deleting the - /// superseded platform chunks. + /// hash-verifiable at its URIs. External(&'a dyn PackBackend), /// A storage policy's targets, requiring `required` verified confirmations /// ([`crate::storage::replicate`]). This is what a `git push` writes through. @@ -190,11 +125,10 @@ pub enum RepackTarget<'a> { }, } -/// The maximum length of the `packManifest.uris` JSON string (repo-v1 schema). -pub const MANIFEST_URIS_MAX_LEN: usize = 2600; - -/// The maximum length of the `config.backend.uris` JSON string (repo-v1 schema). -pub const BACKEND_URIS_MAX_LEN: usize = 1300; +/// The `packManifest.uris` budget of a v2 repo: a typed array (forge-core schema). +pub const MANIFEST_URIS_V2: UriBudget = UriBudget::array(8, 300); +/// The `config.backend.uris` budget of a v2 repo (forge-core schema). +pub const BACKEND_URIS_V2: UriBudget = UriBudget::array(4, 300); /// How a manifest records an artifact stored through [`RepackTarget::Replicated`]: /// `storage` 0 when an on-chain copy exists (Platform-reading clients, including today's @@ -211,7 +145,8 @@ pub struct StoredArtifact { } impl StoredArtifact { - /// Derive the manifest fields from a successful replication of `bytes`. + /// Derive the manifest fields from a successful replication of `bytes` into a v2 + /// repository (the only kind anything is written to). pub fn from_replication(rep: &Replication, bytes: &[u8]) -> Result { let platform = rep.has_platform(); Ok(Self { @@ -221,32 +156,28 @@ impl StoredArtifact { } else { 0 }, - uris: rep.manifest_uris(MANIFEST_URIS_MAX_LEN)?, + uris: rep.manifest_uris(MANIFEST_URIS_V2)?, }) } } /// The Platform `chunk`-document tier as a [`StorageTarget`]. /// -/// This is the contract-model-specific end of the push seam: everything that knows chunks -/// are documents in a repo-v1 contract lives here, so a new contract generation replaces -/// this type and leaves the replication engine and the helper alone. -/// /// With a journal it uploads resumably (an interrupted push resumes without re-paying for -/// confirmed chunks); without one it rolls back a partial upload, because chunks no -/// manifest references are invisible to every deletion path and their deposit would be -/// stranded. Chunk writes are consensus-confirmed one by one, which is this tier's upload -/// verification — no re-read is needed. +/// confirmed chunks). On forge-v2 chunks are permanent and keyed by uploader, so a partial +/// upload is simply resumed (or re-uploaded idempotently) by the next push: chunk writes are +/// content-addressed by `(repoId, uploader, packHash, seq)`, so the retry of a stored chunk +/// is refused as a duplicate and treated as stored. pub struct PlatformChunkTarget<'s> { svc: &'s RepoService<'s>, - repo: &'s RepoHandle, + repo: &'s RepoRef, name: String, journal: Option<(std::sync::Mutex, &'s (dyn JournalStore + Sync))>, } impl<'s> PlatformChunkTarget<'s> { - /// A target writing `repo`'s chunks through `svc`, rolling back partial uploads. - pub fn new(svc: &'s RepoService<'s>, repo: &'s RepoHandle, name: impl Into) -> Self { + /// A target writing `repo`'s chunks through `svc`. + pub fn new(svc: &'s RepoService<'s>, repo: &'s RepoRef, name: impl Into) -> Self { Self { svc, repo, @@ -258,7 +189,7 @@ impl<'s> PlatformChunkTarget<'s> { /// A resumable target: confirmed chunks are checkpointed through `store`. pub fn resumable( svc: &'s RepoService<'s>, - repo: &'s RepoHandle, + repo: &'s RepoRef, name: impl Into, journal: PushJournal, store: &'s (dyn JournalStore + Sync), @@ -295,24 +226,7 @@ impl StorageTarget for PlatformChunkTarget<'_> { *journal.lock().map_err(|_| poisoned())? = j; return res; } - let pack_hash = meta.pack_hash_bytes()?; - match self.svc.put_pack(self.repo, bytes, meta).await { - Ok(u) => Ok(u), - Err(e) => { - match self.svc.delete_chunks(self.repo, pack_hash).await { - Ok(n) => { - tracing::debug!(reclaimed_chunks = n, "rolled back a partial chunk upload"); - } - Err(cleanup) => tracing::warn!( - error = %cleanup, - pack_hash = %meta.pack_hash, - "could not roll back a partial chunk upload; its deposit is stranded \ - until the repo is deleted" - ), - } - Err(e) - } - } + self.svc.put_pack(self.repo, bytes, meta).await } } @@ -349,7 +263,7 @@ struct ConsolidatedPack<'a> { tips: &'a [String], } -/// The result of [`RepoService::repack`] (architecture §4.2 repack/GC). +/// The result of [`RepoService::repack`] (consolidate-only on forge-v2). #[derive(Debug, Clone)] pub struct RepackReport { /// SHA-256 of the new consolidated pack. @@ -370,44 +284,30 @@ pub struct RepackReport { pub new_uris: Vec, /// How many prior packs the new manifest supersedes. pub superseded_count: usize, - /// Bytes of superseded pack storage the caller reclaimed (sum of deleted pack sizes). - pub bytes_reclaimed: u64, - /// Deleted `chunk` documents (caller-owned, WRITE-gated refund). - pub deleted_chunks: usize, - /// Deleted `packManifest` (+ `manifestPart`) documents (caller-owned). - pub deleted_manifests: usize, - /// Credits spent uploading the new consolidated pack + writing its manifest. - pub upload_cost_credits: u64, - /// Credits reclaimed by the superseded-doc deletions (the observed on-chain refund). - pub refund_credits: u64, - /// Net credit change over the whole repack (`refund − upload`; negative = net spend). - pub net_credits: i128, + /// Bytes of the packs the new manifest supersedes. They stay stored (readers fall + /// back to them); on your own external storage they may be garbage-collected. + pub superseded_bytes: u64, + /// Credits spent uploading the consolidated pack and writing its manifest(s). + pub cost_credits: u64, } -/// The result of [`RepoService::reseed`] (availability restore, architecture §5). -#[derive(Debug, Clone)] +/// The result of [`RepoService::reseed`]. +#[derive(Debug, Clone, Default)] pub struct ReseedReport { - /// Per-pack `(packHash, new URIs)` the reseed added. - pub reseeded: Vec<([u8; 32], Vec)>, - /// Whether the new URIs were persisted on-chain as `packMirror` docs (needs the - /// template-v2 `packMirror` type). `false` = the type is absent on this contract, so - /// the URIs are returned to the caller but not announced on Platform (see the method - /// docs for the fallback). - pub announced_on_chain: bool, - /// The number of `packMirror` documents written (0 when `announced_on_chain` is false). - pub mirror_docs_written: usize, + /// Every pack re-uploaded. + pub reseeded: Vec, } -/// One pack [`RepoService::reseed_from_local`] re-uploaded. +/// One pack [`RepoService::reseed`] re-uploaded. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct LocalReseed { +pub struct Reseeded { /// The pack. pub pack_hash: [u8; 32], - /// Every URI the confirmed copies are at now. + /// Where the new copy is. pub uris: Vec, - /// Whether one of them is a URI the manifest already records (the recorded copy is - /// readable again — the usual outcome when re-uploading through the original profile). - pub restored_recorded_uri: bool, + /// Whether the caller's own manifest now records it (`false`: the caller already had + /// one for this pack, and manifests are immutable). + pub announced: bool, } /// The result of [`RepoService::reseed_from_local`]. @@ -419,17 +319,28 @@ pub struct LocalReseedReport { pub healthy: Vec<[u8; 32]>, /// Packs that needed restoring but have no local copy in this clone. pub missing: Vec<[u8; 32]>, - /// Whether new locations could be announced on-chain (`packMirror` present). - pub announced_on_chain: bool, - /// `packMirror` docs written. - pub mirror_docs_written: usize, } -/// The repo-lifecycle service, bound to one owner identity and its keys. +/// One pack [`RepoService::reseed_from_local`] re-uploaded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalReseed { + /// The pack. + pub pack_hash: [u8; 32], + /// Every URI the confirmed copies are at now. + pub uris: Vec, + /// Whether one of them is a URI the manifest already records (the recorded copy is + /// readable again — the usual outcome when re-uploading through the original profile). + pub restored_recorded_uri: bool, +} + +/// A repository's current members as the pack reader rule needs them (maintainers' +/// copies first): empty on v1, where every copy is the repo contract's own. +type RoleMap = BTreeMap; + +/// The git data-plane service, bound to one signing identity and its keys. /// -/// Constructed per-operation-batch: it borrows a connected [`PlatformClient`], the -/// fetched owner [`LoadedIdentity`], and the owner's [`BridgeIdentity`] key material -/// (HIGH for document ops, CRITICAL for the token-bearing contract create — spike S0.7). +/// Constructed per operation batch: it borrows a connected [`PlatformClient`], the fetched +/// signer [`LoadedIdentity`] and its [`BridgeIdentity`] key material. pub struct RepoService<'a> { client: &'a PlatformClient, identity: &'a LoadedIdentity, @@ -437,7 +348,7 @@ pub struct RepoService<'a> { } impl<'a> RepoService<'a> { - /// Bind the service to `client`, the owner `identity`, and its `bridge` key material. + /// Bind the service to `client`, the signer `identity`, and its `bridge` key material. pub fn new( client: &'a PlatformClient, identity: &'a LoadedIdentity, @@ -450,221 +361,38 @@ impl<'a> RepoService<'a> { } } - /// A document write/delete engine bound to the owner, signing with the HIGH doc-op key. + /// A document write engine bound to the signer, signing with the HIGH doc-op key. fn doc_engine(&self) -> Result> { WriteEngine::new(self.client, self.identity, self.bridge.doc_op_key()?) } - /// Instantiate a repo: publish the repo-v1 contract (tokens auto-credit the owner), - /// write the initial `config`, and publish the registry `repoListing`. - /// - /// Returns a [`CreateRepoResult`] with the measured DataContractCreate cost. The - /// `config` and `repoListing` writes each spend one gated/ungated document create on - /// top (small platform fees, not the headline cost). - pub async fn create_repo(&self, name: &str, opts: &CreateRepoOpts) -> Result { - let normalized = normalize_name(name)?; - let owner_b58 = self.identity.id(); - - // No registry on this network: fail before paying for a contract whose listing - // could never be published. The idempotency guard below treats any resolve error as - // "does not exist yet", so it would not stop this on its own. - self.client.registry_contract_id()?; - - // Idempotency guard (financial safety): if a prior create already published this - // repo's listing, re-running must NOT pay for a second ~1 DASH contract. Resolve - // first and short-circuit to the existing handle (cost 0) when it already exists. - if let Ok(existing) = self.resolve_repo(&owner_b58, name).await { - tracing::warn!( - repo_contract = %existing.repo_contract_id, - "repo already exists; skipping create (idempotent, no double-pay)" - ); - return Ok(CreateRepoResult { - handle: existing, - repo_v1_instantiation_cost_credits: 0, - listing_document_id: String::new(), - }); - } - - let mut template: serde_json::Value = serde_json::from_str(REPO_V1_TEMPLATE) - .map_err(|e| Error::Config(format!("parsing repo-v1 template: {e}")))?; - // The committed `repo-v1.json` source is already the solo-owner, contiguous-position - // shape these two patches produce, so both are idempotent no-ops on it. They are kept - // as backward-compat safety nets: an org-shaped or globally-numbered template (an - // older on-disk source, or a future org variant) is still coerced to the deployable - // solo-owner shape rather than failing the DataContractCreate. See - // `apply_solo_owner_token_rules` / `normalize_document_positions` for the rules. - apply_solo_owner_token_rules(&mut template); - normalize_document_positions(&mut template); - - // 1. Publish the token-bearing contract (CRITICAL key). This is the headline cost. - let crit_key = self.bridge.token_admin_key()?; - let (repo_contract_id, create_cost) = self - .client - .contract_create(&template, self.identity, crit_key) - .await?; - tracing::info!( - repo_contract_id = %repo_contract_id, - cost_credits = create_cost, - cost_dash = credits_to_dash(create_cost), - "repo-v1 contract created (DataContractCreate)" - ); - - // 2 + 3: config + registry listing against the freshly created contract. - let listing_document_id = self - .finalize_repo(&repo_contract_id, name, &normalized, opts) - .await?; - - Ok(CreateRepoResult { - handle: RepoHandle { - repo_contract_id, - owner_id: owner_b58, - name: name.to_string(), - normalized_name: normalized, - }, - repo_v1_instantiation_cost_credits: create_cost, - listing_document_id, - }) - } - - /// Finish instantiating a repo whose contract already exists but whose follow-on - /// writes did not complete — write the `config` and registry `repoListing` against - /// `repo_contract_id`. This is the recovery path for a [`RepoService::create_repo`] - /// whose (already paid-for) DataContractCreate landed but a later step failed, so the - /// expensive create is never repeated. `repo_v1_instantiation_cost_credits` is `0` - /// (nothing new was created). - pub async fn resume_repo( - &self, - repo_contract_id: &str, - name: &str, - opts: &CreateRepoOpts, - ) -> Result { - let normalized = normalize_name(name)?; - let listing_document_id = self - .finalize_repo(repo_contract_id, name, &normalized, opts) - .await?; - Ok(CreateRepoResult { - handle: RepoHandle { - repo_contract_id: repo_contract_id.to_string(), - owner_id: self.identity.id(), - name: name.to_string(), - normalized_name: normalized, - }, - repo_v1_instantiation_cost_credits: 0, - listing_document_id, - }) - } - - /// Write the initial `config` (MAINTAIN-gated; owner holds MAINTAIN via baseSupply) - /// and the registry `repoListing` (ungated open create) for `repo_contract_id`, - /// returning the listing document id. - async fn finalize_repo( - &self, - repo_contract_id: &str, - name: &str, - normalized: &str, - opts: &CreateRepoOpts, - ) -> Result { - let repo_contract = self.client.fetch_contract(repo_contract_id).await?; - let engine = self.doc_engine()?; - - engine - .create_document( - &repo_contract, - DOC_CONFIG, - config_properties(&opts.default_branch, opts.backend_mode), - ) - .await?; - - let registry = self.client.fetch_registry().await?; - let repo_id_bytes = platform::decode_identifier(repo_contract_id)?; - let mut listing = BTreeMap::new(); - listing.insert("name".to_string(), FieldValue::text(name)); - listing.insert("normalizedName".to_string(), FieldValue::text(normalized)); - listing.insert( - "repoContractId".to_string(), - FieldValue::identifier(repo_id_bytes), - ); - listing.insert( - "templateVersion".to_string(), - FieldValue::integer(u64::from(opts.template_version)), - ); - listing.insert( - "description".to_string(), - FieldValue::text(opts.description.clone()), - ); - listing.insert("topics".to_string(), FieldValue::text("[]")); - engine - .create_document(®istry, DOC_REPO_LISTING, listing) - .await + /// The contract holding `repo`'s git data (forge-core, or the v1 repo contract), + /// fetched and registered with the proof verifier. + pub async fn repo_contract(&self, repo: &RepoRef) -> Result { + self.client.fetch_contract(&repo.scope()?.contract_id).await } - /// Resolve a repo by owner identity id (base58) and repo name via the registry - /// `repoListing` unique `(ownerId, normalizedName)` index. - pub async fn resolve_repo(&self, owner_id: &str, repo_name: &str) -> Result { - let normalized = normalize_name(repo_name)?; - let registry = self.client.fetch_registry().await?; - let owner_bytes = platform::decode_identifier(owner_id)?; - - let docs = self - .client - .query_documents( - ®istry, - DOC_REPO_LISTING, - &[ - QueryFilter::eq("$ownerId", FieldValue::identifier(owner_bytes)), - QueryFilter::eq("normalizedName", FieldValue::text(normalized.clone())), - ], - &[], - 1, - None, - ) - .await?; - - let listing = docs.into_iter().next().ok_or(Error::NotFound)?; - let repo_id_bytes = listing - .field_bytes("repoContractId") - .and_then(|b| <[u8; 32]>::try_from(b).ok()) - .ok_or_else(|| Error::Platform("repoListing missing repoContractId".into()))?; - let name = listing - .field_str("name") - .unwrap_or_else(|| repo_name.to_string()); - - Ok(RepoHandle { - repo_contract_id: platform::encode_identifier(repo_id_bytes), - owner_id: owner_id.to_string(), - name, - normalized_name: normalized, - }) + /// The scope and contract of `repo`, for reading (a repo this client can read). + async fn readable(&self, repo: &RepoRef) -> Result<(DocScope, LoadedContract)> { + repo.require_readable()?; + let scope = repo.scope()?; + let contract = self.client.fetch_contract(&scope.contract_id).await?; + Ok((scope, contract)) } - /// Resolve a repo by its **contract id**, bypassing the registry. - /// - /// The registry maps `(ownerId, normalizedName)` to a contract, and that is the right - /// lookup for a human-typed `dash://alice/project`. But a pull request points at its - /// source repo by contract id only — `patch.sourceContractId` — and there is no index - /// from a contract id back to its listing, so a reviewer holding a PR has a repo they - /// cannot address. This closes that: the contract itself carries its owner, which is - /// everything a [`RepoHandle`] needs for reads. - /// - /// The handle's `name` is the contract id, since no name is recoverable this way. It is - /// a display label; nothing in the read or transport path resolves by it. - pub async fn resolve_repo_by_contract(&self, repo_contract_id: &str) -> Result { - let contract = self.client.fetch_contract(repo_contract_id).await?; - Ok(RepoHandle { - repo_contract_id: contract.id(), - owner_id: contract.owner_id(), - name: contract.id(), - normalized_name: contract.id(), - }) + /// The writable (v2) scope and contract of `repo`, or [`Error::V1ReadOnly`]. + async fn writable(&self, repo: &RepoRef) -> Result<(DocScope, LoadedContract)> { + repo.require_v2()?; + self.readable(repo).await } /// Append a ref update. `new_oid` all-zero = ref deletion; `prev_oid` = the expected /// prior tip (for divergence detection). Protected refs (per the current `config` - /// patterns) route to the MAINTAIN-gated `protectedRefUpdate` type; everything else - /// is a WRITE-gated `refUpdate`. Returns the created document id. + /// patterns) route to the maintainer-gated `protectedRefUpdate`; everything else is a + /// member-gated `refUpdate`. Returns the created document id. pub async fn write_ref_update( &self, - repo: &RepoHandle, + repo: &RepoRef, ref_name: &str, new_oid: &[u8], prev_oid: Option<&[u8]>, @@ -679,43 +407,39 @@ impl<'a> RepoService<'a> { "illegal ref name {ref_name:?}: must be non-empty, no leading '-', no whitespace/control characters" ))); } + let (scope, contract) = self.writable(repo).await?; + let hasher = crate::private::Public; + let ref_name_hash = hasher.hash(ref_name); - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - let ref_name_hash = crate::backends::sha256(ref_name.as_bytes()); - - let configs = self.fetch_config_history(&repo_contract).await?; - let patterns = current_protected_patterns(&configs); - let protected = rules::matches_protected(ref_name, &patterns); + let configs = self.fetch_config_history(&scope, &contract).await?; + let protected = rules::matches_protected(ref_name, ¤t_protected_patterns(&configs)); let doc_type = if protected { DOC_PROTECTED_REF_UPDATE } else { DOC_REF_UPDATE }; - let mut props = BTreeMap::new(); - props.insert( - "refNameHash".to_string(), - FieldValue::bytes32(ref_name_hash), - ); - props.insert("refName".to_string(), FieldValue::text(ref_name)); - props.insert("newOid".to_string(), FieldValue::bytes(new_oid.to_vec())); + let mut props = scope.props([ + ("refNameHash", FieldValue::bytes32(ref_name_hash)), + ("refName", FieldValue::text(ref_name)), + ("newOid", FieldValue::bytes(new_oid.to_vec())), + ("force", FieldValue::boolean(force)), + ]); if let Some(prev) = prev_oid { - props.insert("prevOid".to_string(), FieldValue::bytes(prev.to_vec())); + props.insert("prevOid".into(), FieldValue::bytes(prev.to_vec())); } - props.insert("force".to_string(), FieldValue::boolean(force)); - self.doc_engine()? - .create_document(&repo_contract, doc_type, props) + .create_document(&contract, doc_type, props) .await } /// Enumerate every ref and its resolved [`RefState`]. /// /// Every ref's history comes from [`crate::refs::read_all_ref_updates`] — a keyset scan - /// over the `refState` index, ⌈updates/100⌉ queries per type plus one per ref that fills - /// a page by itself, with a `prevOid` completeness check — and each ref's combined - /// update history + the repo's `config` history is folded by - /// [`crate::rules::resolve_ref`]. + /// over the `refState` index (scoped to the repo: `(repoId, refNameHash, $createdAt)` on + /// forge-v2), ⌈updates/100⌉ queries per type plus one per ref that fills a page by itself, + /// with a `prevOid` completeness check — and each ref's combined update history + the + /// repo's `config` history is folded by [`crate::rules::resolve_ref`]. /// /// Two earlier readers failed in opposite ways. The S0.8 skip-scan (one `limit 1` query /// per ref per type, then one history read per ref) cost about four sequential @@ -725,15 +449,13 @@ impl<'a> RepoService<'a> { /// the whole `reflog` index was correct but read every update of every ref on every /// command, so it is kept only as the fallback the completeness check falls to. /// - /// The ancestry predicate is reflexive-only here (M1 has no read-side commit graph): + /// The ancestry predicate is reflexive-only here (no read-side commit graph): /// fast-forward supersession via `prevOid` still resolves, but descend-detection is - /// deferred to the push-side pipeline that has the object store. A single-tip ref (the - /// common case) resolves to [`RefState::Resolved`] correctly. - pub async fn read_refs(&self, repo: &RepoHandle) -> Result> { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - let configs = self.fetch_config_history(&repo_contract).await?; - - let by_hash = crate::refs::read_all_ref_updates(self.client, &repo_contract).await?; + /// deferred to the push-side pipeline that has the object store. + pub async fn read_refs(&self, repo: &RepoRef) -> Result> { + let (scope, contract) = self.readable(repo).await?; + let configs = self.fetch_config_history(&scope, &contract).await?; + let by_hash = crate::refs::read_all_ref_updates(self.client, &contract, &scope).await?; let mut out = Vec::with_capacity(by_hash.len()); for (hash, updates) in &by_hash { @@ -750,174 +472,132 @@ impl<'a> RepoService<'a> { Ok(out) } - /// Read the repo's current default branch from the newest `config` document (e.g. - /// `main`) — the branch `git-remote-dash` reports as the `HEAD` symref in `list`. - /// `None` when no `config` exists yet (a contract without an initial config). - pub async fn read_default_branch(&self, repo: &RepoHandle) -> Result> { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - let docs = self + /// The newest `config` document in `scope`, if any. + async fn newest_config( + &self, + scope: &DocScope, + contract: &LoadedContract, + ) -> Result> { + Ok(self .client .query_documents( - &repo_contract, + contract, DOC_CONFIG, - &[], + &scope.filters([]), &[QueryOrder::desc("$createdAt")], 1, None, ) - .await?; - Ok(docs + .await? .into_iter() - .next() - .and_then(|d| d.field_str("defaultBranch"))) + .next()) } - /// Append a new `config` document that carries `backend_mode` (`0` platform, `1` ipfs, - /// `2` s3, `3` https, `4` mixed), preserving the current `defaultBranch`, - /// `protectedPatterns`, backend `uris` and `archived` flag from the newest config (config - /// is append-only newest-wins, §2.2). MAINTAIN-gated (the owner holds MAINTAIN via - /// `baseSupply`). Returns the new config document id. This is the `dg repo backend set` - /// write path. - pub async fn set_backend_mode(&self, repo: &RepoHandle, backend_mode: u8) -> Result { - self.set_backend(repo, backend_mode, None).await + /// The repo's current default branch from the newest `config` (e.g. `main`) — the + /// branch `git-remote-dash` reports as the `HEAD` symref. `None` when there is none. + pub async fn read_default_branch(&self, repo: &RepoRef) -> Result> { + let (scope, contract) = self.readable(repo).await?; + Ok(self + .newest_config(&scope, &contract) + .await? + .and_then(|d| d.field_str("defaultBranch"))) } - /// [`Self::set_backend_mode`], optionally replacing the advertised read `uris` (the - /// public read bases of a storage policy — `dg storage advertise`). `None` keeps the - /// newest config's URIs. + /// Append a `config` carrying `backend_mode`, optionally replacing the advertised read + /// `uris` (the public read bases of a storage policy — `dg storage advertise`; `None` + /// keeps the newest config's). Default branch, protected patterns and the archived + /// flag carry over (config is append-only, newest wins). Maintainer-gated. pub async fn set_backend( &self, - repo: &RepoHandle, + repo: &RepoRef, backend_mode: u8, new_uris: Option<&[String]>, ) -> Result { - let replacement = match new_uris { - Some(list) => { - let json = serde_json::to_string(list) - .map_err(|e| Error::Config(format!("serializing backend uris: {e}")))?; - if json.len() > BACKEND_URIS_MAX_LEN { - return Err(Error::Config(format!( - "advertised URIs are {} bytes of JSON; config.backend.uris holds \ - {BACKEND_URIS_MAX_LEN}", - json.len() - ))); - } - Some(json) + if let Some(list) = new_uris { + if !BACKEND_URIS_V2.fits(list) { + return Err(Error::Config(format!( + "config.backend.uris holds at most {} URLs of at most {} bytes each", + BACKEND_URIS_V2.max_items.unwrap_or_default(), + BACKEND_URIS_V2.max_item_len.unwrap_or_default() + ))); } - None => None, - }; - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - let newest = self - .client - .query_documents( - &repo_contract, - DOC_CONFIG, - &[], - &[QueryOrder::desc("$createdAt")], - 1, - None, - ) - .await? - .into_iter() - .next(); - + } + let (scope, contract) = self.writable(repo).await?; + let newest = self.newest_config(&scope, &contract).await?; let default_branch = newest .as_ref() .and_then(|d| d.field_str("defaultBranch")) .unwrap_or_else(|| "main".to_string()); - let protected_patterns = newest + let patterns = newest .as_ref() - .and_then(|d| d.field_str("protectedPatterns")) - .unwrap_or_else(|| "[]".to_string()); + .map(|d| scope::doc_text_list(d, "protectedPatterns")) + .unwrap_or_default(); let archived = newest.as_ref().is_some_and(|d| d.field_bool("archived")); - let uris = replacement.unwrap_or_else(|| { - match newest.as_ref().and_then(|d| d.fields.get("backend")) { - Some(FieldValue::Object(backend)) => backend - .get("uris") - .and_then(FieldValue::as_str) - .unwrap_or("[]") - .to_string(), - _ => "[]".to_string(), - } - }); + let uris = match new_uris { + Some(list) => list.to_vec(), + None => newest.as_ref().map(scope::backend_uris).unwrap_or_default(), + }; - let mut props = BTreeMap::new(); - props.insert( - "defaultBranch".to_string(), - FieldValue::text(default_branch), - ); - props.insert( - "protectedPatterns".to_string(), - FieldValue::text(protected_patterns), - ); let mut backend = BTreeMap::new(); backend.insert( "mode".to_string(), FieldValue::integer(u64::from(backend_mode)), ); - backend.insert("uris".to_string(), FieldValue::text(uris)); - props.insert("backend".to_string(), FieldValue::Object(backend)); - props.insert("archived".to_string(), FieldValue::boolean(archived)); - + if !uris.is_empty() { + backend.insert("uris".to_string(), FieldValue::text_list(uris)); + } + let mut props = scope.props([ + ("defaultBranch", FieldValue::text(default_branch)), + ("backend", FieldValue::Object(backend)), + ("archived", FieldValue::boolean(archived)), + ]); + if !patterns.is_empty() { + props.insert("protectedPatterns".into(), FieldValue::text_list(patterns)); + } self.doc_engine()? - .create_document(&repo_contract, DOC_CONFIG, props) + .create_document(&contract, DOC_CONFIG, props) .await } - /// Write a `packManifest` document (WRITE-gated). Returns the manifest document id. + /// Write a `packManifest` (member-gated). Returns the manifest document id. pub async fn write_pack_manifest( &self, - repo: &RepoHandle, + repo: &RepoRef, manifest: &PackManifestInput, ) -> Result { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - let mut props = BTreeMap::new(); - props.insert( - "packHash".to_string(), - FieldValue::bytes32(manifest.pack_hash), - ); - props.insert("kind".to_string(), FieldValue::integer(manifest.kind)); - props.insert( - "sizeBytes".to_string(), - FieldValue::integer(manifest.size_bytes), - ); - props.insert( - "objectCount".to_string(), - FieldValue::integer(manifest.object_count), - ); - props.insert( - "chunkCount".to_string(), - FieldValue::integer(manifest.chunk_count), - ); - props.insert("storage".to_string(), FieldValue::integer(manifest.storage)); - props.insert( - "offsetIndexParts".to_string(), - FieldValue::integer(manifest.offset_index_parts), - ); + let (scope, contract) = self.writable(repo).await?; + let mut props = scope.props([ + ("packHash", FieldValue::bytes32(manifest.pack_hash)), + ("kind", FieldValue::integer(manifest.kind)), + ("sizeBytes", FieldValue::integer(manifest.size_bytes)), + ("objectCount", FieldValue::integer(manifest.object_count)), + ("chunkCount", FieldValue::integer(manifest.chunk_count)), + ("storage", FieldValue::integer(manifest.storage)), + ( + "offsetIndexParts", + FieldValue::integer(manifest.offset_index_parts), + ), + ]); if !manifest.uris.is_empty() { - let json = serde_json::to_string(&manifest.uris) - .map_err(|e| Error::Config(format!("serializing manifest uris: {e}")))?; - props.insert("uris".to_string(), FieldValue::text(json)); + if !MANIFEST_URIS_V2.fits(&manifest.uris) { + return Err(Error::Config( + "packManifest.uris holds at most 8 URIs of at most 300 bytes each".into(), + )); + } + props.insert("uris".into(), FieldValue::text_list(manifest.uris.clone())); } - // `tips` / `supersedes` are packed byteArrays (concatenated fixed-width entries), - // not JSON strings (data-contracts §2.3: byteArray fields, unlike `uris`). + // `tips` / `supersedes` are packed byteArrays (concatenated fixed-width entries). if !manifest.tips.is_empty() { - let mut packed = Vec::new(); - for oid in &manifest.tips { - packed.extend_from_slice(oid); - } - props.insert("tips".to_string(), FieldValue::bytes(packed)); + props.insert("tips".into(), FieldValue::bytes(manifest.tips.concat())); } if !manifest.supersedes.is_empty() { - let mut packed = Vec::with_capacity(manifest.supersedes.len() * 32); - for hash in &manifest.supersedes { - packed.extend_from_slice(hash); - } - props.insert("supersedes".to_string(), FieldValue::bytes(packed)); + props.insert( + "supersedes".into(), + FieldValue::bytes(manifest.supersedes.concat()), + ); } - self.doc_engine()? - .create_document(&repo_contract, DOC_PACK_MANIFEST, props) + .create_document(&contract, DOC_PACK_MANIFEST, props) .await } @@ -927,86 +607,79 @@ impl<'a> RepoService<'a> { /// downloads the union of every live kind-0 pack, and each push stores an *incremental* /// pack. Drop the oldest manifests — which is what a capped newest-first read does once /// a repo passes one page — and the initial import pack, holding the root objects and - /// the delta bases everything else is built against, falls out of the set. The clone - /// then fails to index rather than failing to be current. `repack` reads the same list, - /// so a truncated read would consolidate over an incomplete input and then delete the - /// chunks it superseded. - /// - pub async fn read_pack_manifests(&self, repo: &RepoHandle) -> Result> { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; + /// the delta bases everything else is built against, falls out of the set. + pub async fn read_pack_manifests(&self, repo: &RepoRef) -> Result> { + let (scope, contract) = self.readable(repo).await?; let docs = self .client .query_all_documents( - &repo_contract, + &contract, DOC_PACK_MANIFEST, - &[], + &scope.filters([]), &[QueryOrder::desc("$createdAt")], ) .await?; - docs.iter().map(manifest_info).collect() } - /// Read the `packManifest` for `pack_hash`, if one exists (the index is unique). - pub async fn read_pack_manifest( + /// Every manifest of `pack_hash` (on v2 each uploader may hold a copy; on v1 the index + /// is unique, so at most one). + pub async fn read_pack_copies( &self, - repo: &RepoHandle, + repo: &RepoRef, pack_hash: [u8; 32], - ) -> Result> { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; + ) -> Result> { + let (scope, contract) = self.readable(repo).await?; let docs = self .client - .query_documents( - &repo_contract, + .query_all_documents( + &contract, DOC_PACK_MANIFEST, - &[QueryFilter::eq("packHash", FieldValue::bytes32(pack_hash))], + &scope.filters([QueryFilter::eq("packHash", FieldValue::bytes32(pack_hash))]), &[], - 1, - None, ) .await?; - docs.first().map(manifest_info).transpose() + docs.iter().map(manifest_info).collect() } - /// Store pack `bytes` as pipelined `chunk` documents via [`PlatformBackend`], returning - /// the `platform://…` locator(s) the manifest should record. + /// Store pack `bytes` as pipelined `chunk` documents, returning the `platform://…` + /// locator the manifest records. pub async fn put_pack( &self, - repo: &RepoHandle, + repo: &RepoRef, bytes: &[u8], meta: &PackMeta, ) -> Result> { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; + let (scope, contract) = self.writable(repo).await?; let engine = self.doc_engine()?; - let backend = PlatformBackend::new(&engine, &repo_contract); - backend.put(bytes, meta).await + PlatformBackend::new(&engine, &contract, &scope, self.identity.id()) + .put(bytes, meta) + .await } /// Store pack `bytes` as `chunk` documents **resumably**: a [`PushJournal`] records the /// chunk seqs already confirmed and is checkpointed through `store` after each one, so an - /// interrupted push (kill -9 mid-upload) resumes by skipping the already-uploaded chunks - /// — total fees ≈ a single push (PRD 02 §A resumable-push acceptance). Idempotent even + /// interrupted push resumes by skipping the already-uploaded chunks. Idempotent even /// without a journal: a re-broadcast chunk that already landed collides on the unique - /// `(packHash, seq)` index → [`crate::platform::BroadcastOutcome::AlreadyExists`], never a - /// second charge. Returns the `platform://…` locator the manifest records. + /// chunk index → [`crate::platform::BroadcastOutcome::AlreadyExists`], never a second + /// charge. Returns the `platform://…` locator the manifest records. pub async fn put_pack_resumable( &self, - repo: &RepoHandle, + repo: &RepoRef, bytes: &[u8], meta: &PackMeta, journal: &mut PushJournal, store: &(dyn JournalStore + Sync), ) -> Result> { - use crate::backends::platform::{chunk_documents, CHUNK_DOC_TYPE, PLATFORM_SCHEME}; + use crate::backends::platform::{chunk_documents, CHUNK_DOC_TYPE}; - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; + let (scope, contract) = self.writable(repo).await?; let engine = self.doc_engine()?; let pack_hash = meta.pack_hash_bytes()?; // Test affordance, compiled only with `--features test-hooks`: abort after // uploading N fresh chunks to simulate a `kill -9` mid-push, so the resume path can - // be exercised deterministically end-to-end. The journal is already checkpointed - // for every chunk written before the abort. + // be exercised deterministically end-to-end. #[cfg(feature = "test-hooks")] let kill_after: Option = std::env::var("DASH_FORGE_KILL_AFTER_CHUNK") .ok() @@ -1017,12 +690,11 @@ impl<'a> RepoService<'a> { for (seq, props) in chunk_documents(bytes, pack_hash) { if journal.has(seq) { - // Confirmed by a prior (interrupted) attempt — skip, do not re-pay. tracing::debug!(seq, "chunk already journaled; skipping"); continue; } let prepared = engine - .create_landed(&repo_contract, CHUNK_DOC_TYPE, props) + .create_landed(&contract, CHUNK_DOC_TYPE, scope.scoped(props)) .await?; journal.record(&WriteIntent::for_prepared(seq, &prepared)); store.checkpoint(journal)?; @@ -1035,118 +707,141 @@ impl<'a> RepoService<'a> { ))); } } + Ok(vec![Uri( + scope.locator(&self.identity.id(), &meta.pack_hash) + )]) + } - Ok(vec![Uri(format!( - "{PLATFORM_SCHEME}://{}/{}", - repo_contract.id(), - meta.pack_hash - ))]) + /// The uploader → current role map the v2 pack reader rule ranks copies by. Empty on + /// v1 (a v1 pack has exactly one copy). + pub async fn copy_roles(&self, repo: &RepoRef) -> Result { + if repo.is_v1() { + return Ok(RoleMap::new()); + } + let members = crate::members::MemberReader::new(self.client) + .list(repo) + .await?; + let oracle = crate::members::oracle(&members); + Ok(members + .iter() + .filter_map(|m| { + oracle + .current_role(&m.identity_id) + .map(|r| (m.identity_id.clone(), r)) + }) + .collect()) } - /// Read pack bytes back from `chunk` documents via [`PlatformBackend`] (optionally a - /// byte range), reassembled by the pure `crate::pack::join`. - pub async fn get_pack( + /// Fetch a stored artifact's bytes with the user's read configuration (storage.toml + /// gateways and S3 profiles). See [`Self::fetch_artifact_from`]. + pub async fn fetch_artifact( &self, - repo: &RepoHandle, - uri: &Uri, - range: Option, + repo: &RepoRef, + manifest: &PackManifestInfo, + reader: &PackReader, ) -> Result> { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - self.get_pack_from(&repo_contract, uri, range).await + let contract = self.repo_contract(repo).await?; + self.fetch_artifact_from(repo, &contract, manifest, reader) + .await } - /// [`Self::get_pack`] against an already-fetched repo contract, for callers reading many - /// packs (a fetch downloads every stored pack) that should not re-fetch and re-verify - /// the same contract once per pack. - pub async fn get_pack_from( + /// Fetch one manifest's artifact, SHA-256-verified against it. + /// + /// External copies go first: every recorded URI, raced with `reader`'s IPFS gateway + /// list (cheap, and needs no Platform queries). The Platform `chunk` copy is the last + /// resort — used when no external copy verifies, or when the manifest records none. On + /// v2 that copy is the chunks *this manifest's uploader* wrote. + pub async fn fetch_artifact_from( &self, - repo_contract: &LoadedContract, - uri: &Uri, - range: Option, + repo: &RepoRef, + contract: &LoadedContract, + manifest: &PackManifestInfo, + reader: &PackReader, ) -> Result> { + let expected = hex::encode(manifest.pack_hash); + let has_chunks = manifest.storage == 0; + // Every body is capped at the manifest's size (0 = unknown on very old manifests). + let size = (manifest.size_bytes > 0).then_some(manifest.size_bytes); + if reader.has_candidates(&manifest.uris) { + // With chunks to fall back on, the external copies get a size-scaled budget + // after which no new candidate starts — dead gateways must not cost minutes per + // pack before the on-chain read, but a big pack streaming from a healthy mirror + // is not abandoned mid-transfer. + let budget = has_chunks.then(|| crate::storage::read::external_budget(size)); + match reader + .fetch_verified(&manifest.uris, &expected, size, budget) + .await + { + Ok(bytes) => return Ok(bytes), + Err(e) if !has_chunks => return Err(e), + Err(e) => tracing::info!( + pack = %expected, + error = %e, + "no external copy verified; reading Platform chunks" + ), + } + } else if !has_chunks { + return Err(Error::Io(format!( + "artifact {expected} is stored externally but its manifest records no URI this \ + client can read ({:?})", + manifest.uris + ))); + } + let scope = repo.scope()?; + let locator = Uri(scope.locator(&manifest.owner_id, &expected)); let engine = self.doc_engine()?; - let backend = PlatformBackend::new(&engine, repo_contract); - backend.get(uri, range).await - } - - /// Fetch (and register with the proof verifier) the repo's contract once, for use with - /// [`Self::get_pack_from`]. - pub async fn repo_contract(&self, repo: &RepoHandle) -> Result { - self.client.fetch_contract(&repo.repo_contract_id).await + let bytes = PlatformBackend::new(&engine, contract, &scope, self.identity.id()) + .get(&locator, None) + .await?; + if hex::encode(crate::backends::sha256(&bytes)) != expected { + return Err(Error::Integrity); + } + Ok(bytes) } - /// Delete a document by id from an arbitrary contract (used for teardown — chunks / - /// manifests / the registry listing refund; the contract and non-deletable audit docs - /// are permanent). - pub async fn delete_document( + /// Read `pack_hash` from the best copy that verifies (forge-v2 §4 reader rule): + /// `copies` are its manifests, tried maintainers' first, then writers', then former + /// members', each by `($createdAt, $id)`. Returns the bytes and the copy they came + /// from, or the last error when no copy verifies. + pub async fn fetch_best_copy<'m>( &self, - contract_id: &str, - document_type: &str, - document_id: &str, - ) -> Result<()> { - let contract = self.client.fetch_contract(contract_id).await?; - self.doc_engine()? - .delete_document(&contract, document_type, document_id) - .await - } - - /// Delete every `chunk` document for a pack (WRITE-gated refund), returning the count - /// removed. - pub async fn delete_chunks(&self, repo: &RepoHandle, pack_hash: [u8; 32]) -> Result { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - let engine = self.doc_engine()?; - let mut removed = 0; - loop { - let page = self - .client - .query_documents( - &repo_contract, - DOC_CHUNK, - &[QueryFilter::eq("packHash", FieldValue::bytes32(pack_hash))], - &[QueryOrder::asc("seq")], - 100, - None, - ) - .await?; - if page.is_empty() { - break; - } - for doc in &page { - engine - .delete_document(&repo_contract, DOC_CHUNK, &doc.id) - .await?; - removed += 1; + repo: &RepoRef, + contract: &LoadedContract, + copies: &[&'m PackManifestInfo], + roles: &RoleMap, + reader: &PackReader, + ) -> Result<(Vec, &'m PackManifestInfo)> { + let mut last = Error::NotFound; + for m in order_copies(copies, roles) { + match self.fetch_artifact_from(repo, contract, m, reader).await { + Ok(bytes) => return Ok((bytes, m)), + Err(e) => { + tracing::info!( + pack = %hex::encode(m.pack_hash), + uploader = %m.owner_id, + error = %e, + "pack copy did not verify; trying the next copy" + ); + last = e; + } } } - Ok(removed) + Err(last) } - /// Consolidate a repo's live packs into **one** optimized pack, publish it, and delete - /// the caller's own now-superseded storage for a refund (architecture §4.2 repack/GC). - /// - /// Flow (availability never dips — the new pack lands *before* anything is deleted): - /// 1. Resolve all refs → the reachable tip set; collect the live (non-superseded) - /// kind-0 `packManifest`s. - /// 2. Fetch each live pack's bytes (Platform chunks or external mirror) and rebuild one - /// consolidated, self-contained pack over the reachable graph - /// ([`crate::pack::repack_from_packs`]) — unreachable objects are GC'd. - /// 3. Upload it to `target` and write a new `packManifest` with `supersedes` = every - /// prior kind-0 packHash and the resolved ref tips. - /// 4. Delete the caller's own superseded `chunk` + `packManifest` (+ `manifestPart`) - /// docs — WRITE-gated, creator-only; a frozen identity's delete fails at consensus. - /// Balance is sampled around the deletes so the [`RepackReport`] carries the real - /// observed refund. + /// Consolidate a repo's live packs into **one** optimized pack and publish it with a + /// `supersedes` list. **Deletes nothing**: on forge-v2 chunks and manifests are + /// permanent, so the superseded packs stay readable as the fallback the reader rule + /// keeps (a hash proves a pack's bytes, not that it holds everything it replaces). /// - /// Only docs the caller **owns** are deleted (Platform enforces creator-only deletes; - /// this also filters client-side so a co-maintainer's packs are superseded-but-kept). - pub async fn repack( - &self, - repo: &RepoHandle, - target: RepackTarget<'_>, - ) -> Result { + /// Flow: resolve refs → reachable tips; fetch each live kind-0 pack (best copy); + /// rebuild one self-contained pack over the tips ([`crate::pack::repack_from_packs`]); + /// upload it to `target`; write its manifest (`supersedes` every live kind-0 pack, the + /// resolved tips); publish the consolidated browse index (best-effort). + pub async fn repack(&self, repo: &RepoRef, target: RepackTarget<'_>) -> Result { + let (_, contract) = self.writable(repo).await?; let caller = self.identity.id(); - // 1. Reachable tips + live kind-0 packs. let refs = self.read_refs(repo).await?; let tips = resolved_tip_oids(&refs); if tips.is_empty() { @@ -1162,10 +857,20 @@ impl<'a> RepoService<'a> { )); } - // 2. Fetch every live pack and rebuild one consolidated pack over the tips. - let mut pack_blobs = Vec::with_capacity(live.len()); - for m in &live { - pack_blobs.push(self.fetch_pack_bytes(repo, m).await?); + let roles = self.copy_roles(repo).await?; + let reader = PackReader::from_user_config(); + let mut pack_blobs = Vec::new(); + for (hash, copies) in group_by_hash(&live) { + let (bytes, _) = self + .fetch_best_copy(repo, &contract, &copies, &roles, &reader) + .await + .map_err(|e| { + Error::Io(format!( + "repack: pack {} is unreadable: {e}", + hex::encode(hash) + )) + })?; + pack_blobs.push(bytes); } let tip_refs: Vec<&str> = tips.iter().map(String::as_str).collect(); let consolidated = crate::pack::repack_from_packs(&pack_blobs, &tip_refs)?; @@ -1174,9 +879,7 @@ impl<'a> RepoService<'a> { let new_pack_hash = new_meta.pack_hash_bytes()?; let object_count = consolidated.parsed.object_count() as u64; - // Guard: if the consolidated pack collides with a still-live pack's hash (already a - // single optimal pack), there is nothing to gain and the unique-index write would - // fail — report it cleanly instead. + // Already a single optimal pack: nothing to gain, and nothing to write. if live.iter().any(|m| m.pack_hash == new_pack_hash) { return Err(Error::Config( "repack: the repo is already a single consolidated pack (nothing to do)".into(), @@ -1184,88 +887,56 @@ impl<'a> RepoService<'a> { } let balance_start = self.client.get_balance(&caller).await.unwrap_or(0); - - // 3. Upload the consolidated pack + write its manifest. let stored = self .store_consolidated(repo, &new_bytes, &new_meta, target) .await?; - let (storage, chunk_count) = (stored.storage, stored.chunk_count); - let new_uris: Vec = stored.uris.into_iter().map(Uri).collect(); + let new_uris = stored.uris.clone(); let (supersedes, new_manifest_id) = self .write_consolidated_manifest( repo, &manifests, - &caller, &ConsolidatedPack { pack_hash: new_pack_hash, size_bytes: new_bytes.len() as u64, object_count, - chunk_count, - storage, - uris: new_uris.iter().map(|u| u.0.clone()).collect(), + chunk_count: stored.chunk_count, + storage: stored.storage, + uris: stored.uris, tips: &tips, }, ) .await?; - - // 3b. Publish the objectLocator over the consolidated pack. Best-effort — see - // `publish_locator_best_effort` for why a failure here is reported, not unwound. let locator_manifest_id = self .publish_locator_best_effort(repo, &consolidated.parsed, new_pack_hash, target) .await; - - // 4. Delete the caller's own superseded storage (refund). The locators the new one - // supersedes are reclaimable too — but only if it actually landed, since otherwise - // the old index is still the live index and deleting it would leave the repo with - // no browse index at all. Sample balance around the deletes so the report is the - // *observed* on-chain refund, not an estimate. - let mut reclaimable = supersedes.clone(); - if locator_manifest_id.is_some() { - reclaimable.extend( - live_locator_manifests(&manifests) - .iter() - .map(|m| m.pack_hash), - ); - } - let balance_before_delete = self + let balance_end = self .client .get_balance(&caller) .await .unwrap_or(balance_start); - let (deleted_chunks, deleted_manifests, bytes_reclaimed) = self - .delete_superseded(repo, &manifests, &reclaimable, new_pack_hash, &caller) - .await; - let balance_after_delete = self - .client - .get_balance(&caller) - .await - .unwrap_or(balance_before_delete); - - let upload_cost_credits = balance_start.saturating_sub(balance_before_delete); - let refund_credits = balance_after_delete.saturating_sub(balance_before_delete); - let net_credits = i128::from(balance_after_delete) - i128::from(balance_start); + let superseded_bytes = supersedes + .iter() + .filter_map(|h| manifests.iter().find(|m| m.pack_hash == *h)) + .map(|m| m.size_bytes) + .sum(); Ok(RepackReport { new_pack_hash, new_manifest_id, locator_manifest_id, new_pack_bytes: new_bytes.len() as u64, object_count, - new_uris: new_uris.iter().map(|u| u.0.clone()).collect(), + new_uris, superseded_count: supersedes.len(), - bytes_reclaimed, - deleted_chunks, - deleted_manifests, - upload_cost_credits, - refund_credits, - net_credits, + superseded_bytes, + cost_credits: balance_start.saturating_sub(balance_end), }) } /// Store a repack's consolidated pack on `target`, returning the manifest fields. async fn store_consolidated( &self, - repo: &RepoHandle, + repo: &RepoRef, bytes: &[u8], meta: &PackMeta, target: RepackTarget<'_>, @@ -1275,22 +946,12 @@ impl<'a> RepoService<'a> { RepackTarget::Platform => StoredArtifact { storage: 0, chunk_count, - uris: self - .put_pack(repo, bytes, meta) - .await? - .into_iter() - .map(|u| u.0) - .collect(), + uris: uri_strings(self.put_pack(repo, bytes, meta).await?), }, RepackTarget::External(backend) => StoredArtifact { storage: 1, chunk_count: 0, - uris: backend - .put(bytes, meta) - .await? - .into_iter() - .map(|u| u.0) - .collect(), + uris: uri_strings(backend.put(bytes, meta).await?), }, RepackTarget::Replicated { targets, required } => { let rep = crate::storage::replicate(targets, bytes, meta, required) @@ -1302,16 +963,14 @@ impl<'a> RepoService<'a> { } /// Write the `packManifest` for a repack's consolidated pack, returning the - /// `supersedes` list it recorded (which the delete pass reclaims from) and the new - /// document id. + /// `supersedes` list it recorded and the new document id. async fn write_consolidated_manifest( &self, - repo: &RepoHandle, + repo: &RepoRef, manifests: &[PackManifestInfo], - caller: &str, pack: &ConsolidatedPack<'_>, ) -> Result<(Vec<[u8; 32]>, String)> { - let supersedes = repack_supersedes(manifests, pack.pack_hash, caller); + let supersedes = repack_supersedes(manifests, pack.pack_hash); let tip_oids: Vec> = pack .tips .iter() @@ -1329,9 +988,7 @@ impl<'a> RepoService<'a> { chunk_count: pack.chunk_count, storage: pack.storage, // 0 = no separate `manifestPart` offset-index doc written. The browse - // plane's index is the `objectLocator` published just below, not a - // second format — matching the incremental-push path. Never claim a - // part that was not stored. + // plane's index is the `objectLocator` published alongside. offset_index_parts: 0, uris: pack.uris.clone(), supersedes: supersedes.clone(), @@ -1342,115 +999,56 @@ impl<'a> RepoService<'a> { Ok((supersedes, id)) } - /// Delete the caller's own now-superseded storage for a repack: for each manifest the - /// caller owns that the new consolidated pack subsumes (every prior kind-0 pack, plus - /// anything explicitly in `supersedes` — which the repack extends with the index - /// fragments its new locator replaced, so those artifacts are reclaimed rather than - /// left paid-for and unreadable), remove its `chunk` + `manifestPart` + `packManifest` - /// docs (WRITE-gated, creator-only). Never touches the new pack or a co-maintainer's - /// docs. Returns `(deleted_chunks, deleted_manifests, bytes_reclaimed)`. - async fn delete_superseded( - &self, - repo: &RepoHandle, - manifests: &[PackManifestInfo], - supersedes: &[[u8; 32]], - new_pack_hash: [u8; 32], - caller: &str, - ) -> (usize, usize, u64) { - let mut deleted_chunks = 0usize; - let mut deleted_manifests = 0usize; - let mut bytes_reclaimed = 0u64; - for m in manifests { - if m.pack_hash == new_pack_hash || m.owner_id != caller { - continue; // never delete the new pack, or a co-maintainer's docs. - } - let is_superseded = m.kind == u64::from(crate::pack::KIND_GIT_PACK) - || supersedes.contains(&m.pack_hash); - if !is_superseded { - continue; - } - if let Ok(n) = self.delete_chunks(repo, m.pack_hash).await { - deleted_chunks += n; - } - deleted_chunks += self - .delete_manifest_parts(repo, m.pack_hash) - .await - .unwrap_or(0); - if self - .delete_document(&repo.repo_contract_id, DOC_PACK_MANIFEST, &m.document_id) - .await - .is_ok() - { - deleted_manifests += 1; - bytes_reclaimed += m.size_bytes; - } - } - (deleted_chunks, deleted_manifests, bytes_reclaimed) - } - - /// Re-upload each pack's bytes to another backend and announce the new availability - /// URIs (architecture §5 reseed). **Availability-only, un-gated: anyone with a clone - /// can reseed** — it never deletes and never changes integrity. + /// Re-upload each live pack to `target` and announce the new location (`dg reseed`). /// - /// For every live kind-0 pack: fetch the bytes (verifying the manifest hash), `put` - /// them to `target`, and record the returned URIs. Persistence of the new URIs on - /// Platform depends on the contract template: - /// - **`packMirror` present (template v2+):** writes a `packMirror` doc per pack - /// (`repoId`, `packHash`, `uris`) — the intended on-chain announcement. - /// - **absent (v1 template, e.g. the deployed testnet contract):** the `packManifest` - /// is immutable with a unique `packHash`, so a second manifest revision cannot carry - /// the URI — the reseed returns the URIs to the caller (for out-of-band pinning / - /// `--json` capture) and flags `announced_on_chain = false`. `packMirror` is the - /// template-v2 addition that closes this gap. - pub async fn reseed( - &self, - repo: &RepoHandle, - target: &dyn PackBackend, - ) -> Result { + /// Every pack is read from its best verifying copy and stored on `target`. On forge-v2 + /// each uploader may record its own manifest for a pack (the unique index includes + /// `$ownerId`), so the new location is announced by writing the caller's own copy — + /// `storage` 1, the new URIs — when the caller has none yet for that pack; readers + /// verify it by hash like any other copy. Packs the caller already holds a manifest for + /// are uploaded but not re-announced (a manifest is immutable). + pub async fn reseed(&self, repo: &RepoRef, target: &dyn PackBackend) -> Result { + let (_, contract) = self.writable(repo).await?; + let me = self.identity.id(); let manifests = self.read_pack_manifests(repo).await?; let live = live_kind0_manifests(&manifests); - - let mut reseeded = Vec::new(); - for m in &live { - let bytes = self.fetch_pack_bytes(repo, m).await?; - let meta = PackMeta { - pack_hash: hex::encode(m.pack_hash), - size: bytes.len() as u64, + let roles = self.copy_roles(repo).await?; + let reader = PackReader::from_user_config(); + let mut report = ReseedReport::default(); + for (hash, copies) in group_by_hash(&live) { + let (bytes, best) = self + .fetch_best_copy(repo, &contract, &copies, &roles, &reader) + .await?; + let meta = PackMeta::for_bytes(&bytes); + let uris = uri_strings(target.put(&bytes, &meta).await?); + let announced = if copies.iter().any(|m| m.owner_id == me) { + false + } else { + self.write_pack_manifest( + repo, + &PackManifestInput { + pack_hash: hash, + kind: best.kind, + size_bytes: best.size_bytes, + object_count: best.object_count, + chunk_count: 0, + storage: 1, + offset_index_parts: 0, + uris: uris.clone(), + supersedes: best.supersedes.clone(), + tips: Vec::new(), + }, + ) + .await?; + true }; - // Verify the fetched bytes before re-announcing them (never propagate corruption). - if hex::encode(crate::backends::sha256(&bytes)) != meta.pack_hash { - return Err(Error::Integrity); - } - let uris = target.put(&bytes, &meta).await?; - reseeded.push((m.pack_hash, uris.iter().map(|u| u.0.clone()).collect())); - } - - // Announce on-chain iff the contract carries the packMirror type. - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - let mut mirror_docs_written = 0usize; - let announced_on_chain = repo_contract.has_document_type(DOC_PACK_MIRROR); - if announced_on_chain { - let repo_id_bytes = platform::decode_identifier(&repo.repo_contract_id)?; - let engine = self.doc_engine()?; - for (pack_hash, uris) in &reseeded { - let mut props = BTreeMap::new(); - props.insert("repoId".to_string(), FieldValue::identifier(repo_id_bytes)); - props.insert("packHash".to_string(), FieldValue::bytes32(*pack_hash)); - let json = serde_json::to_string(uris) - .map_err(|e| Error::Config(format!("serializing packMirror uris: {e}")))?; - props.insert("uris".to_string(), FieldValue::text(json)); - engine - .create_document(&repo_contract, DOC_PACK_MIRROR, props) - .await?; - mirror_docs_written += 1; - } + report.reseeded.push(Reseeded { + pack_hash: hash, + uris, + announced, + }); } - - Ok(ReseedReport { - reseeded, - announced_on_chain, - mirror_docs_written, - }) + Ok(report) } /// Restore lost external copies from a LOCAL clone (`dg reseed --from-local`). @@ -1461,15 +1059,13 @@ impl<'a> RepoService<'a> { /// on `targets` (≥ `required` must confirm). Storage keys are content-addressed — S3 /// `…/packs/.pack`, the IPFS CID — so re-uploading through the SAME profile the /// pack was pushed with recreates the very URI the immutable manifest already records, - /// and readers find it again. Copies at new locations are announced as `packMirror` - /// docs when the contract has that type; on repo-v1 (no `packMirror`) they are only - /// returned, for the caller to print. + /// and readers find it again. /// /// Packs with no local copy are reported in `missing`; packs whose recorded copies - /// still verify are skipped unless `force`. + /// still verify are skipped unless `force`. Writes nothing to Platform. pub async fn reseed_from_local( &self, - repo: &RepoHandle, + repo: &RepoRef, git_dir: &std::path::Path, targets: &[&dyn StorageTarget], required: usize, @@ -1516,53 +1112,14 @@ impl<'a> RepoService<'a> { restored_recorded_uri: restored, }); } - - // Announce new locations where the contract allows it. - if contract.has_document_type(DOC_PACK_MIRROR) { - let repo_id_bytes = platform::decode_identifier(&repo.repo_contract_id)?; - let engine = self.doc_engine()?; - for r in &report.restored { - let fresh: Vec<&String> = r - .uris - .iter() - .filter(|u| { - !live - .iter() - .any(|m| m.pack_hash == r.pack_hash && m.uris.contains(u)) - }) - .collect(); - if fresh.is_empty() { - continue; - } - let mut props = BTreeMap::new(); - props.insert("repoId".to_string(), FieldValue::identifier(repo_id_bytes)); - props.insert("packHash".to_string(), FieldValue::bytes32(r.pack_hash)); - let json = serde_json::to_string(&fresh) - .map_err(|e| Error::Config(format!("serializing packMirror uris: {e}")))?; - props.insert("uris".to_string(), FieldValue::text(json)); - engine - .create_document(&contract, DOC_PACK_MIRROR, props) - .await?; - report.mirror_docs_written += 1; - } - report.announced_on_chain = true; - } Ok(report) } /// Publish the consolidated browse index for a repack, reporting failure as `None` - /// rather than unwinding it. - /// - /// A repack is the one place the whole index can be rebuilt from scratch, and doing so - /// collapses however many per-push fragments have accumulated back into one artifact — - /// so a cold browse is a single fetch again. See [`Self::publish_locator`]. - /// - /// Best-effort because by this point the repack has already landed and been paid for. - /// Failing the whole operation because the index could not be written would leave the - /// caller with a consolidated repo reported as a failure. + /// rather than unwinding it: the repack has already landed and been paid for. async fn publish_locator_best_effort( &self, - repo: &RepoHandle, + repo: &RepoRef, pack: &crate::pack::ParsedPack, pack_hash: [u8; 32], target: RepackTarget<'_>, @@ -1583,20 +1140,12 @@ impl<'a> RepoService<'a> { /// Build and publish an `objectLocator` (kind 1) over the consolidated repack pack, /// superseding every prior locator fragment. /// - /// The locator is the browse plane's index: a fanout header plus OID-sorted fixed-width - /// rows, so a single-object read is the header plus one ~1/256 slice instead of a - /// whole-pack download. See [`crate::pack::ObjectLocator`] for the row format and for - /// why `pack` must be locator-quality. - /// - /// `pack_ref` is read from a FRESH manifest list rather than assumed to be 0. The - /// consolidated pack normally is the only live pack — it supersedes every pack the - /// repack saw — but a push landing between this repack's manifest read and its write - /// stores a pack it could not supersede, with an earlier `$createdAt`, which takes - /// packRef 0. Hard-coding 0 would publish an index that names the wrong pack for every - /// row. + /// `pack_ref` is read from a FRESH manifest list rather than assumed to be 0: a push + /// landing between this repack's manifest read and its write stores a pack it could not + /// supersede, with an earlier `$createdAt`, which takes packRef 0. async fn publish_locator( &self, - repo: &RepoHandle, + repo: &RepoRef, pack: &crate::pack::ParsedPack, pack_hash: [u8; 32], target: RepackTarget<'_>, @@ -1626,24 +1175,16 @@ impl<'a> RepoService<'a> { /// Publish the browse-index fragment for a pack that a push just stored. /// /// The index is published in FRAGMENTS, one per stored pack, rather than as a single - /// whole-repo locator rewritten on every push. A locator row is 36 bytes per object, so - /// republishing the whole index on each push would charge a deposit proportional to the - /// repo on every push — on a 40k-object repo, ~1.4 MB of `chunk` documents to record a - /// one-file change. A fragment costs 36 bytes per object the push actually added, which - /// is the only cost that scales with what the user did. Readers merge the live - /// fragments ([`crate::pack::ObjectLocator::merge`]). - /// - /// Fan-out is bounded the other way by folding: once the live fragment count would - /// exceed [`MAX_LOCATOR_FRAGMENTS`], this fetches them, merges them with the new - /// fragment, and publishes ONE locator superseding the lot — so the whole-index - /// republish happens about once per [`MAX_LOCATOR_FRAGMENTS`] pushes instead of every - /// push, and a reader never faces an unbounded number of index artifacts. + /// whole-repo locator rewritten on every push: a fragment costs 36 bytes per object the + /// push actually added. Readers merge the live fragments + /// ([`crate::pack::ObjectLocator::merge`]). Once the live fragment count would exceed + /// [`MAX_LOCATOR_FRAGMENTS`], this folds them into ONE locator superseding the lot. /// /// Returns what it did, including the reasons it declined; a push has already landed by /// the time this runs, so nothing here is fatal to it. pub async fn publish_push_locator( &self, - repo: &RepoHandle, + repo: &RepoRef, pack: &crate::pack::ParsedPack, pack_hash: [u8; 32], target: RepackTarget<'_>, @@ -1673,26 +1214,28 @@ impl<'a> RepoService<'a> { } => (pack_ref, live_locators), }; - // Fold. Oldest-first for a stable row order; rows are keyed by `(oid, packRef)`, so - // the merge result does not actually depend on it. + // Fold. Oldest-first for a stable row order; rows are keyed by `(oid, packRef)`. + let contract = self.repo_contract(repo).await?; + let reader = PackReader::from_user_config(); let mut parts = Vec::with_capacity(live_locators.len() + 1); for m in live_locators.iter().rev() { - let bytes = self.fetch_pack_bytes(repo, m).await?; + let bytes = self + .fetch_artifact_from(repo, &contract, m, &reader) + .await?; parts.push(crate::pack::ObjectLocator::parse(&bytes)?); } parts.push(crate::pack::ObjectLocator::build(pack, pack_ref)?); let folded = crate::pack::ObjectLocator::merge(&parts.iter().collect::>()); - // The fold is only as sound as the fragments it absorbed. A row naming a pack past - // the end of the live space means one of them was built over a different space than - // the prefix check accepted — publish nothing rather than supersede the parts with - // an index that addresses packs the reader cannot resolve. + // A row naming a pack past the end of the live space means one fragment was built + // over a different space — publish nothing rather than supersede the parts with an + // index that addresses packs the reader cannot resolve. if folded .max_pack_ref() .is_some_and(|r| usize::from(r) >= space_len) { return Ok(PushIndexOutcome::Skipped( "a published index fragment addresses a pack outside the live pack set — \ - run `dg maint repack` to rebuild the index" + run `dg repack` to rebuild the index" .into(), )); } @@ -1709,7 +1252,7 @@ impl<'a> RepoService<'a> { /// Upload a locator artifact and record its `packManifest` (kind 1). async fn store_locator( &self, - repo: &RepoHandle, + repo: &RepoRef, locator: &crate::pack::ObjectLocator, supersedes: Vec<[u8; 32]>, target: RepackTarget<'_>, @@ -1717,43 +1260,15 @@ impl<'a> RepoService<'a> { let bytes = locator.as_bytes().to_vec(); let meta = PackMeta::for_bytes(&bytes); let pack_hash = meta.pack_hash_bytes()?; - let chunk_count = crate::pack::split(&bytes).len() as u64; - let stored = match target { - RepackTarget::Platform => { - // Roll back a partial upload (see [`PlatformChunkTarget`]): there is no - // journal to resume from, and chunks no manifest references are invisible - // to every deletion path. - let t = PlatformChunkTarget::new(self, repo, crate::storage::PLATFORM_PROFILE); - StoredArtifact { - storage: 0, - chunk_count, - uris: t - .store(&bytes, &meta) - .await? - .into_iter() - .map(|u| u.0) - .collect(), - } - } - RepackTarget::External(backend) => StoredArtifact { - storage: 1, - chunk_count: 0, - uris: backend - .put(&bytes, &meta) - .await? - .into_iter() - .map(|u| u.0) - .collect(), - }, RepackTarget::Replicated { targets, required } => { let rep = crate::storage::replicate(targets, &bytes, &meta, required) .await .map_err(|e| Error::Io(format!("browse index: {e}")))?; StoredArtifact::from_replication(&rep, &bytes)? } + other => self.store_consolidated(repo, &bytes, &meta, other).await?, }; - self.write_pack_manifest( repo, &PackManifestInput { @@ -1774,175 +1289,6 @@ impl<'a> RepoService<'a> { .await } - /// Read the extra availability URIs announced via `packMirror` docs, grouped by - /// packHash. Empty when the contract has no `packMirror` type (v1 template) — so - /// `dg storage status` can fold mirror URIs into its probe set without erroring on an - /// older contract. - pub async fn read_pack_mirrors( - &self, - repo: &RepoHandle, - ) -> Result)>> { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - if !repo_contract.has_document_type(DOC_PACK_MIRROR) { - return Ok(Vec::new()); - } - // Complete: mirrors are the availability fallback set, and a capped read would drop - // whole packs' alternate URIs from `dg storage status`'s probe set while reporting - // the packs it did see as fully probed. - let docs = self - .client - .query_all_documents( - &repo_contract, - DOC_PACK_MIRROR, - &[], - &[QueryOrder::desc("$createdAt")], - ) - .await?; - let mut out = Vec::new(); - for d in &docs { - let Some(pack_hash) = d - .field_bytes("packHash") - .and_then(|b| <[u8; 32]>::try_from(b).ok()) - else { - continue; - }; - let uris = d - .field_str("uris") - .and_then(|s| serde_json::from_str::>(&s).ok()) - .unwrap_or_default(); - out.push((pack_hash, uris)); - } - Ok(out) - } - - /// Fetch a stored artifact's bytes with the user's read configuration (storage.toml - /// gateways and S3 profiles). See [`Self::fetch_artifact`]. - async fn fetch_pack_bytes( - &self, - repo: &RepoHandle, - manifest: &PackManifestInfo, - ) -> Result> { - let contract = self.repo_contract(repo).await?; - self.fetch_manifest_pack(repo, &contract, manifest).await - } - - /// [`Self::fetch_pack_bytes`] against an already-fetched repo contract, with the user's - /// read configuration. See [`Self::fetch_artifact_from`]. - pub async fn fetch_manifest_pack( - &self, - repo: &RepoHandle, - repo_contract: &LoadedContract, - manifest: &PackManifestInfo, - ) -> Result> { - self.fetch_artifact_from( - repo, - repo_contract, - manifest, - &PackReader::from_user_config(), - ) - .await - } - - /// Fetch a stored artifact's bytes, SHA-256-verified against its manifest. - /// - /// External copies go first: every recorded URI, raced with `reader`'s IPFS gateway - /// list (cheap, and needs no Platform queries). Platform `chunk` documents are the last - /// resort — used when no external copy verifies, or when the manifest records none. - /// An external-only manifest whose copies are all gone fails with every candidate's - /// reason. - pub async fn fetch_artifact( - &self, - repo: &RepoHandle, - manifest: &PackManifestInfo, - reader: &PackReader, - ) -> Result> { - let contract = self.repo_contract(repo).await?; - self.fetch_artifact_from(repo, &contract, manifest, reader) - .await - } - - /// [`Self::fetch_artifact`] against an already-fetched repo contract (a fetch reads - /// many packs and should not re-fetch the contract per pack). - pub async fn fetch_artifact_from( - &self, - repo: &RepoHandle, - repo_contract: &LoadedContract, - manifest: &PackManifestInfo, - reader: &PackReader, - ) -> Result> { - let expected = hex::encode(manifest.pack_hash); - let has_chunks = manifest.storage == 0; - // Every body is capped at the manifest's size (0 = unknown on very old manifests). - let size = (manifest.size_bytes > 0).then_some(manifest.size_bytes); - if reader.has_candidates(&manifest.uris) { - // With chunks to fall back on, the external copies get a size-scaled budget - // after which no new candidate starts — dead gateways must not cost minutes per - // pack before the on-chain read, but a big pack streaming from a healthy mirror - // is not abandoned mid-transfer. - let budget = has_chunks.then(|| crate::storage::read::external_budget(size)); - match reader - .fetch_verified(&manifest.uris, &expected, size, budget) - .await - { - Ok(bytes) => return Ok(bytes), - Err(e) if !has_chunks => return Err(e), - Err(e) => tracing::info!( - pack = %expected, - error = %e, - "no external copy verified; reading Platform chunks" - ), - } - } else if !has_chunks { - return Err(Error::Io(format!( - "artifact {expected} is stored externally but its manifest records no URI this \ - client can read ({:?})", - manifest.uris - ))); - } - let locator = Uri(format!( - "{}://{}/{}", - crate::backends::PLATFORM_SCHEME, - repo.repo_contract_id, - expected, - )); - let bytes = self.get_pack_from(repo_contract, &locator, None).await?; - if hex::encode(crate::backends::sha256(&bytes)) != expected { - return Err(Error::Integrity); - } - Ok(bytes) - } - - /// Delete every `manifestPart` document for a pack (WRITE-gated refund), returning the - /// count removed. A no-op for packs whose offset index fit in the manifest. - async fn delete_manifest_parts(&self, repo: &RepoHandle, pack_hash: [u8; 32]) -> Result { - let repo_contract = self.client.fetch_contract(&repo.repo_contract_id).await?; - let engine = self.doc_engine()?; - let mut removed = 0; - loop { - let page = self - .client - .query_documents( - &repo_contract, - DOC_MANIFEST_PART, - &[QueryFilter::eq("packHash", FieldValue::bytes32(pack_hash))], - &[QueryOrder::asc("partSeq")], - 100, - None, - ) - .await?; - if page.is_empty() { - break; - } - for doc in &page { - engine - .delete_document(&repo_contract, DOC_MANIFEST_PART, &doc.id) - .await?; - removed += 1; - } - } - Ok(removed) - } - // --- internal read helpers --- /// The repo's **complete** `config` history (append-only, non-deletable), as @@ -1951,17 +1297,19 @@ impl<'a> RepoService<'a> { /// Paged to exhaustion. `config_as_of` treats "no config in force at time T" as /// UNPROTECTED, so a truncated history does not merely go stale — it silently /// re-admits plain `refUpdate`s on protected refs that the rules layer had correctly - /// rendered inert. It also has to be complete for cross-client agreement: forge-web - /// reads the same timeline, and if the two clients hold different slices of it they - /// resolve the same ref differently, which is precisely what FORGE_RULES_V1 exists to - /// prevent. - async fn fetch_config_history(&self, repo_contract: &LoadedContract) -> Result> { + /// rendered inert. forge-web reads the same timeline, and the two clients must fold the + /// same input. + async fn fetch_config_history( + &self, + scope: &DocScope, + contract: &LoadedContract, + ) -> Result> { let docs = self .client .query_all_documents( - repo_contract, + contract, DOC_CONFIG, - &[], + &scope.filters([]), &[QueryOrder::asc("$createdAt")], ) .await?; @@ -1970,133 +1318,48 @@ impl<'a> RepoService<'a> { .map(|d| ConfigDoc { id: d.id.clone(), created_at: d.created_at.unwrap_or(0), - protected_patterns: d - .field_str("protectedPatterns") - .and_then(|s| serde_json::from_str::>(&s).ok()) - .unwrap_or_default(), + protected_patterns: scope::doc_text_list(d, "protectedPatterns"), }) .collect()) } } -/// The initial `config` document properties (`defaultBranch`, empty protected patterns, -/// backend mode, not archived). -fn config_properties(default_branch: &str, backend_mode: u8) -> BTreeMap { - let mut props = BTreeMap::new(); - props.insert( - "defaultBranch".to_string(), - FieldValue::text(default_branch), - ); - props.insert("protectedPatterns".to_string(), FieldValue::text("[]")); - let mut backend = BTreeMap::new(); - backend.insert( - "mode".to_string(), - FieldValue::integer(u64::from(backend_mode)), - ); - backend.insert("uris".to_string(), FieldValue::text("[]")); - props.insert("backend".to_string(), FieldValue::Object(backend)); - props.insert("archived".to_string(), FieldValue::boolean(false)); - props -} - -/// Rewrite a repo template's tokens for a **solo-owner** repo: every token-admin rule -/// that points at `MainGroup` is re-pointed at `ContractOwner`, `mainControlGroup` is -/// cleared, and the top-level `groups` object is dropped. -/// -/// The committed repo-v1 template targets an org repo (a control group holds -/// mint/freeze/destroy). Platform requires a group to have ≥ 2 members -/// (`GroupHasTooFewMembersError`), which a single owner cannot satisfy — so a solo repo -/// instantiates with the owner as the sole token authority (the S0.7-validated shape). -/// Org repos (a multi-principal owner) are a documented follow-up that keeps the group. -fn apply_solo_owner_token_rules(template: &mut serde_json::Value) { - if let Some(obj) = template.as_object_mut() { - obj.remove("groups"); - } - let Some(tokens) = template.get_mut("tokens").and_then(|t| t.as_object_mut()) else { - return; - }; - for token in tokens.values_mut() { - repoint_group_rules_to_owner(token); - } -} - -/// Recursively replace `MainGroup` action-taker values with `ContractOwner` and null out -/// any `mainControlGroup` reference within a token configuration. -fn repoint_group_rules_to_owner(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - for (key, v) in map.iter_mut() { - if (key == "authorizedToMakeChange" || key == "adminActionTakers") - && v.as_str() == Some("MainGroup") - { - *v = serde_json::Value::String("ContractOwner".to_string()); - } else if key == "mainControlGroup" { - *v = serde_json::Value::Null; - } else { - repoint_group_rules_to_owner(v); - } - } - } - serde_json::Value::Array(items) => { - for item in items { - repoint_group_rules_to_owner(item); - } - } - _ => {} - } +/// The `String`s of a list of [`Uri`]s. +fn uri_strings(uris: Vec) -> Vec { + uris.into_iter().map(|u| u.0).collect() } -/// Renumber every doc-type schema's `position` fields so each object level is -/// contiguous 0..N (in existing position order). -/// -/// Native rs-dpp validates that a document type's **top-level** property positions run -/// `0..N` with no gaps (`MissingPositionsInDocumentTypePropertiesError`). The committed -/// repo-v1 template numbers positions globally — a nested `imported`/`backend` object's -/// children take positions in the *parent's* sequence, so the parent object then jumps -/// past them (e.g. `comment`: top-level 0-6, then `imported` at 10), leaving a gap. -/// Renumbering per object level makes it valid without changing field identity (fields -/// are addressed by name, not position, in every write path). -fn normalize_document_positions(template: &mut serde_json::Value) { - let Some(schemas) = template - .get_mut("documentSchemas") - .and_then(|v| v.as_object_mut()) - else { - return; - }; - for schema in schemas.values_mut() { - renumber_object_positions(schema); +/// Group manifests by pack hash (every copy of one pack together), in hash order. +pub fn group_by_hash(manifests: &[PackManifestInfo]) -> Vec<([u8; 32], Vec<&PackManifestInfo>)> { + let mut groups: BTreeMap<[u8; 32], Vec<&PackManifestInfo>> = BTreeMap::new(); + for m in manifests { + groups.entry(m.pack_hash).or_default().push(m); } + groups.into_iter().collect() } -/// Renumber one object schema's direct `properties` to contiguous 0-based positions (in -/// current position order), recursing into nested object properties. -fn renumber_object_positions(schema: &mut serde_json::Value) { - let Some(props) = schema.get_mut("properties").and_then(|v| v.as_object_mut()) else { - return; - }; - let mut order: Vec<(String, u64)> = props +/// `copies` of one pack in the order the forge-v2 reader rule tries them +/// ([`crate::rules::v2::order_pack_copies`]): uploaders who are currently maintainers, +/// then writers, then anyone else, each by `($createdAt, $id)`. +pub(crate) fn order_copies<'m>( + copies: &[&'m PackManifestInfo], + roles: &RoleMap, +) -> Vec<&'m PackManifestInfo> { + let as_rule: Vec = copies .iter() - .map(|(k, v)| { - ( - k.clone(), - v.get("position") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - ) + .map(|m| PackCopy { + id: m.document_id.clone(), + pack_hash: hex::encode(m.pack_hash), + owner_role: roles.get(&m.owner_id).copied(), + created_at: m.created_at, + verified: true, + supersedes: Vec::new(), }) .collect(); - order.sort_by_key(|(_, pos)| *pos); - for (new_pos, (key, _)) in order.into_iter().enumerate() { - if let Some(prop) = props.get_mut(&key) { - if let Some(obj) = prop.as_object_mut() { - obj.insert( - "position".to_string(), - serde_json::Value::from(new_pos as u64), - ); - } - renumber_object_positions(prop); - } - } + crate::rules::v2::order_pack_copies(&as_rule) + .into_iter() + .filter_map(|c| copies.iter().find(|m| m.document_id == c.id).copied()) + .collect() } /// Collect the distinct hex OIDs a repo's resolved refs point at — the reachable tip set @@ -2125,71 +1388,22 @@ fn resolved_tip_oids(refs: &[(String, RefState)]) -> Vec { out } -/// Pack hashes a repack's consolidated manifest must list in `supersedes`. -/// -/// `supersedes` is a packed byteArray capped at 1024 bytes — **32 hashes** — so this is a -/// budget, not a dump, and what fills it is chosen by what breaks if it is left out. +/// Pack hashes a repack's consolidated manifest lists in `supersedes`: every live kind-0 +/// pack except the new one, oldest first. /// -/// Liveness is derived solely from the `supersedes` lists of manifests that still EXIST -/// ([`live_kind0_manifests`]), so a hash drops out of the record the moment the only -/// manifest naming it is deleted. Two groups therefore need slots, in this order: -/// -/// 1. **Not the caller's, and currently superseded.** These are zombies waiting to happen: -/// the document that supersedes such a pack is the caller's previous consolidated -/// manifest, which the delete pass is about to remove. Drop the hash here and the pack -/// rejoins the live set at packRef 0 — it is the oldest — silently invalidating the -/// locator this repack publishes. -/// 2. **Not the caller's, and live.** Platform allows creator-only deletes, so these -/// manifests survive the repack; only this list records that the consolidated pack -/// subsumes them. -/// 3. **The caller's own live packs.** Their manifests are deleted moments later, which -/// removes them from the live set by itself — but the locator is published *before* the -/// deletes, so they must be superseded for the pack space to be right at that moment. -/// -/// Past 32 the list is truncated in that order and the caller is warned: the repack still -/// consolidates and still refunds, but a pack it could not name stays live, so the index it -/// publishes will read as behind until a later repack can name it. -fn repack_supersedes( - manifests: &[PackManifestInfo], - new_pack_hash: [u8; 32], - caller: &str, -) -> Vec<[u8; 32]> { - /// `packManifest.supersedes` is a byteArray of at most 1024 bytes (data-contracts §2.3). +/// `supersedes` is a packed byteArray capped at 1024 bytes — **32 hashes**. On forge-v2 +/// nothing is deleted, so a pack superseded by an earlier repack stays superseded (the +/// manifest that says so is permanent) and needs no slot here. Past 32 the list is +/// truncated and the caller warned: the repack still consolidates, but a pack it could not +/// name stays live, so the browse index reads as behind until a later repack names it. +fn repack_supersedes(manifests: &[PackManifestInfo], new_pack_hash: [u8; 32]) -> Vec<[u8; 32]> { + /// `packManifest.supersedes` is a byteArray of at most 1024 bytes. const MAX_SUPERSEDES: usize = 1024 / 32; - - let live: BTreeSet<[u8; 32]> = live_kind0_manifests(manifests) - .iter() - .map(|m| m.pack_hash) - .collect(); - let kind0 = |m: &&PackManifestInfo| { - m.kind == u64::from(crate::pack::KIND_GIT_PACK) && m.pack_hash != new_pack_hash - }; let mut out: Vec<[u8; 32]> = Vec::new(); - let push = |h: [u8; 32], out: &mut Vec<[u8; 32]>| { - if !out.contains(&h) { - out.push(h); + for m in locator_pack_space(manifests, None) { + if m.pack_hash != new_pack_hash && !out.contains(&m.pack_hash) { + out.push(m.pack_hash); } - }; - for m in manifests - .iter() - .filter(kind0) - .filter(|m| m.owner_id != caller && !live.contains(&m.pack_hash)) - { - push(m.pack_hash, &mut out); - } - for m in manifests - .iter() - .filter(kind0) - .filter(|m| m.owner_id != caller && live.contains(&m.pack_hash)) - { - push(m.pack_hash, &mut out); - } - for m in manifests - .iter() - .filter(kind0) - .filter(|m| m.owner_id == caller && live.contains(&m.pack_hash)) - { - push(m.pack_hash, &mut out); } if out.len() > MAX_SUPERSEDES { tracing::warn!( @@ -2282,13 +1496,14 @@ fn plan_push_index(manifests: &[PackManifestInfo], pack_hash: [u8; 32]) -> PushI /// * *as of* — a locator only indexes packs that existed when it was built; later /// incremental packs are outside its space. `None` means "as of now". /// * *live* — a repack consolidates several packs into one and marks the originals -/// `supersedes`. Counting superseded packs would leave every index shifted by however many -/// of them happen to survive, and survival is incidental: [`RepoService::repack`] deletes -/// only the CALLER's own manifests, so in a multi-author repo some remain. Liveness is +/// `supersedes`. Superseded manifests survive (v2 manifests are permanent; v1 repacks +/// deleted only the caller's own), so counting them would shift every index. Liveness is /// computed WITHIN the as-of bound, so a later repack cannot retroactively change what an /// older locator meant. /// * *oldest-first by `($createdAt, $id)`* — the platform total order, not a reversed /// `$createdAt desc` query, which drops the `$id` tiebreak on equal timestamps. +/// * *one entry per pack* — on forge-v2 each uploader has its own copy of a pack; a pack's +/// position is that of its earliest copy. pub fn locator_pack_space( manifests: &[PackManifestInfo], as_of: Option, @@ -2307,19 +1522,19 @@ pub fn locator_pack_space( .cmp(&b.created_at) .then_with(|| a.document_id.cmp(&b.document_id)) }); + // forge-v2: several uploaders may each hold a copy of one pack. The space has one entry + // per pack, placed at its first copy — a later copy does not shift existing packRefs. + let mut seen = BTreeSet::new(); + live.retain(|m| seen.insert(m.pack_hash)); live } /// Decode a `packManifest` document. -fn manifest_info(d: &platform::FetchedDocument) -> Result { +fn manifest_info(d: &FetchedDocument) -> Result { let pack_hash = d - .field_bytes("packHash") - .and_then(|b| <[u8; 32]>::try_from(b).ok()) + .field_bytes32("packHash") .ok_or_else(|| Error::Platform("packManifest missing packHash".into()))?; - let uris = d - .field_str("uris") - .and_then(|s| serde_json::from_str::>(&s).ok()) - .unwrap_or_default(); + let uris = scope::doc_text_list(d, "uris"); // `supersedes` is a packed byteArray of concatenated 32-byte packHashes. let supersedes = d .field_bytes("supersedes") @@ -2374,7 +1589,8 @@ fn live_locator_manifests(manifests: &[PackManifestInfo]) -> Vec Vec { let superseded: BTreeSet<[u8; 32]> = manifests @@ -2408,32 +1624,14 @@ fn current_protected_patterns(configs: &[ConfigDoc]) -> Vec { .unwrap_or_default() } -/// Normalize and validate a repo name against the registry -/// `^[a-z0-9][a-z0-9._-]{0,62}$` pattern (lowercased first). -fn normalize_name(name: &str) -> Result { - let normalized = name.to_ascii_lowercase(); - let bytes = normalized.as_bytes(); - let valid = (1..=63).contains(&bytes.len()) - && bytes[0].is_ascii_alphanumeric() - && bytes[1..] - .iter() - .all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')); - if valid { - Ok(normalized) - } else { - Err(Error::Config(format!( - "invalid repo name '{name}': must match ^[a-z0-9][a-z0-9._-]{{0,62}}$ after lowercasing" - ))) - } -} - #[cfg(test)] mod tests { use super::{ - current_protected_patterns, live_locator_manifests, locator_pack_space, normalize_name, - plan_push_index, repack_supersedes, PackManifestInfo, PushIndexPlan, MAX_LOCATOR_FRAGMENTS, - REPO_V1_TEMPLATE, + current_protected_patterns, group_by_hash, live_locator_manifests, locator_pack_space, + order_copies, plan_push_index, repack_supersedes, PackManifestInfo, PushIndexPlan, RoleMap, + MAX_LOCATOR_FRAGMENTS, }; + use crate::rules::v2::Role; use crate::rules::ConfigDoc; /// A manifest stub carrying only what the packRef space is derived from. @@ -2506,17 +1704,6 @@ mod tests { assert_eq!(hashes(&live_locator_manifests(&manifests)), vec![9, 8]); } - #[test] - fn normalize_name_accepts_valid_and_rejects_invalid() { - assert_eq!(normalize_name("MyRepo").unwrap(), "myrepo"); - assert_eq!(normalize_name("a.b-c_1").unwrap(), "a.b-c_1"); - assert!(normalize_name("").is_err()); - assert!(normalize_name(".leading-dot").is_err()); - assert!(normalize_name("has space").is_err()); - assert!(normalize_name(&"x".repeat(64)).is_err()); - assert!(normalize_name(&"x".repeat(63)).is_ok()); - } - #[test] fn current_protected_patterns_picks_newest_config() { let configs = vec![ @@ -2538,108 +1725,6 @@ mod tests { assert!(current_protected_patterns(&[]).is_empty()); } - #[test] - fn embedded_template_has_tokens_and_doc_types() { - let t: serde_json::Value = serde_json::from_str(REPO_V1_TEMPLATE).unwrap(); - assert_eq!( - t.get("tokens").and_then(|v| v.as_object()).unwrap().len(), - 2 - ); - assert!( - t.get("documentSchemas") - .and_then(|v| v.as_object()) - .unwrap() - .len() - >= 15 - ); - } - - #[test] - fn normalize_positions_makes_top_level_contiguous() { - use super::normalize_document_positions; - let mut t: serde_json::Value = serde_json::from_str(REPO_V1_TEMPLATE).unwrap(); - normalize_document_positions(&mut t); - - for (name, schema) in t.get("documentSchemas").unwrap().as_object().unwrap() { - let props = schema.get("properties").unwrap().as_object().unwrap(); - let mut positions: Vec = props - .values() - .map(|p| p.get("position").unwrap().as_u64().unwrap()) - .collect(); - positions.sort_unstable(); - let expected: Vec = (0..positions.len() as u64).collect(); - assert_eq!( - positions, expected, - "top-level positions for '{name}' must be contiguous 0..N" - ); - // Nested `imported`/`backend` objects also renumbered to local 0-based. - for prop in props.values() { - if let Some(nested) = prop.get("properties").and_then(|v| v.as_object()) { - let mut np: Vec = nested - .values() - .map(|p| p.get("position").unwrap().as_u64().unwrap()) - .collect(); - np.sort_unstable(); - assert_eq!(np, (0..np.len() as u64).collect::>()); - } - } - } - } - - #[test] - fn solo_owner_transform_drops_group_and_repoints_rules() { - use super::apply_solo_owner_token_rules; - // A synthetic *org*-shaped contract (a control group holds mint/freeze). The - // committed source is already solo-owner, so the transform is exercised on the - // org shape it exists to coerce (an older on-disk source, or a future org variant). - let mut t = serde_json::json!({ - "groups": { "0": { "members": { "aaa": 1 }, "requiredPower": 1 } }, - "tokens": { - "0": { - "manualMintingRules": { - "authorizedToMakeChange": "MainGroup", - "adminActionTakers": "MainGroup" - }, - "mainControlGroup": 0 - } - } - }); - assert!(serde_json::to_string(&t).unwrap().contains("MainGroup")); - - apply_solo_owner_token_rules(&mut t); - - // Group dropped; no MainGroup rule survives; every mainControlGroup nulled. - assert!(t.get("groups").is_none()); - assert!( - !serde_json::to_string(&t).unwrap().contains("MainGroup"), - "no MainGroup rule should remain" - ); - for token in t.get("tokens").unwrap().as_object().unwrap().values() { - assert!(token.get("mainControlGroup").unwrap().is_null()); - } - } - - #[test] - fn committed_template_is_already_solo_owner_and_transform_is_noop() { - use super::apply_solo_owner_token_rules; - // The committed source has been reconciled to the deployable solo-owner shape, so - // the runtime patch is an idempotent no-op safety net (see `create_repo`). - let mut t: serde_json::Value = serde_json::from_str(REPO_V1_TEMPLATE).unwrap(); - assert!(t.get("groups").is_none(), "source must have no group"); - assert!( - !serde_json::to_string(&t).unwrap().contains("MainGroup"), - "source must carry no MainGroup rule" - ); - let before = t.clone(); - apply_solo_owner_token_rules(&mut t); - assert_eq!( - t, before, - "transform must be a no-op on the already-solo source" - ); - for token in t.get("tokens").unwrap().as_object().unwrap().values() { - assert!(token.get("mainControlGroup").unwrap().is_null()); - } - } /// Shorthand: what `plan_push_index` decided, as `(pack_ref, fold)` or the skip reason. fn plan(manifests: &[PackManifestInfo], hash: u8) -> Result<(u16, bool, usize), String> { match plan_push_index(manifests, [hash; 32]) { @@ -2733,37 +1818,68 @@ mod tests { } #[test] - fn repack_supersedes_carries_forward_a_co_maintainers_already_superseded_pack() { - // After repack #1, Bob's pack is superseded-but-kept and the ONLY document saying so - // is Alice's consolidated manifest — which repack #2 deletes as her own. If repack - // #2's list names only the live packs, Bob's pack loses its last superseder and - // rejoins the live set at packRef 0, silently invalidating the locator being - // published alongside it. + fn repack_supersedes_names_every_live_pack_but_the_new_one() { + // Nothing is deleted on v2: an already-superseded pack keeps its superseder and + // needs no slot; every live pack (anyone's) is named, oldest first. let mut bob = manifest("bob", 100, 0, 1); bob.owner_id = "bob".into(); let mut prev = manifest("prev", 300, 0, 2); prev.supersedes = vec![[1u8; 32]]; - let manifests = vec![prev, bob]; - - let out = repack_supersedes(&manifests, [9u8; 32], "owner"); - assert!( - out.contains(&[1u8; 32]), - "Bob's superseded pack must stay named" - ); - assert!(out.contains(&[2u8; 32]), "the live pack must be named"); + let new = manifest("new", 400, 0, 9); + let out = repack_supersedes(&[new, prev, bob], [9u8; 32]); + assert_eq!(out, vec![[2u8; 32]]); } #[test] fn repack_supersedes_never_names_the_new_pack_and_stays_within_the_field() { // `supersedes` is a 1024-byte packed byteArray: 32 hashes, no more. Past that the - // list is truncated in priority order rather than failing the write. + // list is truncated rather than failing the write. let mut manifests = vec![manifest("new", 999, 0, 9)]; for i in 0..40u8 { - manifests.push(manifest(&format!("p{i}"), 100 + u64::from(i), 0, i)); + manifests.push(manifest(&format!("p{i}"), 100 + u64::from(i), 0, i + 10)); } - let out = repack_supersedes(&manifests, [9u8; 32], "owner"); + let out = repack_supersedes(&manifests, [9u8; 32]); assert_eq!(out.len(), 32); assert!(!out.contains(&[9u8; 32]), "must never supersede itself"); + assert_eq!(out[0], [10u8; 32], "oldest first"); + } + + #[test] + fn a_second_uploaders_copy_does_not_shift_the_pack_space() { + // v2: carol re-uploads pack 1 after pack 2 landed. Pack 1 keeps packRef 0. + let mut copy = manifest("c2", 300, 0, 1); + copy.owner_id = "carol".into(); + let manifests = vec![copy, manifest("p2", 200, 0, 2), manifest("p1", 100, 0, 1)]; + assert_eq!(hashes(&locator_pack_space(&manifests, None)), vec![1, 2]); + assert_eq!(group_by_hash(&manifests)[0].1.len(), 2); + } + + #[test] + fn copies_are_tried_maintainers_then_writers_then_former_members() { + let mut stranger = manifest("s", 50, 0, 1); + stranger.owner_id = "mallory".into(); + let mut writer = manifest("w", 200, 0, 1); + writer.owner_id = "bob".into(); + let mut maint = manifest("m", 300, 0, 1); + maint.owner_id = "alice".into(); + let roles: RoleMap = [ + ("alice".to_string(), Role::Maintainer), + ("bob".to_string(), Role::Writer), + ] + .into_iter() + .collect(); + let copies = [&stranger, &writer, &maint]; + let order: Vec<_> = order_copies(&copies, &roles) + .iter() + .map(|m| m.document_id.clone()) + .collect(); + assert_eq!(order, ["m", "w", "s"]); + // With no membership known (v1), the order is by time. + let order: Vec<_> = order_copies(&copies, &RoleMap::new()) + .iter() + .map(|m| m.document_id.clone()) + .collect(); + assert_eq!(order, ["s", "w", "m"]); } #[test] diff --git a/crates/forge-core/src/resolve.rs b/crates/forge-core/src/resolve.rs new file mode 100644 index 000000000..b7d9d96e5 --- /dev/null +++ b/crates/forge-core/src/resolve.rs @@ -0,0 +1,244 @@ +//! Resolving a repository reference (`owner/name`, or an id) to a [`RepoRef`]. +//! +//! * `owner/name`: the name is checked and normalized as a forge-v2 slug +//! ([`crate::rules::v2::normalize_repo_name`]), then looked up as a forge-core `repo` +//! through its unique `($ownerId, name)` index. Only when that lookup *proves* there is no +//! such repo does it fall back to the v1 registry listing (read-only repositories). A +//! failed lookup is an error, never a fallback, so a flaky node cannot send a push to a +//! different repository than the name means. +//! * an id: a forge-core `repo` document id first, then a v1 repo contract id. Both lookups +//! are proof-verified; the ids are 32-byte hashes of different preimages, so at most one +//! can exist, and the forge contracts themselves are refused as "repo contracts". +//! +//! A network with no forge-v2 deployment resolves v1 repositories only; one with no +//! registry resolves v2 repositories only. + +use crate::error::{Error, Result}; +use crate::network::ForgeIds; +use crate::platform::{self, FetchedDocument, FieldValue, PlatformClient, QueryFilter, QueryOrder}; +use crate::rules::v2::normalize_repo_name; +use crate::scope::{visibility_of, RepoRef}; + +/// The forge-core document type of a repository. +pub const DOC_REPO: &str = "repo"; +/// The v1 registry listing type. +const DOC_REPO_LISTING: &str = "repoListing"; + +/// The `repo.name` slug `input` names, or a configuration error explaining the rule. +pub fn repo_slug(input: &str) -> Result { + normalize_repo_name(input).ok_or_else(|| { + Error::Config(format!( + "invalid repo name {input:?}: use 1-63 of a-z, 0-9, '.', '_', '-', starting with a \ + letter or digit (upper-case letters are folded to lower-case)" + )) + }) +} + +/// A forge-core `repo` document as a [`RepoRef`]. +pub fn repo_ref_from_doc(forge: &ForgeIds, doc: &FetchedDocument) -> Result { + Ok(RepoRef::V2 { + forge: forge.clone(), + repo_id: doc.id.clone(), + owner_id: doc.owner_id.clone(), + name: doc + .field_str("name") + .ok_or_else(|| Error::Platform(format!("repo {} has no name", doc.id)))?, + visibility: visibility_of(doc), + }) +} + +/// Resolve `owner/name` (owner a base58 identity id): v2 first, then the v1 registry. +pub async fn resolve_named(client: &PlatformClient, owner: &str, name: &str) -> Result { + let slug = repo_slug(name)?; + let owner_bytes = platform::decode_identifier(owner)?; + let target = client.target(); + if let Some(forge) = &target.v2 { + if let Some(repo) = find_v2(client, forge, owner_bytes, &slug).await? { + return Ok(repo); + } + } + match (&target.registry, &target.v2) { + (Some(_), _) => find_v1(client, owner, owner_bytes, &slug) + .await? + .ok_or(Error::NotFound), + (None, Some(_)) => Err(Error::NotFound), + (None, None) => Err(Error::V2NotDeployed { + network: target.network.key(), + }), + } +} + +/// The forge-v2 repo `owner` named `slug`, if it exists (proved either way). +pub async fn find_v2( + client: &PlatformClient, + forge: &ForgeIds, + owner: [u8; 32], + slug: &str, +) -> Result> { + let core = client.fetch_contract(&forge.core).await?; + let docs = client + .query_documents( + &core, + DOC_REPO, + &[ + QueryFilter::eq("$ownerId", FieldValue::identifier(owner)), + QueryFilter::eq("name", FieldValue::text(slug)), + ], + &[], + 1, + None, + ) + .await?; + docs.first() + .map(|d| repo_ref_from_doc(forge, d)) + .transpose() +} + +/// The v1 repo `owner` listed as `slug` in the registry, if any. +async fn find_v1( + client: &PlatformClient, + owner: &str, + owner_bytes: [u8; 32], + slug: &str, +) -> Result> { + let registry = client.fetch_registry().await?; + let docs = client + .query_documents( + ®istry, + DOC_REPO_LISTING, + &[ + QueryFilter::eq("$ownerId", FieldValue::identifier(owner_bytes)), + QueryFilter::eq("normalizedName", FieldValue::text(slug)), + ], + &[], + 1, + None, + ) + .await?; + let Some(listing) = docs.into_iter().next() else { + return Ok(None); + }; + let contract = listing + .field_bytes32("repoContractId") + .ok_or_else(|| Error::Platform("repoListing missing repoContractId".into()))?; + Ok(Some(RepoRef::V1 { + contract_id: platform::encode_identifier(contract), + owner_id: owner.to_string(), + name: listing + .field_str("name") + .unwrap_or_else(|| slug.to_string()), + })) +} + +/// Resolve a bare id: a forge-v2 `repo` document id, else a v1 repo contract id. +pub async fn resolve_id(client: &PlatformClient, id: &str) -> Result { + platform::decode_identifier(id)?; + let target = client.target(); + if let Some(forge) = &target.v2 { + let core = client.fetch_contract(&forge.core).await?; + if let Some(doc) = client.fetch_document(&core, DOC_REPO, id).await? { + return repo_ref_from_doc(forge, &doc); + } + if id == forge.core || id == forge.collab { + return Err(Error::Config(format!( + "{id} is a forge-v2 contract, not a repository; address a repo as \ + dash:/// or by its repo id" + ))); + } + } + let contract = client.fetch_contract(id).await?; + // A v1 repo contract carries the git data-plane types itself; any other contract + // (DPNS, the registry, forge-collab) is not a repository. + if !contract.has_document_type("refUpdate") || contract.has_document_type(DOC_REPO) { + return Err(Error::Config(format!( + "{id} is a data contract but not a Dash Forge v1 repository" + ))); + } + Ok(RepoRef::V1 { + contract_id: contract.id(), + owner_id: contract.owner_id(), + name: contract.id(), + }) +} + +/// Every repository `owner` has: forge-v2 repos (when deployed), then v1 registry listings +/// (when a registry is deployed), each by name. +pub async fn list_owned(client: &PlatformClient, owner: &str) -> Result> { + let owner_bytes = platform::decode_identifier(owner)?; + let target = client.target(); + let mut out = Vec::new(); + if let Some(forge) = &target.v2 { + let core = client.fetch_contract(&forge.core).await?; + let docs = client + .query_all_documents( + &core, + DOC_REPO, + &[QueryFilter::eq( + "$ownerId", + FieldValue::identifier(owner_bytes), + )], + &[QueryOrder::asc("name")], + ) + .await?; + for d in &docs { + out.push(RepoSummary { + repo: repo_ref_from_doc(forge, d)?, + description: d.field_str("description").unwrap_or_default(), + }); + } + } + if target.registry.is_some() { + let registry = client.fetch_registry().await?; + let docs = client + .query_all_documents( + ®istry, + DOC_REPO_LISTING, + &[QueryFilter::eq( + "$ownerId", + FieldValue::identifier(owner_bytes), + )], + &[QueryOrder::asc("normalizedName")], + ) + .await?; + for d in &docs { + let Some(contract) = d.field_bytes32("repoContractId") else { + continue; + }; + out.push(RepoSummary { + repo: RepoRef::V1 { + contract_id: platform::encode_identifier(contract), + owner_id: owner.to_string(), + name: d + .field_str("normalizedName") + .or_else(|| d.field_str("name")) + .unwrap_or_default(), + }, + description: d.field_str("description").unwrap_or_default(), + }); + } + } + Ok(out) +} + +/// One row of [`list_owned`]. +#[derive(Debug, Clone)] +pub struct RepoSummary { + /// The repository. + pub repo: RepoRef, + /// Its description (v2 `repo.description`, v1 listing description). + pub description: String, +} + +#[cfg(test)] +mod tests { + use super::repo_slug; + + #[test] + fn slugs_follow_the_contract_pattern() { + assert_eq!(repo_slug("Dash-Forge").unwrap(), "dash-forge"); + assert_eq!(repo_slug("a.b_c-1").unwrap(), "a.b_c-1"); + for bad in ["", ".x", "-x", "has space", "é", "a/b", &"x".repeat(64)] { + assert!(repo_slug(bad).is_err(), "{bad:?} should be refused"); + } + } +} diff --git a/crates/forge-core/src/scope.rs b/crates/forge-core/src/scope.rs new file mode 100644 index 000000000..b509d1cd1 --- /dev/null +++ b/crates/forge-core/src/scope.rs @@ -0,0 +1,402 @@ +//! Which repository a client is talking to, and how its documents are addressed. +//! +//! Two generations of repository exist side by side: +//! +//! * **forge-v2** (protocol 14): a repository is a `repo` document in the network's shared +//! forge-core contract, and every other document of it (refs, config, packs, chunks, +//! membership) lives in the same contract, keyed by `repoId`. This is where writes go. +//! * **forge-v1**: one data contract per repository, found through the registry. **Read +//! only**: existing v1 repositories stay cloneable, but nothing writes to them any more. +//! +//! [`RepoRef`] names one repository of either kind. [`DocScope`] turns it into the contract +//! to query and the filters/properties every query and write needs: on v2 each query gains +//! `repoId == R` and each write a `repoId` property; on v1 the contract itself is the scope. +//! The field codec here ([`text_list`], [`chunk_scope`], ...) absorbs the remaining schema +//! differences so the transport code is written once. + +use std::collections::BTreeMap; + +use crate::error::{Error, Result}; +use crate::network::ForgeIds; +use crate::platform::{self, FetchedDocument, FieldValue, QueryFilter}; +use crate::rules::v2::Visibility; + +/// A resolved repository. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RepoRef { + /// A forge-v2 repository: a `repo` document in the network's forge-core contract. + V2 { + /// The forge-v2 contracts of the network the repo lives on. + forge: ForgeIds, + /// The base58 `repo` document id (`repoId` in every other document). + repo_id: String, + /// The base58 owner identity (`repo.$ownerId`). + owner_id: String, + /// The immutable URL slug (`repo.name`). + name: String, + /// `repo.visibility`. + visibility: Visibility, + }, + /// A forge-v1 repository: its own data contract. Read only. + V1 { + /// The base58 repo contract id. + contract_id: String, + /// The base58 owner identity (the contract owner). + owner_id: String, + /// The registry name, or the contract id when it was addressed by id. + name: String, + }, +} + +impl RepoRef { + /// The owner identity (base58). + pub fn owner_id(&self) -> &str { + match self { + RepoRef::V2 { owner_id, .. } | RepoRef::V1 { owner_id, .. } => owner_id, + } + } + + /// The repository name (the v2 slug, the v1 registry name). + pub fn name(&self) -> &str { + match self { + RepoRef::V2 { name, .. } | RepoRef::V1 { name, .. } => name, + } + } + + /// The id that names this repository on chain: the `repo` document id (v2) or the + /// repo contract id (v1). Either form is accepted by `dash://`. + pub fn id(&self) -> &str { + match self { + RepoRef::V2 { repo_id, .. } => repo_id, + RepoRef::V1 { contract_id, .. } => contract_id, + } + } + + /// `v2` or `v1`, for display and `--json`. + pub fn generation(&self) -> &'static str { + match self { + RepoRef::V2 { .. } => "v2", + RepoRef::V1 { .. } => "v1", + } + } + + /// Whether this is a (read-only) forge-v1 repository. + pub fn is_v1(&self) -> bool { + matches!(self, RepoRef::V1 { .. }) + } + + /// `owner/name`, the form users type. + pub fn display(&self) -> String { + format!("{}/{}", self.owner_id(), self.name()) + } + + /// The `dash://` remote URL for this repository. + pub fn remote_url(&self) -> String { + format!("dash://{}", self.display()) + } + + /// The forge-v2 contracts, or [`Error::V1ReadOnly`] for a v1 repository. Every write + /// path starts here, so a v1 repository refuses writes before anything is signed. + pub fn require_v2(&self) -> Result<&ForgeIds> { + match self { + RepoRef::V2 { forge, .. } => Ok(forge), + RepoRef::V1 { .. } => Err(Error::V1ReadOnly { + repo: self.display(), + }), + } + } + + /// The v1 repo contract id, for the services that still address a repository by its + /// contract (issues, PRs, releases, labels: forge-collab lands in a later release). + pub fn v1_contract_id(&self) -> Result<&str> { + match self { + RepoRef::V1 { contract_id, .. } => Ok(contract_id), + RepoRef::V2 { .. } => Err(Error::Config(format!( + "{} is a forge-v2 repo; issues, pull requests, releases and labels on forge-v2 \ + are not supported by this version of the CLI yet", + self.display() + ))), + } + } + + /// The document scope of this repository's git data plane (refs, config, packs). + pub fn scope(&self) -> Result { + Ok(match self { + RepoRef::V2 { forge, repo_id, .. } => DocScope { + contract_id: forge.core.clone(), + repo_id: Some(platform::decode_identifier(repo_id)?), + }, + RepoRef::V1 { contract_id, .. } => DocScope { + contract_id: contract_id.clone(), + repo_id: None, + }, + }) + } + + /// Refuse to operate on a repository this client cannot read correctly yet. + pub fn require_readable(&self) -> Result<()> { + match self { + RepoRef::V2 { + visibility: Visibility::Private, + .. + } => Err(Error::Config(format!( + "{} is a private repository; private repositories are not supported by this \ + version of the CLI yet", + self.display() + ))), + _ => Ok(()), + } + } +} + +/// Where a repository's git-data documents live, and how to address them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocScope { + /// The contract holding the documents: forge-core (v2) or the repo contract (v1). + pub contract_id: String, + /// `Some(repoId)` on v2: every query filters on it and every write carries it. + pub repo_id: Option<[u8; 32]>, +} + +impl DocScope { + /// Whether this is a forge-v2 scope. + pub fn is_v2(&self) -> bool { + self.repo_id.is_some() + } + + /// `extra` with `repoId == R` prepended on v2 (it is the first property of every + /// forge-v2 index, so it must come first). + pub fn filters(&self, extra: impl IntoIterator) -> Vec { + self.repo_id + .map(|id| QueryFilter::eq("repoId", FieldValue::identifier(id))) + .into_iter() + .chain(extra) + .collect() + } + + /// Document properties with `repoId` added on v2. + pub fn props( + &self, + props: impl IntoIterator, + ) -> BTreeMap { + self.scoped(props.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) + } + + /// An already-built property map with `repoId` added on v2. + pub fn scoped(&self, mut props: BTreeMap) -> BTreeMap { + if let Some(id) = self.repo_id { + props.insert("repoId".to_string(), FieldValue::identifier(id)); + } + props + } + + /// The filters that select one pack's chunks, in `seq` order. + /// + /// v2 chunks are keyed `(repoId, $ownerId, packHash, seq)`: each uploader has its own + /// copy, so a chunk read names whose copy it reads (the manifest's `$ownerId`). v1 + /// chunks are keyed `(packHash, seq)` inside the repo contract. + pub fn chunk_filters( + &self, + owner: Option<&str>, + pack_hash: [u8; 32], + ) -> Result> { + let owner = match (self.is_v2(), owner) { + (false, _) => None, + (true, Some(o)) => Some(QueryFilter::eq( + "$ownerId", + FieldValue::identifier(platform::decode_identifier(o)?), + )), + (true, None) => { + return Err(Error::Config( + "a forge-v2 chunk read must name the uploader whose copy it reads".into(), + )) + } + }; + Ok(self.filters( + owner + .into_iter() + .chain([QueryFilter::eq("packHash", FieldValue::bytes32(pack_hash))]), + )) + } + + /// The `platform://` locator of a pack stored as this scope's chunks: + /// `platform://///` on v2 (the uploader is part of the + /// chunk key), `platform:///` on v1. + pub fn locator(&self, owner: &str, pack_hash_hex: &str) -> String { + let scheme = crate::backends::PLATFORM_SCHEME; + match self.repo_id { + Some(id) => format!( + "{scheme}://{}/{}/{owner}/{pack_hash_hex}", + self.contract_id, + platform::encode_identifier(id) + ), + None => format!("{scheme}://{}/{pack_hash_hex}", self.contract_id), + } + } +} + +/// A string-list field, in either generation's encoding: a typed string array (v2) or a +/// JSON-encoded string (v1: `protectedPatterns`, `uris`, `backend.uris`). Absent or +/// unreadable is empty. +pub fn text_list(value: Option<&FieldValue>) -> Vec { + match value { + Some(FieldValue::Text(json)) => serde_json::from_str(json).unwrap_or_default(), + Some(v) => v.as_text_list().unwrap_or_default(), + None => Vec::new(), + } +} + +/// [`text_list`] of a document's top-level field. +pub fn doc_text_list(doc: &FetchedDocument, field: &str) -> Vec { + text_list(doc.fields.get(field)) +} + +/// The `config.backend.uris` list of a `config` document. +pub fn backend_uris(doc: &FetchedDocument) -> Vec { + match doc.fields.get("backend") { + Some(FieldValue::Object(backend)) => text_list(backend.get("uris")), + _ => Vec::new(), + } +} + +/// The `repo.visibility` string as a [`Visibility`]; anything but `private` is public. +pub fn visibility_of(doc: &FetchedDocument) -> Visibility { + match doc.field_str("visibility").as_deref() { + Some("private") => Visibility::Private, + _ => Visibility::Public, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::QueryOp; + + const REPO: &str = "GdZYaEntYPiW9dvUGCHyeqN7H7qEocbSkuj81n341i3L"; + const OWNER: &str = "9r27eDsuXEqoMNymW1A2MKFrpBhzSkepVKwXrGzq9dUD"; + + fn forge() -> ForgeIds { + ForgeIds { + core: "CORE".into(), + collab: "COLLAB".into(), + group: "GROUP".into(), + } + } + + fn v2() -> RepoRef { + RepoRef::V2 { + forge: forge(), + repo_id: REPO.into(), + owner_id: OWNER.into(), + name: "proj".into(), + visibility: Visibility::Public, + } + } + + fn v1() -> RepoRef { + RepoRef::V1 { + contract_id: REPO.into(), + owner_id: OWNER.into(), + name: "proj".into(), + } + } + + #[test] + fn v2_scope_filters_every_query_by_repo_id_first() { + let scope = v2().scope().unwrap(); + assert_eq!(scope.contract_id, "CORE"); + let f = scope.filters([QueryFilter::eq("packHash", FieldValue::bytes32([1; 32]))]); + assert_eq!(f.len(), 2); + assert_eq!(f[0].field, "repoId"); + assert_eq!(f[0].op, QueryOp::Eq); + assert_eq!( + f[0].value, + FieldValue::identifier(platform::decode_identifier(REPO).unwrap()) + ); + assert_eq!(f[1].field, "packHash"); + // An unfiltered v2 read is still scoped to the repo. + assert_eq!(scope.filters([]).len(), 1); + } + + #[test] + fn v1_scope_is_the_repo_contract_with_no_extra_filter() { + let scope = v1().scope().unwrap(); + assert_eq!(scope.contract_id, REPO); + assert!(scope.filters([]).is_empty()); + assert!(!scope.props([]).contains_key("repoId")); + } + + #[test] + fn v2_writes_carry_the_repo_id() { + let props = v2() + .scope() + .unwrap() + .props([("refName", FieldValue::text("refs/heads/main"))]); + assert!(matches!( + props.get("repoId"), + Some(FieldValue::Identifier(_)) + )); + assert_eq!(props.len(), 2); + } + + #[test] + fn v2_chunk_reads_are_scoped_to_one_uploaders_copy() { + let scope = v2().scope().unwrap(); + let f = scope.chunk_filters(Some(OWNER), [7; 32]).unwrap(); + let fields: Vec<_> = f.iter().map(|f| f.field.as_str()).collect(); + assert_eq!(fields, ["repoId", "$ownerId", "packHash"]); + assert!(scope.chunk_filters(None, [7; 32]).is_err()); + // v1: the pack hash alone. + let f = v1().scope().unwrap().chunk_filters(None, [7; 32]).unwrap(); + assert_eq!(f.len(), 1); + } + + #[test] + fn locators_name_the_uploader_on_v2() { + let h = "ab".repeat(32); + assert_eq!( + v2().scope().unwrap().locator(OWNER, &h), + format!("platform://CORE/{REPO}/{OWNER}/{h}") + ); + assert_eq!( + v1().scope().unwrap().locator(OWNER, &h), + format!("platform://{REPO}/{h}") + ); + } + + #[test] + fn v1_refuses_writes_and_v2_refuses_v1_only_services() { + let err = v1().require_v2().unwrap_err().to_string(); + assert!(err.contains("v1 repo (read-only)"), "{err}"); + assert!(err.contains("dg migrate"), "{err}"); + assert!(v2().require_v2().is_ok()); + assert!(v2().v1_contract_id().is_err()); + assert_eq!(v1().v1_contract_id().unwrap(), REPO); + } + + #[test] + fn private_v2_repos_are_refused_until_supported() { + let mut r = v2(); + if let RepoRef::V2 { visibility, .. } = &mut r { + *visibility = Visibility::Private; + } + assert!(r.require_readable().is_err()); + assert!(v2().require_readable().is_ok()); + } + + #[test] + fn text_lists_decode_both_generations() { + assert_eq!( + text_list(Some(&FieldValue::text(r#"["a","b"]"#))), + vec!["a", "b"] + ); + assert_eq!( + text_list(Some(&FieldValue::text_list(["a", "b"]))), + vec!["a", "b"] + ); + // An empty typed array comes back as empty bytes. + assert!(text_list(Some(&FieldValue::bytes(Vec::new()))).is_empty()); + assert!(text_list(None).is_empty()); + assert!(text_list(Some(&FieldValue::text("not json"))).is_empty()); + } +} diff --git a/crates/forge-core/src/storage/mod.rs b/crates/forge-core/src/storage/mod.rs index 8bcb14770..c007737fb 100644 --- a/crates/forge-core/src/storage/mod.rs +++ b/crates/forge-core/src/storage/mod.rs @@ -30,7 +30,7 @@ pub use profiles::{Profile, SecretRef, StorageProfiles, PLATFORM_PROFILE}; pub use read::PackReader; pub use targets::{ replicate, ExternalTarget, Observed, Replica, Replication, ReplicationError, StorageTarget, - StoreOutcome, TargetFailure, + StoreOutcome, TargetFailure, UriBudget, }; /// The shared defaults file (also imported by forge-web). diff --git a/crates/forge-core/src/storage/targets.rs b/crates/forge-core/src/storage/targets.rs index 36a9b42be..563fc1068 100644 --- a/crates/forge-core/src/storage/targets.rs +++ b/crates/forge-core/src/storage/targets.rs @@ -37,6 +37,51 @@ pub struct Replica { pub platform: bool, } +/// What a manifest's `uris` field can hold. v1 stores the list as one JSON string of at +/// most `max_json_len` bytes; v2 stores a typed array of at most `max_items` strings of at +/// most `max_item_len` bytes each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UriBudget { + /// The JSON-encoded length limit (v1), if any. + pub max_json_len: Option, + /// The item-count limit (v2), if any. + pub max_items: Option, + /// The per-item length limit (v2), if any. + pub max_item_len: Option, +} + +impl UriBudget { + /// A v1 JSON-string field of `max_json_len` bytes. + pub const fn json(max_json_len: usize) -> Self { + Self { + max_json_len: Some(max_json_len), + max_items: None, + max_item_len: None, + } + } + + /// A v2 typed string array. + pub const fn array(max_items: usize, max_item_len: usize) -> Self { + Self { + max_json_len: None, + max_items: Some(max_items), + max_item_len: Some(max_item_len), + } + } + + /// Whether `uris` fits. + pub fn fits(&self, uris: &[String]) -> bool { + let json_ok = self + .max_json_len + .is_none_or(|max| serde_json::to_string(uris).map_or(usize::MAX, |s| s.len()) <= max); + let count_ok = self.max_items.is_none_or(|max| uris.len() <= max); + let items_ok = self + .max_item_len + .is_none_or(|max| uris.iter().all(|u| u.len() <= max)); + json_ok && count_ok && items_ok + } +} + /// A target that did not confirm, and why. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TargetFailure { @@ -87,13 +132,11 @@ impl Replication { groups.concat() } - /// The URI list as the manifest's `uris` JSON, trimmed to fit `max_json_len`: private - /// `s3://` locators are dropped first (readers without that profile cannot use them), - /// then an error — never a silently truncated or empty list. - pub fn manifest_uris(&self, max_json_len: usize) -> Result> { - let fits = |v: &Vec| { - serde_json::to_string(v).map_or(usize::MAX, |s| s.len()) <= max_json_len - }; + /// The URI list for a manifest's `uris`, trimmed to fit `budget`: private `s3://` + /// locators are dropped first (readers without that profile cannot use them), then an + /// error — never a silently truncated or empty list. + pub fn manifest_uris(&self, budget: UriBudget) -> Result> { + let fits = |v: &Vec| budget.fits(v); let mut uris = self.uris(); if uris.is_empty() { return Err(Error::Config( @@ -108,10 +151,11 @@ impl Replication { if fits(&uris) && !uris.is_empty() { return Ok(uris); } - Err(Error::Config(format!( - "the confirmed copies' URIs do not fit the manifest's {max_json_len}-byte uris field; \ - use shorter public URLs or fewer targets" - ))) + Err(Error::Config( + "the confirmed copies' URIs do not fit the manifest's uris field; use shorter \ + public URLs or fewer targets" + .into(), + )) } } @@ -516,7 +560,7 @@ pub(crate) mod tests { }], failures: vec![], }; - assert!(rep.manifest_uris(2600).is_err()); + assert!(rep.manifest_uris(UriBudget::json(2600)).is_err()); } #[tokio::test] @@ -605,11 +649,19 @@ pub(crate) mod tests { }], failures: vec![], }; - assert_eq!(rep.manifest_uris(2600).unwrap().len(), 2); - let trimmed = rep.manifest_uris(60).unwrap(); + assert_eq!(rep.manifest_uris(UriBudget::json(2600)).unwrap().len(), 2); + let trimmed = rep.manifest_uris(UriBudget::json(60)).unwrap(); assert_eq!(trimmed.len(), 1); assert!(trimmed[0].starts_with("https://")); - assert!(rep.manifest_uris(10).is_err()); + assert!(rep.manifest_uris(UriBudget::json(10)).is_err()); + // v2: a typed array bounded per item and in count. + assert_eq!( + rep.manifest_uris(UriBudget::array(8, 300)).unwrap().len(), + 2 + ); + let trimmed = rep.manifest_uris(UriBudget::array(1, 300)).unwrap(); + assert!(trimmed[0].starts_with("https://")); + assert!(rep.manifest_uris(UriBudget::array(8, 10)).is_err()); } /// An in-memory backend whose reads can be made to lie (`lie`), or whose first stored diff --git a/crates/forge-core/src/tokens.rs b/crates/forge-core/src/tokens.rs index 2915fb457..4f8a6e706 100644 --- a/crates/forge-core/src/tokens.rs +++ b/crates/forge-core/src/tokens.rs @@ -1,35 +1,19 @@ -//! [`TokenService`] — the on-chain collaborator ACL over a repo contract's tokens. +//! [`TokenService`] — the collaborator list of a **forge-v1** repository, read only. //! -//! A repo's two tokens **are** its access-control list (data-contracts §2.1): +//! A v1 repo's two tokens were its access-control list: position **0 = WRITE** (push, +//! upload), position **1 = MAINTAIN** (protected refs, releases, config). v1 is read only +//! now, so this module only reads: [`TokenService::list_collaborators`] / +//! [`TokenService::holdings`] (balances + frozen status) and +//! [`TokenService::token_history`] (mint/freeze/unfreeze/destroy records with consensus +//! `$createdAt`, fed to [`crate::rules::holdings_as_of`] for as-of-time event authorization +//! when folding a v1 repo's issues and PRs). //! -//! * position **0 = WRITE** — push / upload / CI (gates every `refUpdate` / `chunk` / -//! `packManifest` create + refund-delete). -//! * position **1 = MAINTAIN** — protected refs / releases / labels / webhooks / config. -//! -//! Collaborator management is therefore token administration, not document writes: -//! -//! * [`TokenService::grant`] — **mint** `10⁹` of the token to a member (they can now -//! spend the gated actions). -//! * [`TokenService::suspend`] — **freeze** the member's balance (kept, but unspendable → -//! every gated create *and* delete fails at consensus, S0.7). -//! * [`TokenService::revoke`] — **freeze + destroyFrozenFunds** (balance zeroed, removed -//! from the collaborator set). -//! * [`TokenService::list_collaborators`] / [`TokenService::holdings`] — read the balances -//! (with frozen status) back: the balances are the ACL. -//! * [`TokenService::token_history`] — the mint/freeze/unfreeze/destroy records with -//! consensus `$createdAt`, fed to [`crate::rules::holdings_as_of`] for as-of-time event -//! authorization (§4). -//! -//! Every mutating op signs with the owner's **CRITICAL** key (S0.7: HIGH is rejected for -//! token admin) and — because of the keepsHistory `mint()` return-value bug (S0.7) — -//! **verifies success via a balance/frozen query afterwards, never the return value**. -//! All SDK contact goes through [`crate::platform`]; this module names no rs-sdk type. +//! forge-v2 membership is documents: see [`crate::members`]. use std::collections::BTreeSet; -use crate::error::{Error, Result}; -use crate::keystore::BridgeIdentity; -use crate::platform::{self, FieldValue, LoadedIdentity, PlatformClient, QueryFilter, QueryOrder}; +use crate::error::Result; +use crate::platform::{self, FieldValue, PlatformClient, QueryFilter, QueryOrder}; use crate::rules::{TokenKind, TokenOp, TokenRecord}; /// WRITE token position (push / upload / CI). @@ -37,11 +21,6 @@ pub const WRITE_POSITION: u16 = 0; /// MAINTAIN token position (protected refs / releases / labels / config). pub const MAINTAIN_POSITION: u16 = 1; -/// The amount minted per grant (`10⁹`), matching the `baseSupply` the owner is -/// auto-credited (data-contracts §2.1) — plenty for a collaborator's per-action -/// `tokenCost` spends over the repo's lifetime. -pub const GRANT_AMOUNT: u64 = 1_000_000_000; - /// The system **TokenHistory** contract holding the `mint` / `freeze` / /// `unfreeze` / `destroyFrozenFunds` audit documents with consensus `$createdAt` /// (S0.7 experiment 7). A Platform system contract: its id is fixed by rs-dpp @@ -55,15 +34,6 @@ const TH_FREEZE: &str = "freeze"; const TH_UNFREEZE: &str = "unfreeze"; const TH_DESTROY: &str = "destroyFrozenFunds"; -/// Max post-broadcast verify re-reads before concluding a freeze/unfreeze/destroy did not -/// take. Platform reads are eventually consistent, so the status query *immediately* after -/// a broadcast can still reflect the pre-write state (S0.7 read-after-write lag); the write -/// itself has already landed, so this only bounds how long we wait for the read to catch up -/// before reporting the real state. Mirrors `git-remote-dash`'s post-push convergence poll. -const VERIFY_MAX_ATTEMPTS: usize = 6; -/// Delay between verify re-reads (`VERIFY_MAX_ATTEMPTS` × this ≈ a few seconds total). -const VERIFY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(700); - /// A collaborator role, mapped to its token position. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Role { @@ -124,159 +94,15 @@ pub struct Collaborator { pub holdings: HoldingStatus, } -/// The token-administration service, bound to the repo **owner** identity (the token -/// authority for a solo-owner repo) and its keys. +/// Read access to a v1 repository's token ACL. pub struct TokenService<'a> { client: &'a PlatformClient, - identity: &'a LoadedIdentity, - bridge: &'a BridgeIdentity, } impl<'a> TokenService<'a> { - /// Bind the service to `client`, the owner `identity`, and its `bridge` key material - /// (the CRITICAL key is required for every mint/freeze/destroy). - pub fn new( - client: &'a PlatformClient, - identity: &'a LoadedIdentity, - bridge: &'a BridgeIdentity, - ) -> Self { - Self { - client, - identity, - bridge, - } - } - - /// The base58 token id for `position` (0 = WRITE, 1 = MAINTAIN) of a repo contract. - pub async fn token_id(&self, repo_contract_id: &str, position: u16) -> Result { - let contract = self.client.fetch_contract(repo_contract_id).await?; - Ok(self.client.token_id(&contract, position)) - } - - /// **Grant** `role` to `member_id` (base58): mint `10⁹` of the role's token to it. - /// Idempotent — if the member already holds a positive balance, the mint is skipped - /// (no double-mint on retry). Verified via a balance query (S0.7 mint-return bug). - pub async fn grant(&self, repo_contract_id: &str, member_id: &str, role: Role) -> Result<()> { - let contract = self.client.fetch_contract(repo_contract_id).await?; - let token = self.client.token_id(&contract, role.position()); - - if self.balance_of(&token, member_id).await? > 0 { - // Already a holder → skip the mint (no double-mint on retry). If the member is - // *frozen*, grant is the wrong tool — the balance is present but suspended, and - // minting more would not restore access; direct the caller to `unsuspend`. - let frozen = self.frozen_of(&token, member_id).await?; - if frozen { - tracing::warn!( - member = member_id, - role = ?role, - "member already holds the token but is FROZEN; skipping mint — use \ - `unsuspend` to restore a frozen member, not `grant`" - ); - } else { - tracing::warn!( - member = member_id, - role = ?role, - "member already holds the token; skipping mint (idempotent)" - ); - } - return Ok(()); - } - - let key = self.bridge.token_admin_key()?; - self.client - .token_mint( - &contract, - self.identity, - key, - role.position(), - GRANT_AMOUNT, - member_id, - ) - .await?; - - // Verify via query — the keepsHistory mint() return value is not trusted. - if self.balance_of(&token, member_id).await? == 0 { - return Err(Error::Platform( - "grant broadcast but the member's balance did not increase".into(), - )); - } - Ok(()) - } - - /// **Suspend** `role` for `member_id`: freeze its balance. Verified via a frozen-status - /// query. - pub async fn suspend(&self, repo_contract_id: &str, member_id: &str, role: Role) -> Result<()> { - let contract = self.client.fetch_contract(repo_contract_id).await?; - let token = self.client.token_id(&contract, role.position()); - let key = self.bridge.token_admin_key()?; - - self.client - .token_freeze(&contract, self.identity, key, role.position(), member_id) - .await?; - - // Verify via a bounded poll — the freeze broadcast has landed, but the frozen-status - // read can lag (eventual consistency), so re-query a few times before concluding it - // did not take. - if !self.poll_frozen(&token, member_id, true).await? { - return Err(Error::Platform( - "suspend broadcast but the member's token is not frozen".into(), - )); - } - Ok(()) - } - - /// **Unsuspend** `role` for `member_id`: lift a freeze so the member can spend again - /// (the inverse of [`TokenService::suspend`]). Verified via a frozen-status query. - pub async fn unsuspend( - &self, - repo_contract_id: &str, - member_id: &str, - role: Role, - ) -> Result<()> { - let contract = self.client.fetch_contract(repo_contract_id).await?; - let token = self.client.token_id(&contract, role.position()); - let key = self.bridge.token_admin_key()?; - - self.client - .token_unfreeze(&contract, self.identity, key, role.position(), member_id) - .await?; - - // Verify via a bounded poll — the unfreeze has landed, but the frozen-status read can - // lag (eventual consistency), so re-query a few times before concluding it did not - // take. - if self.poll_frozen(&token, member_id, false).await? { - return Err(Error::Platform( - "unsuspend broadcast but the member's token is still frozen".into(), - )); - } - Ok(()) - } - - /// **Revoke** `role` from `member_id`: freeze (if not already) then destroy the frozen - /// balance. Verified by a zero-balance query. - pub async fn revoke(&self, repo_contract_id: &str, member_id: &str, role: Role) -> Result<()> { - let contract = self.client.fetch_contract(repo_contract_id).await?; - let token = self.client.token_id(&contract, role.position()); - let key = self.bridge.token_admin_key()?; - - // destroyFrozenFunds requires the balance to be frozen first. - if !self.frozen_of(&token, member_id).await? { - self.client - .token_freeze(&contract, self.identity, key, role.position(), member_id) - .await?; - } - self.client - .token_destroy_frozen(&contract, self.identity, key, role.position(), member_id) - .await?; - - // Verify via a bounded poll — the destroy has landed, but the balance read can lag - // (eventual consistency), so re-query a few times before concluding it did not take. - if self.poll_balance_zero(&token, member_id).await? != 0 { - return Err(Error::Platform( - "revoke broadcast but the member's balance is not zero".into(), - )); - } - Ok(()) + /// A reader over `client`. + pub fn new(client: &'a PlatformClient) -> Self { + Self { client } } /// The **on-chain collaborator list**: every identity that currently holds either @@ -291,7 +117,7 @@ impl<'a> TokenService<'a> { // Candidates: everyone ever minted to (from history) + the owner (baseSupply). let history = self.token_history(repo_contract_id).await?; let mut candidates: BTreeSet = history.into_iter().map(|r| r.identity).collect(); - candidates.insert(self.identity.id()); + candidates.insert(contract.owner_id()); let candidates: Vec = candidates.into_iter().collect(); let write_bal = self @@ -403,8 +229,7 @@ impl<'a> TokenService<'a> { affected.insert(owner.clone()); for m in &mints { let Some(recipient) = m - .field_bytes("recipientId") - .and_then(|b| <[u8; 32]>::try_from(b).ok()) + .field_bytes32("recipientId") .map(platform::encode_identifier) else { continue; @@ -456,58 +281,6 @@ impl<'a> TokenService<'a> { } Ok(records) } - - // --- internal query helpers --- - - async fn balance_of(&self, token_id_b58: &str, identity: &str) -> Result { - let bal = self - .client - .token_balances(token_id_b58, &[identity.to_string()]) - .await?; - Ok(bal.get(identity).copied().unwrap_or(0)) - } - - async fn frozen_of(&self, token_id_b58: &str, identity: &str) -> Result { - let frozen = self - .client - .token_frozen(token_id_b58, &[identity.to_string()]) - .await?; - Ok(frozen.get(identity).copied().unwrap_or(false)) - } - - /// Re-read frozen status until it reaches `expected` or the retry budget is spent, - /// tolerating read-after-write lag. Returns the last observed value (which the caller - /// compares against `expected` to decide success/failure). - async fn poll_frozen( - &self, - token_id_b58: &str, - identity: &str, - expected: bool, - ) -> Result { - let mut frozen = self.frozen_of(token_id_b58, identity).await?; - for attempt in 1..=VERIFY_MAX_ATTEMPTS { - if frozen == expected || attempt == VERIFY_MAX_ATTEMPTS { - break; - } - tokio::time::sleep(VERIFY_RETRY_DELAY).await; - frozen = self.frozen_of(token_id_b58, identity).await?; - } - Ok(frozen) - } - - /// Re-read balance until it reaches zero or the retry budget is spent, tolerating - /// read-after-write lag. Returns the last observed balance. - async fn poll_balance_zero(&self, token_id_b58: &str, identity: &str) -> Result { - let mut bal = self.balance_of(token_id_b58, identity).await?; - for attempt in 1..=VERIFY_MAX_ATTEMPTS { - if bal == 0 || attempt == VERIFY_MAX_ATTEMPTS { - break; - } - tokio::time::sleep(VERIFY_RETRY_DELAY).await; - bal = self.balance_of(token_id_b58, identity).await?; - } - Ok(bal) - } } #[cfg(test)] diff --git a/crates/forge-core/src/user_error.rs b/crates/forge-core/src/user_error.rs index 5fe05d404..e0b10117a 100644 --- a/crates/forge-core/src/user_error.rs +++ b/crates/forge-core/src/user_error.rs @@ -89,6 +89,7 @@ pub const CATALOGUE: &[(&str, &str)] = &[ (codes::SUSPENDED, "write access suspended"), (codes::ALREADY_EXISTS, "already exists"), (codes::REJECTED, "rejected by Platform"), + (codes::READ_ONLY, "v1 repository is read only"), (codes::UNREACHABLE, "Dash Platform unreachable"), ( codes::NOT_DEPLOYED, @@ -153,6 +154,8 @@ pub mod codes { pub const ALREADY_EXISTS: &str = "E603"; /// Any other consensus rejection. pub const REJECTED: &str = "E604"; + /// A write to a forge-v1 repository, which is read only. + pub const READ_ONLY: &str = "E605"; /// DAPI / the quorum service could not be reached. pub const UNREACHABLE: &str = "E701"; /// The selected network has no Dash Forge deployment. @@ -530,6 +533,23 @@ fn from_core(core: &CoreError, chain: &str, ctx: &ErrorContext<'_>) -> Option suspended(ctx, &format!("40702 {core}")), + CoreError::NotAMember(detail) => not_a_writer(ctx, detail), + CoreError::V1ReadOnly { repo } => UserError::new( + codes::READ_ONLY, + ctx.headline(&format!("{repo} is a v1 repository, which is read only")), + ) + .cause("forge-v1 repositories (one contract each) can still be cloned and viewed, but no longer written") + .fix("create a forge-v2 repository (`dg repo create `) and push there") + .note("`dg migrate` (moving a v1 repo to forge-v2) is coming soon"), + CoreError::V2NotDeployed { network } => UserError::new( + codes::NOT_DEPLOYED, + ctx.headline(&format!("forge-v2 isn't deployed on {network} yet")), + ) + .cause(format!( + "forge-contracts/deployments/{network}.json records no forge-v2 contracts" + )) + .fix("use a network where it is: `--network devnet --devnet-name moutai`") + .note("existing v1 repositories on this network stay readable"), CoreError::Unauthorized => not_a_writer(ctx, &format!("40700/40701 {core}")), CoreError::Timeout { retryable } => timed_out(ctx, *retryable), CoreError::IncompleteRead { diff --git a/crates/forge-core/tests/collab_tokens.rs b/crates/forge-core/tests/collab_tokens.rs index 0647240f8..ee934691d 100644 --- a/crates/forge-core/tests/collab_tokens.rs +++ b/crates/forge-core/tests/collab_tokens.rs @@ -1,4 +1,4 @@ -//! Live testnet collaboration + token-management lifecycle test (gated `#[ignore]`). +//! Live testnet collaboration + v1 token-read test (gated `#[ignore]`). //! //! Reuses the DEPLOYER-owned M1 repo contract (cheap — the token contract already exists) //! to exercise the [`forge_core::collab`] and [`forge_core::tokens`] service layer against @@ -8,9 +8,9 @@ //! event → fold shows closed → reopen event → fold shows open. `issue_count` reflects the //! count tree. //! * **Social** (registry, un-gated): star a listing id → `star_count` reflects it → unstar. -//! * **Tokens** (the ACL): grant WRITE to COLLAB → `list_collaborators` shows it → -//! suspend (freeze) → `holdings` shows frozen → unsuspend (restore). `token_history` shows -//! the mint record. Optional destroy-revoke behind `FORGE_TOKEN_DESTROY=1`. +//! * **Tokens** (the v1 ACL, read only now): `list_collaborators` includes the repo owner, +//! and `token_history` reads the mint records back. Granting and revoking is forge-v2 +//! membership (`forge_core::members`), exercised by the moutai live tests. //! //! Run with: //! ```text @@ -20,7 +20,7 @@ use forge_core::collab::{IssueService, SocialService, StateFilter}; use forge_core::keystore::BridgeIdentity; use forge_core::platform::{self, FieldValue, Network, PlatformClient, QueryFilter, QueryOrder}; -use forge_core::tokens::{Role, TokenService}; +use forge_core::tokens::TokenService; /// Whether the `event` docs for `target_id` carry a consensus `$createdAt` — the clock the /// close/reopen fold needs. `false` on a stale pre-`$createdAt` contract (M1). @@ -211,125 +211,48 @@ async fn collab_and_token_lifecycle_on_testnet() { ); // ===================================================================== - // 3. Tokens: grant WRITE to COLLAB -> list -> suspend -> holdings -> unsuspend + // 3. Tokens (v1, read only): the owner is a collaborator; history reads back. // ===================================================================== - let tokens = TokenService::new(&client, &identity, &bridge); - - let write_token = tokens - .token_id(M1_REPO_CONTRACT, forge_core::tokens::WRITE_POSITION) - .await - .expect("write token id"); - println!("WRITE token id: {write_token}"); - - tokens - .grant(M1_REPO_CONTRACT, &collab_id, Role::Write) - .await - .expect("grant WRITE to COLLAB"); - println!("granted WRITE to COLLAB"); - + let tokens = TokenService::new(&client); let collaborators = tokens .list_collaborators(M1_REPO_CONTRACT) .await .expect("list_collaborators"); println!("collaborators: {collaborators:#?}"); - let collab_entry = collaborators - .iter() - .find(|c| c.identity_id == collab_id) - .expect("COLLAB should appear as a collaborator"); - assert!(collab_entry.holdings.write, "COLLAB should hold WRITE"); - assert!( - !collab_entry.holdings.write_frozen, - "COLLAB WRITE should not be frozen yet" - ); - - // Suspend (freeze) -> holdings shows frozen. - tokens - .suspend(M1_REPO_CONTRACT, &collab_id, Role::Write) - .await - .expect("suspend COLLAB WRITE"); - println!("suspended (froze) COLLAB WRITE"); - let holdings = tokens - .holdings(M1_REPO_CONTRACT, &collab_id) - .await - .expect("holdings"); - println!("COLLAB holdings after suspend: {holdings:?}"); - assert!(holdings.write, "COLLAB still holds the WRITE balance"); - assert!(holdings.write_frozen, "COLLAB WRITE should be frozen"); - - // list_collaborators reflects the frozen status too. - let collaborators = tokens - .list_collaborators(M1_REPO_CONTRACT) - .await - .expect("list_collaborators"); - let collab_entry = collaborators - .iter() - .find(|c| c.identity_id == collab_id) - .expect("COLLAB still a collaborator while frozen"); assert!( - collab_entry.holdings.write_frozen, - "list_collaborators should show COLLAB WRITE frozen" + collaborators.iter().any(|c| c.identity_id == owner_id), + "the repo owner holds the baseSupply tokens" ); - - // token_history shows the mint (grant) record for COLLAB. let history = tokens .token_history(M1_REPO_CONTRACT) .await .expect("token_history"); - let collab_mints = history - .iter() - .filter(|r| { - r.identity == collab_id - && matches!(r.op, forge_core::rules::TokenOp::Mint) - && r.token == forge_core::rules::TokenKind::Write - }) - .count(); - println!( - "token_history: {} record(s) total, {} COLLAB WRITE mint(s)", - history.len(), - collab_mints - ); - assert!(collab_mints >= 1, "a COLLAB WRITE mint should be recorded"); - - if std::env::var("FORGE_TOKEN_DESTROY").is_ok() { - // Full revoke (destroys COLLAB's frozen balance). - tokens - .revoke(M1_REPO_CONTRACT, &collab_id, Role::Write) - .await - .expect("revoke COLLAB WRITE"); - println!("revoked (destroyed) COLLAB WRITE balance"); - let holdings = tokens - .holdings(M1_REPO_CONTRACT, &collab_id) - .await - .expect("holdings"); - assert!(!holdings.write, "after revoke COLLAB holds no WRITE"); - } else { - // Restore clean state so re-runs are cheap and idempotent. - tokens - .unsuspend(M1_REPO_CONTRACT, &collab_id, Role::Write) - .await - .expect("unsuspend COLLAB WRITE"); - println!("unsuspended COLLAB WRITE (restored)"); - let holdings = tokens - .holdings(M1_REPO_CONTRACT, &collab_id) - .await - .expect("holdings"); - assert!(!holdings.write_frozen, "COLLAB WRITE should be unfrozen"); - } + println!("token_history: {} record(s)", history.len()); + let _ = &collab_id; // ===================================================================== // 4. Cleanup (best-effort refund of the deletable docs). // ===================================================================== // The issue + comment are author-owned & deletable; close/reopen events are - // non-deletable (permanent audit log) and stay. Reuse `RepoService::delete_document`. - let repo = forge_core::repo::RepoService::new(&client, &identity, &bridge); - if let Err(e) = repo - .delete_document(M1_REPO_CONTRACT, "comment", &comment_id) + // non-deletable (permanent audit log) and stay. + let contract = client + .fetch_contract(M1_REPO_CONTRACT) + .await + .expect("fetch repo contract"); + let engine = forge_core::platform::WriteEngine::new( + &client, + &identity, + bridge.doc_op_key().expect("doc key"), + ) + .expect("engine"); + if let Err(e) = engine + .delete_document(&contract, "comment", &comment_id) .await { println!("comment cleanup skipped: {e}"); } - if let Err(e) = repo - .delete_document(M1_REPO_CONTRACT, "issue", &issue.document_id) + if let Err(e) = engine + .delete_document(&contract, "issue", &issue.document_id) .await { println!("issue cleanup skipped: {e}"); diff --git a/crates/forge-core/tests/repo_lifecycle.rs b/crates/forge-core/tests/repo_lifecycle.rs index 367cc67d6..1795b2e7a 100644 --- a/crates/forge-core/tests/repo_lifecycle.rs +++ b/crates/forge-core/tests/repo_lifecycle.rs @@ -1,192 +1,225 @@ -//! Live testnet repo-lifecycle test (gated `#[ignore]`). +//! Live forge-v2 repo lifecycle on devnet moutai (gated `#[ignore]`). //! -//! Exercises the full M1 loop against real testnet with the funded DEPLOYER identity: -//! create a repo (measuring the repo-v1 instantiation cost), resolve it via the registry, -//! write + read a ref, round-trip a pack chunk through `PlatformBackend`, then clean up -//! the deletable docs (chunks / manifest / listing — refund; the contract is permanent). +//! With the moutai OWNER / COLLAB / CONTRIB fixtures: +//! +//! 1. create a repo (the `repo` + owner `maintainer` + `config` session) and check the cost +//! is under 0.01 DASH; re-running the create costs nothing and writes nothing; +//! 2. resolve it by `owner/name` and by its id; +//! 3. OWNER writes a ref + a Platform-stored pack and reads both back; +//! 4. grant COLLAB `writer` → COLLAB writes a ref; revoke → COLLAB's next write is refused +//! at consensus with 40120 ([`Error::NotAMember`]); +//! 5. CONTRIB (never a member) is refused the same way. //! -//! Run with: //! ```text //! cargo test -p forge-core --test repo_lifecycle -- --ignored --nocapture //! ``` +//! Identities: `$E2E_IDENTITY_DIR` (default `~/.config/dash-forge/test-identities/devnet-moutai`). +use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use forge_core::backends::PackMeta; +use forge_core::create::{create_repo, CreateRepoOpts, StepOutcome}; use forge_core::keystore::BridgeIdentity; -use forge_core::platform::{Network, PlatformClient}; -use forge_core::repo::{credits_to_dash, CreateRepoOpts, PackManifestInput, RepoService}; +use forge_core::members::{MemberReader, MemberService}; +use forge_core::network::NetworkSettings; +use forge_core::platform::{LoadedIdentity, PlatformClient}; +use forge_core::repo::{credits_to_dash, PackManifestInput, RepoService}; +use forge_core::resolve::{resolve_id, resolve_named}; +use forge_core::rules::v2::Role; use forge_core::rules::RefState; +use forge_core::storage::PackReader; +use forge_core::Error; + +fn fixture(role: &str) -> BridgeIdentity { + let dir = std::env::var_os("E2E_IDENTITY_DIR").map_or_else( + || { + PathBuf::from(std::env::var_os("HOME").expect("HOME")) + .join(".config/dash-forge/test-identities/devnet-moutai") + }, + PathBuf::from, + ); + BridgeIdentity::load_from_file(dir.join(format!("{role}.identity.json"))) + .unwrap_or_else(|e| panic!("load {role}: {e}")) +} -const DEPLOYER_PATH: &str = - "/Users/pasta/.config/dash-forge/test-identities/DEPLOYER.identity.json"; - -#[tokio::test] -#[ignore = "live testnet; spends ~1-2 tDASH; run manually"] -#[allow(clippy::too_many_lines)] -async fn full_repo_lifecycle_on_testnet() { - let bridge = BridgeIdentity::load_from_file(DEPLOYER_PATH).expect("load DEPLOYER identity"); - let owner_id = bridge.identity_id.clone(); - println!("owner (DEPLOYER): {owner_id}"); - - let client = PlatformClient::connect_network(Network::Testnet) - .await - .expect("connect testnet"); - let identity = client - .fetch_identity(&owner_id) +async fn loaded(client: &PlatformClient, b: &BridgeIdentity) -> LoadedIdentity { + client + .fetch_identity(&b.identity_id) .await - .expect("fetch DEPLOYER identity"); - println!("balance before: {} credits", identity.balance()); + .expect("fetch identity") +} - let service = RepoService::new(&client, &identity, &bridge); +async fn ref_tip( + svc: &RepoService<'_>, + repo: &forge_core::scope::RepoRef, + name: &str, +) -> Option { + for _ in 0..8 { + let refs = svc.read_refs(repo).await.expect("read_refs"); + if let Some((_, RefState::Resolved { oid, .. })) = refs.iter().find(|(n, _)| n == name) { + return Some(oid.clone()); + } + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + } + None +} - // Unique per run so a re-run does not collide on the (ownerId, normalizedName) index. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "live devnet moutai; spends ~0.01 DASH; run manually"] +#[allow(clippy::too_many_lines)] +async fn forge_v2_repo_lifecycle_on_moutai() { + let target = NetworkSettings { + devnet_name: Some("moutai".into()), + ..Default::default() + } + .resolve() + .unwrap(); + let client = PlatformClient::connect(target) + .await + .expect("connect moutai"); + let owner_b = fixture("OWNER"); + let collab_b = fixture("COLLAB"); + let contrib_b = fixture("CONTRIB"); + let owner = loaded(&client, &owner_b).await; + let collab = loaded(&client, &collab_b).await; + let contrib = loaded(&client, &contrib_b).await; + let journals = tempfile::tempdir().unwrap(); + + // --- 1. create --- let suffix = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - let name = format!("m1-test-{suffix}"); - - // --- 1. create_repo (headline cost) --- - // - // `FORGE_REUSE_CONTRACT=` resumes against an already-created repo-v1 contract - // (skipping the paid DataContractCreate) — used when the funded identity already paid - // for a create whose follow-ups failed, so the ~1.18 DASH create is not repeated. - let opts = CreateRepoOpts { - default_branch: "main".into(), - backend_mode: 0, - description: "M1 lifecycle test repo".into(), - template_version: 1, - }; - let reuse = std::env::var("FORGE_REUSE_CONTRACT").unwrap_or_default(); - let created = if reuse.is_empty() { - service - .create_repo(&name, &opts) - .await - .expect("create_repo") - } else { - println!("resuming against existing repo contract {reuse}"); - service - .resume_repo(&reuse, &name, &opts) - .await - .expect("resume_repo") - }; - let handle = created.handle.clone(); - let cost = created.repo_v1_instantiation_cost_credits; + let mut opts = CreateRepoOpts::public(format!("v2-life-{suffix}")); + opts.description = "forge-v2 lifecycle test".into(); + let created = create_repo(&client, &owner, &owner_b, &opts, journals.path()) + .await + .expect("create_repo"); println!( - "REPO-V1 INSTANTIATION COST: {cost} credits = {:.6} DASH", - credits_to_dash(cost) + "created {} ({}) cost {} credits = {:.6} DASH; steps {:?}", + created.repo.display(), + created.repo.id(), + created.cost_credits, + credits_to_dash(created.cost_credits), + created.steps ); - println!("repo contract id: {}", handle.repo_contract_id); - if reuse.is_empty() { - assert!(cost > 0, "instantiation cost should be measured"); - } - - // --- 2. resolve via registry --- - let resolved = service - .resolve_repo(&owner_id, &name) + assert!(created + .steps + .iter() + .all(|(_, o)| *o == StepOutcome::Created)); + assert!( + credits_to_dash(created.cost_credits) <= 0.01, + "repo create must cost ≤ 0.01 DASH" + ); + let again = create_repo(&client, &owner, &owner_b, &opts, journals.path()) + .await + .expect("re-run create"); + assert!(again.already_existed(), "{:?}", again.steps); + assert_eq!(again.repo, created.repo); + println!("re-run cost {} credits (no double-pay)", again.cost_credits); + let repo = created.repo; + + // --- 2. resolve --- + let by_name = resolve_named(&client, &owner.id(), &opts.name.to_uppercase()) .await - .expect("resolve_repo"); + .expect("resolve by name"); + assert_eq!(by_name, repo); assert_eq!( - resolved.repo_contract_id, handle.repo_contract_id, - "resolved contract id must match the created one" + resolve_id(&client, repo.id()).await.expect("resolve by id"), + repo ); - println!("resolve_repo OK -> {}", resolved.repo_contract_id); - // --- 3. write a ref update (refs/heads/main -> oid) --- + // --- 3. OWNER writes a ref + pack --- + let svc = RepoService::new(&client, &owner, &owner_b); + assert_eq!( + svc.read_default_branch(&repo).await.unwrap().as_deref(), + Some("main") + ); let oid = [0x11u8; 20]; - let ref_name = "refs/heads/main"; - let ref_doc_id = service - .write_ref_update(&handle, ref_name, &oid, None, false) + svc.write_ref_update(&repo, "refs/heads/main", &oid, None, false) .await - .expect("write_ref_update"); - println!("wrote refUpdate {ref_doc_id}"); - - // --- 4. read_refs resolves it --- - let refs = service.read_refs(&handle).await.expect("read_refs"); - println!("read_refs -> {refs:?}"); - let main = refs - .iter() - .find(|(n, _)| n == ref_name) - .expect("refs/heads/main present"); - match &main.1 { - RefState::Resolved { oid: got, .. } => { - assert_eq!( - *got, - hex::encode(oid), - "resolved tip must equal written oid" - ); - } - other => panic!("expected Resolved, got {other:?}"), - } - println!("ref resolved correctly"); + .expect("owner ref write"); + assert_eq!( + ref_tip(&svc, &repo, "refs/heads/main").await, + Some(hex::encode(oid)) + ); - // --- 5. write a small pack (chunk) + manifest via PlatformBackend --- - let pack_bytes: Vec = (0..5000u32).map(|i| (i % 251) as u8).collect(); - let meta = PackMeta::for_bytes(&pack_bytes); - let pack_hash = meta.pack_hash_bytes().expect("pack hash bytes"); - let locators = service - .put_pack(&handle, &pack_bytes, &meta) + let payload: Vec = (0..9000u32) + .map(|i| u8::try_from(i % 251).unwrap()) + .collect(); + let meta = PackMeta::for_bytes(&payload); + let uris = svc + .put_pack(&repo, &payload, &meta) .await .expect("put_pack"); - println!("put_pack -> {locators:?}"); - let locator = locators.first().expect("one locator").clone(); - - let manifest = PackManifestInput { - pack_hash, - kind: 0, - size_bytes: pack_bytes.len() as u64, - object_count: 1, - chunk_count: 1, - storage: 0, - offset_index_parts: 1, - uris: vec![locator.0.clone()], - supersedes: Vec::new(), - tips: Vec::new(), - }; - let manifest_id = service - .write_pack_manifest(&handle, &manifest) + println!("pack stored at {uris:?}"); + svc.write_pack_manifest( + &repo, + &PackManifestInput { + pack_hash: meta.pack_hash_bytes().unwrap(), + kind: 3, // not a git pack: stays out of fetch and the locator space + size_bytes: payload.len() as u64, + object_count: 0, + chunk_count: forge_core::pack::split(&payload).len() as u64, + storage: 0, + offset_index_parts: 0, + uris: uris.iter().map(|u| u.0.clone()).collect(), + supersedes: Vec::new(), + tips: Vec::new(), + }, + ) + .await + .expect("manifest"); + let manifests = svc.read_pack_manifests(&repo).await.unwrap(); + let m = manifests + .iter() + .find(|m| m.pack_hash == meta.pack_hash_bytes().unwrap()) + .unwrap(); + assert_eq!(m.owner_id, owner.id()); + let got = svc + .fetch_artifact(&repo, m, &PackReader::from_user_config()) .await - .expect("write_pack_manifest"); - println!("wrote packManifest {manifest_id}"); + .expect("read pack back"); + assert_eq!(got, payload); - let manifests = service - .read_pack_manifests(&handle) + // --- 4. writer grant → write → revoke → 40120 --- + let members = MemberService::new(&client, &owner, &owner_b); + members + .grant(&repo, &collab.id(), Role::Writer) .await - .expect("read_pack_manifests"); - assert!( - manifests.iter().any(|m| m.pack_hash == pack_hash), - "manifest should be readable" - ); - - // --- 6. read the chunk back and verify bytes --- - let got = service - .get_pack(&handle, &locator, None) + .expect("grant"); + let listed = MemberReader::new(&client).list(&repo).await.unwrap(); + assert!(listed + .iter() + .any(|m| m.identity_id == collab.id() && m.role == Role::Writer)); + assert!(listed + .iter() + .any(|m| m.identity_id == owner.id() && m.role == Role::Maintainer)); + let collab_svc = RepoService::new(&client, &collab, &collab_b); + collab_svc + .write_ref_update(&repo, "refs/heads/collab", &[0x22; 20], None, false) .await - .expect("get_pack"); - assert_eq!(got, pack_bytes, "chunk round-trip must be bit-for-bit"); - println!("chunk round-trip OK ({} bytes)", got.len()); - - // --- 7. cleanup (refund the deletable docs) --- - let removed = service - .delete_chunks(&handle, pack_hash) + .expect("writer can push"); + assert!(members + .revoke(&repo, &collab.id(), Role::Writer) .await - .expect("delete_chunks"); - println!("deleted {removed} chunk(s)"); - service - .delete_document(&handle.repo_contract_id, "packManifest", &manifest_id) + .unwrap()); + let err = collab_svc + .write_ref_update(&repo, "refs/heads/collab", &[0x33; 20], None, false) .await - .expect("delete packManifest"); - service - .delete_document( - client.registry_contract_id().expect("testnet registry"), - "repoListing", - &created.listing_document_id, - ) + .expect_err("revoked writer must be refused"); + println!("revoked writer: {err}"); + assert!(matches!(err, Error::NotAMember(_)), "{err}"); + assert!(err.to_string().contains("40120"), "{err}"); + + // --- 5. never a member --- + let err = RepoService::new(&client, &contrib, &contrib_b) + .write_ref_update(&repo, "refs/heads/contrib", &[0x44; 20], None, false) .await - .expect("delete repoListing"); - println!("cleanup done (contract + non-deletable config/refUpdate remain permanently)"); + .expect_err("non-member must be refused"); + assert!(matches!(err, Error::NotAMember(_)), "{err}"); - let after = client.get_balance(&owner_id).await.expect("balance after"); - println!("balance after (post-refund): {after} credits"); + let after = client.get_balance(&owner.id()).await.unwrap(); + println!("OWNER balance now {after} credits"); } diff --git a/crates/forge-import/src/estimate.rs b/crates/forge-import/src/estimate.rs index 1551df399..754898e91 100644 --- a/crates/forge-import/src/estimate.rs +++ b/crates/forge-import/src/estimate.rs @@ -13,9 +13,9 @@ use forge_core::pack::split; use crate::github::{GhIssue, GhLabel, GhMilestone, GhPull, GhRelease, RepoMeta}; -/// Measured repo-v1 instantiation cost, in credits (~1.18 DASH, EXECUTION.md economics). -/// Only charged when the migration creates a fresh destination repo. -pub const REPO_V1_CREATE_CREDITS: u64 = 118_000_000_000; +/// A forge-v2 repo create (`repo` + owner `maintainer` + first `config`), in credits: an +/// upper bound on three small documents. Charged when the migration creates the repo. +pub const REPO_CREATE_CREDITS: u64 = 200_000_000; /// Serialized system-field + CBOR-map overhead added to each document's property bytes /// (`$id`, `$ownerId`, `$revision`, `$createdAt`, `$updatedAt`, type tag, map framing). @@ -137,6 +137,24 @@ impl Plan { self.ref_count = ref_count; } + /// Whether any collaboration artifact (issue, PR, release, label, milestone) is planned. + pub fn has_collab(&self) -> bool { + !(self.issues.is_empty() + && self.pulls.is_empty() + && self.releases.is_empty() + && self.labels.is_empty() + && self.milestones.is_empty()) + } + + /// Drop every collaboration artifact from the plan (a git-only import). + pub fn drop_collab(&mut self) { + self.issues.clear(); + self.pulls.clear(); + self.releases.clear(); + self.labels.clear(); + self.milestones.clear(); + } + /// Total projected comment volume across issues + PRs (from GitHub's per-item counter). pub fn projected_comments(&self) -> u64 { self.issues.iter().map(|i| i.comments).sum::() @@ -147,12 +165,10 @@ impl Plan { pub fn cost(&self, skip: SkipFlags) -> Vec { let mut out = Vec::new(); if self.creates_repo { - // The instantiation cost is measured, not per-byte — a single deposit-heavy line - // (dominated by count-tree + token storage). out.push(ClassCost { label: "repo-create", count: 1, - deposit: REPO_V1_CREATE_CREDITS, + deposit: REPO_CREATE_CREDITS, burn: 0, }); } @@ -404,6 +420,6 @@ mod tests { let costs = plan.cost(SkipFlags::default()); let rc = costs.iter().find(|c| c.label == "repo-create").unwrap(); assert_eq!(rc.count, 1); - assert_eq!(rc.deposit, super::REPO_V1_CREATE_CREDITS); + assert_eq!(rc.deposit, super::REPO_CREATE_CREDITS); } } diff --git a/crates/forge-import/src/importer.rs b/crates/forge-import/src/importer.rs index a687f7ac0..f9f5d2ab5 100644 --- a/crates/forge-import/src/importer.rs +++ b/crates/forge-import/src/importer.rs @@ -17,11 +17,12 @@ use forge_core::collab::{ Imported, IssueService, LabelService, PullRequestInput, PullRequestService, ReleaseInput, ReleaseService, }; +use forge_core::create::{create_repo, default_journal_dir, CreateRepoOpts}; use forge_core::keystore::BridgeIdentity; use forge_core::network::NetworkTarget; use forge_core::pack::build_pack; use forge_core::platform::{LoadedIdentity, PlatformClient}; -use forge_core::repo::{credits_to_dash, CreateRepoOpts, RepoService}; +use forge_core::repo::credits_to_dash; use forge_core::rules::EventKind; use crate::estimate::{ClassCost, Plan, SkipFlags}; @@ -109,9 +110,22 @@ pub async fn run(cfg: &ImportConfig) -> Result<()> { std::process::id() )); let _clone_guard = CloneGuard(clone_dir.clone()); - let push_git = cfg.repo_contract_id.is_none(); - if push_git { - size_git_data(&gh, &clone_dir, &mut plan)?; + if cfg.repo_contract_id.is_some() { + bail!( + "--repo-contract names a v1 repository, and v1 repositories are read only; import \ + into a new forge-v2 repository instead (omit --repo-contract)" + ); + } + size_git_data(&gh, &clone_dir, &mut plan)?; + // Collaboration documents (issues, PRs, labels, releases) move to the forge-collab + // contract in a later release; until then a forge-v2 import carries the git history. + if plan.has_collab() { + println!( + "note: importing issues, pull requests, labels and releases into forge-v2 is not \ + supported yet; this run imports the git history only (re-run with --resume once \ + collaboration import lands)" + ); + plan.drop_collab(); } // A fresh repo is created only when no existing contract was named and none resolves. let mut state = ImportState::load_or_new(&cfg.resume_path, &cfg.source.slug())?; @@ -152,29 +166,15 @@ pub async fn run(cfg: &ImportConfig) -> Result<()> { let balance_before = identity.balance(); // 5. Resolve or create the destination repo. - let repo_contract_id = - resolve_or_create(&client, &identity, &bridge, cfg, &plan, &mut state).await?; + resolve_or_create(&client, &identity, &bridge, cfg, &plan, &mut state).await?; - // 6. Git data (skipped for the import-into-existing-contract path). - if push_git && !state.refs_pushed { + // 6. Git data. + if !state.refs_pushed { push_git_data(cfg, &clone_dir, &state)?; state.refs_pushed = true; state.save()?; } - // 7. Collaboration docs. - import_collab( - &client, - &identity, - &bridge, - &gh, - cfg, - &plan, - &repo_contract_id, - &mut state, - ) - .await?; - // 8. Report actual vs estimated. let after = client .get_balance(&bridge.identity_id) @@ -191,7 +191,7 @@ fn enumerate(gh: &GithubClient, cfg: &ImportConfig) -> Result { let meta = gh.repo_meta()?; let mut plan = Plan { meta, - creates_repo: cfg.repo_contract_id.is_none(), + creates_repo: true, ..Plan::default() }; @@ -272,9 +272,8 @@ fn ref_tips(repo: &Path) -> Result> { Ok(tips) } -/// Resolve the destination repo (existing contract id, or by name under the signing owner), -/// creating a fresh repo-v1 contract when none exists. Records the outcome in `state` so a -/// resume never re-pays the ~1.18 DASH create. +/// Create the destination forge-v2 repo (or finish creating it: the create session is +/// resumable and never pays for a step twice). Records the outcome in `state`. async fn resolve_or_create( client: &PlatformClient, identity: &LoadedIdentity, @@ -282,29 +281,15 @@ async fn resolve_or_create( cfg: &ImportConfig, plan: &Plan, state: &mut ImportState, -) -> Result { - if let Some(id) = &cfg.repo_contract_id { - // Verify it exists / is fetchable, then import collab straight into it. - client - .fetch_contract(id) - .await - .with_context(|| format!("fetching destination contract {id}"))?; - state.repo_contract_id = Some(id.clone()); - state.save()?; - tracing::info!(repo_contract = %id, "importing into existing repo contract"); - return Ok(id.clone()); - } - - if let Some(id) = &state.repo_contract_id { - tracing::info!(repo_contract = %id, "resuming into repo from state"); - return Ok(id.clone()); +) -> Result<()> { + if let (Some(owner), Some(name)) = (&state.owner_id, &state.repo_name) { + tracing::info!(%owner, %name, "resuming into repo from state"); + return Ok(()); } - let name = cfg .repo_name .clone() .unwrap_or_else(|| cfg.source.repo.clone()); - let svc = RepoService::new(client, identity, bridge); let default_branch = if plan.meta.default_branch.is_empty() { "main".to_string() } else { @@ -323,22 +308,23 @@ async fn resolve_or_create( 500, ); let opts = CreateRepoOpts { + name, + display_name: String::new(), + description, default_branch, backend_mode: cfg.backend.mode(), - description, - template_version: 1, + visibility: forge_core::rules::v2::Visibility::Public, }; - tracing::info!(%name, "creating destination repo (repo-v1 contract)"); - let result = svc - .create_repo(&name, &opts) + tracing::info!(name = %opts.name, "creating destination repo (forge-v2)"); + let result = create_repo(client, identity, bridge, &opts, &default_journal_dir()?) .await .context("creating destination repo")?; - state.repo_contract_id = Some(result.handle.repo_contract_id.clone()); - state.owner_id = Some(result.handle.owner_id.clone()); - state.repo_name = Some(result.handle.normalized_name.clone()); - state.repo_created = result.repo_v1_instantiation_cost_credits > 0; - state.add_spend(result.repo_v1_instantiation_cost_credits)?; - Ok(result.handle.repo_contract_id) + state.repo_contract_id = Some(result.repo.id().to_string()); + state.owner_id = Some(result.repo.owner_id().to_string()); + state.repo_name = Some(result.repo.name().to_string()); + state.repo_created = !result.already_existed(); + state.add_spend(result.cost_credits)?; + Ok(()) } /// Push all branches + tags through `git push dash:///` — the M1-proven helper @@ -471,7 +457,10 @@ fn journal_idle_time(clone_dir: &Path) -> Option { /// Import labels, milestone-derived labels, issues (+ state/label events + comments), PRs /// (+ state events), and releases — each resumable and provenance-stamped. -#[allow(clippy::too_many_arguments)] +/// +/// Addresses a v1 repo contract. Not called while collaboration documents are moving to +/// forge-collab; kept for that migration. +#[allow(clippy::too_many_arguments, dead_code)] async fn import_collab( client: &PlatformClient, identity: &LoadedIdentity, diff --git a/crates/git-remote-dash/src/admin.rs b/crates/git-remote-dash/src/admin.rs index 22ac2142e..ce471b1c8 100644 --- a/crates/git-remote-dash/src/admin.rs +++ b/crates/git-remote-dash/src/admin.rs @@ -1,34 +1,20 @@ //! Out-of-protocol admin commands used to provision and inspect `dash://` repos. //! //! These are not part of the git remote-helper protocol; they are a thin CLI over -//! `forge-core`'s [`RepoService`] so the M1 round-trip can create a repo, check the -//! signing identity's balance, and refund deletable storage after the test. The real, -//! polished surface for this is `dg` (PRD 02 §B); this is the minimal helper-local shim. +//! `forge-core` so test scripts can create a repo, check the signing identity's balance and +//! read raw ref documents. The real, polished surface for this is `dg` (PRD 02 §B). use anyhow::{anyhow, bail, Context, Result}; use tokio::runtime::Runtime; +use forge_core::create::{create_repo as create_v2, default_journal_dir, CreateRepoOpts}; use forge_core::keystore::BridgeIdentity; use forge_core::platform::{PlatformClient, QueryOrder}; -use forge_core::repo::{credits_to_dash, CreateRepoOpts, RepoService}; +use forge_core::repo::{credits_to_dash, RepoService}; +use forge_core::resolve::resolve_named; use crate::helper::network_target; -/// Default nonce-scan window for `--resume-repo` (how many nonces back to look for the -/// orphan contract). Widened from the original 3 so intervening writes cannot hide it. -const DEFAULT_RESUME_WINDOW: u64 = 20; - -/// The `CreateRepoOpts` the M1 provisioning path uses (default branch `main`, platform -/// backend). Shared by the create and resume flows so they stay in lockstep. -fn m1_repo_opts() -> CreateRepoOpts { - CreateRepoOpts { - default_branch: "main".to_string(), - backend_mode: 0, - description: "Dash Forge M1 round-trip test repo".to_string(), - template_version: 1, - } -} - /// Dispatch an admin subcommand (`args[0]` is the `--…` verb). pub fn run(rt: &Runtime, args: &[String]) -> Result<()> { match args.first().map(String::as_str) { @@ -39,19 +25,6 @@ pub fn run(rt: &Runtime, args: &[String]) -> Result<()> { rt.block_on(create_repo(name)) } Some("--balance") => rt.block_on(balance()), - Some("--resume-repo") => { - let name = args.get(1).ok_or_else(|| { - anyhow!("usage: git-remote-dash --resume-repo [nonce-window]") - })?; - // Optional widened scan window (default DEFAULT_RESUME_WINDOW nonces back). - let window = args - .get(2) - .map(|s| s.parse::()) - .transpose() - .map_err(|e| anyhow!("nonce-window must be an integer: {e}"))? - .unwrap_or(DEFAULT_RESUME_WINDOW); - rt.block_on(resume_repo(name, window)) - } Some("--write-ref") => { let owner = args.get(1); let repo = args.get(2); @@ -70,15 +43,10 @@ pub fn run(rt: &Runtime, args: &[String]) -> Result<()> { _ => bail!("usage: git-remote-dash --dump-refs "), } } - Some("--teardown") => { - let owner = args - .get(1) - .ok_or_else(|| anyhow!("usage: git-remote-dash --teardown "))?; - let repo = args - .get(2) - .ok_or_else(|| anyhow!("usage: git-remote-dash --teardown "))?; - rt.block_on(teardown(owner, repo)) - } + Some(verb @ ("--resume-repo" | "--teardown")) => bail!( + "{verb} is gone: repo creation is resumable (re-run --create-repo), and forge-v2 \ + packs are permanent" + ), other => bail!("unknown admin command {other:?}"), } } @@ -96,87 +64,29 @@ async fn connect() -> Result<(PlatformClient, BridgeIdentity)> { Ok((client, bridge)) } -/// Create a fresh repo owned by the `DASH_FORGE_KEY` identity, printing its ids and the -/// measured instantiation cost. +/// Create (or finish creating) a forge-v2 repo owned by the `DASH_FORGE_KEY` identity, +/// printing its ids and what it cost. async fn create_repo(name: &str) -> Result<()> { let (client, bridge) = connect().await?; let identity = client.fetch_identity(&bridge.identity_id).await?; let before = identity.balance(); - - let svc = RepoService::new(&client, &identity, &bridge); - let result = svc - .create_repo(name, &m1_repo_opts()) - .await - .context("create_repo")?; - - println!("repo_contract_id={}", result.handle.repo_contract_id); - println!("owner_id={}", result.handle.owner_id); - println!("normalized_name={}", result.handle.normalized_name); - println!("listing_document_id={}", result.listing_document_id); - println!( - "instantiation_cost_credits={}", - result.repo_v1_instantiation_cost_credits - ); - println!( - "instantiation_cost_dash={:.6}", - credits_to_dash(result.repo_v1_instantiation_cost_credits) - ); + let result = create_v2( + &client, + &identity, + &bridge, + &CreateRepoOpts::public(name), + &default_journal_dir()?, + ) + .await + .context("create_repo")?; + + println!("repo_id={}", result.repo.id()); + println!("owner_id={}", result.repo.owner_id()); + println!("name={}", result.repo.name()); + println!("cost_credits={}", result.cost_credits); + println!("cost_dash={:.6}", credits_to_dash(result.cost_credits)); println!("balance_before_dash={:.6}", credits_to_dash(before)); - println!( - "remote_url=dash://{}/{}", - result.handle.owner_id, result.handle.normalized_name - ); - Ok(()) -} - -/// Recover a repo whose (already paid-for) DataContractCreate landed but whose follow-on -/// `config` + `repoListing` writes did not — the case where `broadcast_and_wait` returned -/// a cached `AlreadyExists`. Scans recent identity nonces, derives the deterministic -/// contract id `hash(ownerId || nonce)`, and finalizes the first one that exists on-chain -/// without paying for a second create. -async fn resume_repo(name: &str, window: u64) -> Result<()> { - let (client, bridge) = connect().await?; - let identity = client.fetch_identity(&bridge.identity_id).await?; - let owner = &bridge.identity_id; - let current = client.identity_nonce(owner).await?; - tracing::info!( - nonce = current, - window, - "current identity nonce; scanning for orphan contract" - ); - - // The create bumped-and-used the nonce; if it landed, the on-chain nonce is that value. - // Scan `window` nonces back for robustness (configurable — intervening writes can bump - // the nonce past the create before a resume is attempted). - let mut found: Option = None; - for nonce in (current.saturating_sub(window)..=current).rev() { - let candidate = client.derive_contract_id(owner, nonce)?; - if client.fetch_contract(&candidate).await.is_ok() { - println!("found_orphan_contract id={candidate} nonce={nonce}"); - found = Some(candidate); - break; - } - } - let contract_id = found.ok_or_else(|| { - anyhow!( - "no orphan contract found in the recent nonce window; the create may not have landed" - ) - })?; - - let svc = RepoService::new(&client, &identity, &bridge); - let result = svc - .resume_repo(&contract_id, name, &m1_repo_opts()) - .await - .context("resume_repo (finalize config + listing)")?; - - println!("repo_contract_id={}", result.handle.repo_contract_id); - println!("owner_id={}", result.handle.owner_id); - println!("normalized_name={}", result.handle.normalized_name); - println!("listing_document_id={}", result.listing_document_id); - println!( - "remote_url=dash://{}/{}", - result.handle.owner_id, result.handle.normalized_name - ); + println!("remote_url={}", result.repo.remote_url()); Ok(()) } @@ -187,13 +97,12 @@ async fn write_ref(owner: &str, repo: &str, ref_name: &str, oid_hex: &str) -> Re let (client, bridge) = connect().await?; let identity = client.fetch_identity(&bridge.identity_id).await?; let svc = RepoService::new(&client, &identity, &bridge); - let handle = svc - .resolve_repo(owner, repo) + let repo = resolve_named(&client, owner, repo) .await - .context("resolve_repo")?; + .context("resolving repo")?; let new_oid = hex::decode(oid_hex).context("oid must be hex")?; let doc_id = svc - .write_ref_update(&handle, ref_name, &new_oid, None, false) + .write_ref_update(&repo, ref_name, &new_oid, None, false) .await .context("write_ref_update")?; println!("wrote_ref_update id={doc_id} ref={ref_name:?}"); @@ -202,26 +111,31 @@ async fn write_ref(owner: &str, repo: &str, ref_name: &str, oid_hex: &str) -> Re /// Dump raw `refUpdate` / `protectedRefUpdate` documents (diagnostic). async fn dump_refs(owner: &str, repo: &str) -> Result<()> { - let (client, bridge) = connect().await?; - let identity = client.fetch_identity(&bridge.identity_id).await?; - let svc = RepoService::new(&client, &identity, &bridge); - let handle = svc.resolve_repo(owner, repo).await?; - let contract = client.fetch_contract(&handle.repo_contract_id).await?; + let (client, _bridge) = connect().await?; + let repo = resolve_named(&client, owner, repo).await?; + let scope = repo.scope()?; + let contract = client.fetch_contract(&scope.contract_id).await?; for doc_type in ["refUpdate", "protectedRefUpdate"] { // A diagnostic that dumps "the raw history" must dump all of it. let docs = client - .query_all_documents(&contract, doc_type, &[], &[QueryOrder::asc("$createdAt")]) + .query_all_documents( + &contract, + doc_type, + &scope.filters([]), + &[QueryOrder::asc("$createdAt")], + ) .await?; println!("--- {doc_type}: {} docs ---", docs.len()); for d in &docs { println!( - " ref={:?} new={} prev={} force={} createdAt={} id={}", + " ref={:?} new={} prev={} force={} createdAt={} id={} owner={}", d.field_str("refName").unwrap_or_default(), d.field_hex("newOid").unwrap_or_default(), d.field_hex("prevOid").unwrap_or_default(), d.field_bool("force"), d.created_at.unwrap_or_default(), d.id, + d.owner_id, ); } } @@ -237,34 +151,3 @@ async fn balance() -> Result<()> { println!("balance_dash={:.6}", credits_to_dash(credits)); Ok(()) } - -/// Best-effort refund of a repo's deletable storage (chunks + pack manifests). The repo -/// contract and its audit docs are permanent by design; the registry listing is left in -/// place (a small doc) so the repo stays resolvable. -async fn teardown(owner: &str, repo: &str) -> Result<()> { - let (client, bridge) = connect().await?; - let identity = client.fetch_identity(&bridge.identity_id).await?; - let svc = RepoService::new(&client, &identity, &bridge); - - let handle = svc - .resolve_repo(owner, repo) - .await - .context("resolve_repo")?; - let manifests = svc.read_pack_manifests(&handle).await?; - println!("manifests={}", manifests.len()); - - for m in &manifests { - match svc.delete_chunks(&handle, m.pack_hash).await { - Ok(n) => println!("deleted_chunks pack={} count={n}", hex::encode(m.pack_hash)), - Err(e) => tracing::warn!(error = %e, "delete_chunks failed (continuing)"), - } - match svc - .delete_document(&handle.repo_contract_id, "packManifest", &m.document_id) - .await - { - Ok(()) => println!("deleted_manifest id={}", m.document_id), - Err(e) => tracing::warn!(error = %e, "delete manifest failed (continuing)"), - } - } - Ok(()) -} diff --git a/crates/git-remote-dash/src/helper.rs b/crates/git-remote-dash/src/helper.rs index 73650db30..a42ef041b 100644 --- a/crates/git-remote-dash/src/helper.rs +++ b/crates/git-remote-dash/src/helper.rs @@ -2,11 +2,15 @@ //! `list` / `fetch` / `push` operations against `forge-core`'s [`RepoService`]. //! //! Data flow (architecture §6): -//! - **list** → `resolve_repo` → `read_refs` (proof-verified, folded by `FORGE_RULES_V1`) → -//! ` ` lines + the `HEAD` symref from the repo's default branch. -//! - **fetch** → collect `packManifest`s (kind 0) → `get_pack` each (SHA-256-verified) → -//! `git index-pack` into the local odb. A `--filter` partial clone re-packs the download -//! through a scratch repo and writes the `.promisor` marker (S0.9). +//! - **resolve** → `dash://owner/name` is a forge-v2 `repo` (else a v1 registry listing, +//! read only); `dash://` a v2 repo id or a v1 repo contract id +//! (`forge_core::resolve`). +//! - **list** → `read_refs` (proof-verified, folded by the ref rules) → ` ` lines +//! + the `HEAD` symref from the repo's default branch. +//! - **fetch** → every kind-0 pack's copies → the first copy that verifies, in the +//! `FORGE_RULES_V2` order (maintainers' copies first) → `git index-pack` into the local +//! odb. A `--filter` partial clone re-packs the download through a scratch repo and +//! writes the `.promisor` marker (S0.9). //! - **push** → fast-forward check vs remote refs → `build_pack` (thin + `--fix-thin` = //! self-contained) → the repo's storage policy (`dash.storage` / `dash.replicas`, see //! [`crate::policy`]) → cost guard → replicate the pack to every target in parallel, @@ -14,25 +18,27 @@ //! `write_pack_manifest` (every confirmed URI + the SHA-256) → `write_ref_update` //! (prevOid recorded; non-FF refused without `+`) → post-push ref re-read for a //! lost-race late non-fast-forward. Refs are written ONLY after the storage policy is -//! met and the manifest has landed. +//! met and the manifest has landed. A v1 repository refuses every push (read only). use std::path::PathBuf; use anyhow::{anyhow, bail, Context, Result}; use forge_core::backends::PackMeta; use forge_core::keystore::BridgeIdentity; +use forge_core::members::MemberReader; use forge_core::network::{NetworkSettings, NetworkTarget}; use forge_core::pack::{build_pack, split, KIND_GIT_PACK}; use forge_core::platform::{LoadedIdentity, PlatformClient}; use forge_core::repo::{ - PackManifestInput, PlatformChunkTarget, RepackTarget, RepoHandle, RepoService, StoredArtifact, + group_by_hash, PackManifestInput, PlatformChunkTarget, RepackTarget, RepoService, + StoredArtifact, }; -use forge_core::rules::{Holdings, RefState, TokenRecord}; +use forge_core::rules::RefState; +use forge_core::scope::RepoRef; use forge_core::storage::{ human_bytes, replicate, ExternalTarget, Observed, PackReader, Replica, Replication, StorageTarget, StoreOutcome, }; -use forge_core::tokens::TokenService; use forge_core::user_error::{codes, dash, UserError, NOTE_PLATFORM_CHUNKS_JOURNALED}; use futures::stream::{self, StreamExt, TryStreamExt}; @@ -89,7 +95,7 @@ struct Conn { client: PlatformClient, identity: LoadedIdentity, bridge: BridgeIdentity, - repo: RepoHandle, + repo: RepoRef, } /// The remote helper, holding parsed config and a lazily-established connection. @@ -146,23 +152,21 @@ impl Helper { .fetch_identity(&bridge.identity_id) .await .with_context(|| format!("fetching identity {}", bridge.identity_id))?; - let repo = { - let svc = RepoService::new(&client, &identity, &bridge); - match &self.url { - DashUrl::Named { owner, repo } => svc - .resolve_repo(owner, repo) + let repo = match &self.url { + DashUrl::Named { owner, repo } => { + forge_core::resolve::resolve_named(&client, owner, repo) .await - .with_context(|| format!("resolving repo {owner}/{repo}"))?, - // Contract-addressed: no registry lookup, the contract carries its owner. - DashUrl::Contract { contract_id } => svc - .resolve_repo_by_contract(contract_id) - .await - .with_context(|| format!("resolving repo contract {contract_id}"))?, + .with_context(|| format!("resolving repo {owner}/{repo}"))? } + DashUrl::Id { id } => forge_core::resolve::resolve_id(&client, id) + .await + .with_context(|| format!("resolving repo {id}"))?, }; + repo.require_readable()?; tracing::info!( - repo_contract = %repo.repo_contract_id, - owner = %repo.owner_id, + repo = %repo.id(), + generation = repo.generation(), + owner = %repo.owner_id(), "resolved dash:// repo" ); self.conn = Some(Conn { @@ -258,16 +262,22 @@ impl Helper { // Each pack comes from the first of its recorded copies that hash-verifies — // external URIs raced with the configured IPFS gateways — with Platform chunks as // the last resort. + // + // forge-v2: each uploader may hold its own copy of a pack. A pack is read from the + // first copy that verifies, maintainers' copies first (FORGE_RULES_V2 reader rule). let svc = &svc; let repo = &conn.repo; let contract = &svc.repo_contract(repo).await?; let reader = &PackReader::from_user_config(); - let fetched: Vec>> = stream::iter(git_packs.iter().map(|m| async move { - let hash = hex::encode(m.pack_hash); - let got = svc.fetch_artifact_from(repo, contract, m, reader).await; + let roles = &svc.copy_roles(repo).await?; + let packs = group_by_hash(&git_packs); + let fetched: Vec>> = stream::iter(packs.iter().map(|(h, copies)| async move { + let hash = hex::encode(h); + let got = svc.fetch_best_copy(repo, contract, copies, roles, reader).await; + let on_chain = copies.iter().any(|m| m.storage == 0); let bytes = match got { - Ok(bytes) => bytes, - Err(e) if m.storage == 0 => { + Ok((bytes, _)) => bytes, + Err(e) if on_chain => { return Err(anyhow::Error::from(e).context(format!("downloading pack {hash}"))); } // An external-only pack whose copies are down, rate-limited or absent is @@ -278,7 +288,7 @@ impl Helper { // candidate is bounded (size-scaled deadline + idle timeout), so this // cannot hang. Err(e) => { - tracing::warn!(pack = %hash, mirrors = ?m.uris, error = %e, "external pack unobtainable; skipping it"); + tracing::warn!(pack = %hash, copies = copies.len(), error = %e, "external pack unobtainable; skipping it"); return Ok(None); } }; @@ -341,26 +351,22 @@ impl Helper { } else { None }; - let contract_url = match &self.url { - DashUrl::Contract { contract_id } => Some(contract_id.clone()), - DashUrl::Named { .. } => None, - }; let conn = self.ensure_conn().await?; - // How the repo is named in fixes the user may paste into `dg`: `owner/name` when - // the registry knows it, else the contract id (which `dg` also accepts). - let repo_label = contract_url - .unwrap_or_else(|| format!("{}/{}", conn.repo.owner_id, conn.repo.normalized_name)); + // v1 repositories are read only: refuse before building or paying for anything. + conn.repo.require_v2()?; + // How the repo is named in fixes the user may paste into `dg`: `owner/name`. + let repo_label = conn.repo.display(); let svc = RepoService::new(&conn.client, &conn.identity, &conn.bridge); let remote_refs = svc.read_refs(&conn.repo).await?; - // Fail fast when this identity holds no spendable token on the repo. The ACL itself - // is enforced at consensus — `refUpdate`, `chunk` and `packManifest` all carry a - // WRITE token cost, so an unauthorized push cannot land regardless. But without this - // check the helper builds a pack and broadcasts chunk state transitions that - // consensus rejects one at a time, burning processing fees and surfacing a raw - // platform error. That error is the first thing a would-be contributor sees, and it - // teaches them nothing; this is the moment to point them at the PR flow instead. + // Fail fast when this identity is not a member. Consensus is the authority — + // `refUpdate`, `chunk` and `packManifest` are all `ownerRefersTo`-gated on a + // `maintainer`/`writer` document, so a non-member's push cannot land regardless + // (40120). But without this check the helper builds a pack and broadcasts chunk + // transitions that consensus rejects, surfacing a raw platform error. That error is + // the first thing a would-be contributor sees, and it teaches them nothing; this is + // the moment to point them at `dg collab` or a fork instead. // // `DASH_FORGE_SKIP_WRITE_PRECHECK=1` skips the check, so a caller that needs to see // what consensus itself does with an unauthorized push (the e2e ACL scenarios) can. @@ -402,6 +408,7 @@ impl Helper { policy: push_policy, progress, dry_run, + identity: conn.identity.id(), }; // Storage first. Any error here — the policy's N not met, the cost guard // refusing, the manifest write failing — returns before a single ref update @@ -474,12 +481,8 @@ impl Helper { Some(c) if c > 0 => Charge::Measured(c), _ => Charge::Estimated(est_credits), }; - let (text, event) = progress::done_line( - charge, - after, - &conn.repo.owner_id, - &conn.repo.normalized_name, - ); + let (text, event) = + progress::done_line(charge, after, conn.repo.owner_id(), conn.repo.name()); progress.emit(&text, &event); } @@ -603,7 +606,7 @@ fn plan_pushes(specs: &[PushSpec], remote_refs: &[(String, RefState)]) -> Vec { svc: &'a RepoService<'a>, - repo: &'a RepoHandle, + repo: &'a RepoRef, /// The repo as the user addressed it (`owner/name`), for the plan line. repo_label: String, /// Short names of the refs this push updates. @@ -613,6 +616,8 @@ struct PushContext<'a> { progress: Progress, /// `--dry-run`: build the pack and print the plan, store nothing. dry_run: bool, + /// The pushing identity (base58). + identity: String, } impl PushContext<'_> { @@ -713,15 +718,15 @@ async fn upload_push_pack( // An earlier push may already have recorded this exact pack (unique packHash; a // duplicate manifest create is treated as "already stored"). Decide BEFORE paying: // still readable → nothing to store; unreadable → refuse now, not after new copies. - if let Some(existing) = ctx + let copies = ctx .svc - .read_pack_manifest(ctx.repo, job.pack_hash) + .read_pack_copies(ctx.repo, job.pack_hash) .await - .context("checking for an existing manifest of this pack")? - { + .context("checking for an existing manifest of this pack")?; + if !copies.is_empty() { // The browse index is left alone: the earlier push published (or tried to) the // fragment for this pack, and a missing one is rebuilt by the next repack. - confirm_existing_manifest(ctx, &job, &existing).await?; + confirm_existing_manifest(ctx, &job, &copies).await?; return Ok(Some(policy::estimate_ref_updates(ctx.refs.len()))); } @@ -755,9 +760,8 @@ async fn upload_push_pack( } else if jpath.exists() { ctx.say(&format!( "note: an earlier interrupted push left Platform chunks for this pack that \ - this push did not use (journal kept at {}); they hold a refundable deposit until \ - deleted (dg repo delete / admin teardown), or re-push with dash.storage including \ - platform to put them to use", + this push did not use (journal kept at {}); Platform chunks are permanent, so \ + re-push with dash.storage including platform to put them to use", jpath.display() )); } @@ -977,31 +981,43 @@ async fn record_pack( } /// This pack already has a manifest (an earlier push recorded it). Accept it only if at -/// least one copy it records is readable and hash-matches; otherwise refuse — before this +/// least one recorded copy is readable and hash-matches; otherwise refuse — before this /// push pays for anything — naming the dead copies and the way back. /// -/// A Platform-tier (`storage = 0`) manifest written by this identity for a pack of the +/// A Platform-tier (`storage = 0`) manifest written by THIS identity for a pack of the /// same size is accepted without a download: its chunks were confirmed at consensus when /// it was written, and a plain Platform re-push (the common "interrupted after the -/// manifest" resume) must not re-download the whole pack to find that out. +/// manifest" resume) must not re-download the whole pack to find that out. On forge-v2 a +/// manifest from someone else is never taken on trust: it is read and verified, since a +/// hostile member could post a manifest with the right hash and no bytes behind it. async fn confirm_existing_manifest( ctx: &PushContext<'_>, job: &PackJob<'_>, - existing: &forge_core::repo::PackManifestInfo, + copies: &[forge_core::repo::PackManifestInfo], ) -> Result<()> { let short = &job.meta.pack_hash[..12]; - let on_chain = existing.storage == 0 - && existing.chunk_count == u64::from(job.chunk_count) - && existing.size_bytes == job.bytes.len() as u64; - if on_chain { + let mine_on_chain = copies.iter().any(|m| { + m.storage == 0 + && m.owner_id == ctx.identity + && m.chunk_count == u64::from(job.chunk_count) + && m.size_bytes == job.bytes.len() as u64 + }); + if mine_on_chain { ctx.say(&format!( "pack {short} is already stored on Platform by an earlier push; not storing it again" )); return Ok(()); } let reader = PackReader::from_user_config(); - match ctx.svc.fetch_artifact(ctx.repo, existing, &reader).await { - Ok(bytes) if PackMeta::for_bytes(&bytes).pack_hash == job.meta.pack_hash => { + let contract = ctx.svc.repo_contract(ctx.repo).await?; + let roles = ctx.svc.copy_roles(ctx.repo).await?; + let refs: Vec<&forge_core::repo::PackManifestInfo> = copies.iter().collect(); + match ctx + .svc + .fetch_best_copy(ctx.repo, &contract, &refs, &roles, &reader) + .await + { + Ok((bytes, _)) if PackMeta::for_bytes(&bytes).pack_hash == job.meta.pack_hash => { ctx.say(&format!( "pack {short} was already recorded by an earlier push and is still \ readable; not storing it again" @@ -1013,11 +1029,17 @@ async fn confirm_existing_manifest( Ok(_) => "bytes did not match".to_string(), Err(e) => e.to_string(), }; - let recorded = if existing.uris.is_empty() { - "Platform chunks".to_string() - } else { - existing.uris.join(", ") - }; + let recorded = copies + .iter() + .map(|m| { + if m.uris.is_empty() { + format!("Platform chunks of {}", m.owner_id) + } else { + m.uris.join(", ") + } + }) + .collect::>() + .join("; "); Err(UserError::new( codes::RECORDED_COPY_LOST, format!("push refused: pack {short} is already recorded, and no recorded copy is readable"), @@ -1199,95 +1221,41 @@ struct Denied { wire: &'static str, } -/// `Some(refusal)` when the connected identity provably holds no spendable WRITE or MAINTAIN -/// token on the repo, and so cannot land any push. +/// `Some(refusal)` when the connected identity provably is not a member (no `writer` or +/// `maintainer` document) of the repo, and so cannot land any push. /// -/// Returns `None` — i.e. proceed — when the holding cannot be determined. The token history -/// read is advisory: consensus is the authority, and a transient read failure must not block -/// a push a holder is entitled to make. +/// Returns `None` — i.e. proceed — when membership cannot be determined. The read is +/// advisory: consensus (`ownerRefersTo`, 40120) is the authority, and a transient read +/// failure must not block a push a member is entitled to make. async fn write_access_denied(conn: &Conn) -> Option { - let tokens = TokenService::new(&conn.client, &conn.identity, &conn.bridge); - let records = tokens - .token_history(&conn.repo.repo_contract_id) + let me = conn.identity.id(); + let members = MemberReader::new(&conn.client) + .roles_of(&conn.repo, &me) .await .ok()?; - let now = u64::try_from( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok()? - .as_millis(), - ) - .ok()?; - let me = conn.identity.id(); - let holdings = forge_core::rules::holdings_as_of(&records, &me, now); - if holdings.any() { + if !members.is_empty() { return None; } - Some(write_denied( - frozen_grants(&records, &me, now), - &format!("{}/{}", conn.repo.owner_id, conn.repo.name), - &me, - )) -} - -/// Which of `identity`'s WRITE / MAINTAIN grants are still held but frozen at `now` (a -/// suspended collaborator), as opposed to never held. Frozen tokens are not spendable, so -/// [`forge_core::rules::holdings_as_of`] reports both cases the same way; replaying the -/// history with the freezes left out tells them apart without a second copy of the token -/// state machine. Only meaningful when the real holdings are empty. -fn frozen_grants(records: &[TokenRecord], identity: &str, now: u64) -> Holdings { - use forge_core::rules::TokenOp; - let unfrozen: Vec = records - .iter() - .filter(|r| !matches!(r.op, TokenOp::Freeze | TokenOp::Unfreeze)) - .cloned() - .collect(); - forge_core::rules::holdings_as_of(&unfrozen, identity, now) + Some(write_denied(&conn.repo.display(), &me)) } -/// The push refusal for an identity with no spendable token. A suspended collaborator is -/// told which token is frozen: "no WRITE token" is false for them, and forking is not the -/// fix. +/// The push refusal for an identity that is not a member. /// -/// Worded unlike the consensus errors ("token frozen: …", "unauthorized: …"), so a caller -/// (the e2e suite) can tell this local refusal from a network verdict. -fn write_denied(frozen: Holdings, repo: &str, me: &str) -> Denied { - let which = match (frozen.write, frozen.maintain) { - (true, true) => Some("WRITE and MAINTAIN tokens on this repo are"), - (true, false) => Some("WRITE token on this repo is"), - (false, true) => Some("MAINTAIN token on this repo is"), - (false, false) => None, - }; - if let Some(which) = which { - return Denied { - error: UserError::new( - codes::SUSPENDED, - format!("push rejected: your {which} frozen"), - ) - .cause("a maintainer suspended your push access, so consensus would reject this push") - .fix(format!( - "ask a maintainer to run `dg collab unsuspend {repo} {me}`" - )) - .note(NOTE_PRECHECK), - wire: "your token on this repo is frozen", - }; - } - // `dg pr create` takes the target repo POSITIONALLY as `owner/name`, which is what the - // pusher typed into their remote URL — not the contract id. +/// Worded unlike the consensus error (`40120`, "consensus refused"), so a caller (the e2e +/// suite) can tell this local refusal from a network verdict. +fn write_denied(repo: &str, me: &str) -> Denied { Denied { error: UserError::new( codes::NOT_A_WRITER, format!("push rejected: you are not a writer of {repo}"), ) - .cause("no WRITE token on this repo for your identity") + .cause("your identity has no writer or maintainer document for this repo") .fix(format!( - "ask the owner to run `dg collab add {repo} {me} --role write`" - )) - .fix(format!( - "or fork it and open a pull request: `dg repo create `, push your branch there, then `dg pr create {repo} --title --source-contract --head-oid `" + "ask the owner to run `dg collab add {repo} {me} --role writer`" )) + .fix("or push to a repo of your own: `dg repo create `, then `git push dash:/// `") .note(NOTE_PRECHECK), - wire: "no WRITE token on this repo", + wire: "not a writer of this repo", } } @@ -1305,9 +1273,9 @@ fn resolve_key_path(url: &DashUrl) -> Result { // which is fine, because it is reached from `dg`, not typed by hand. let owner = match url { DashUrl::Named { owner, .. } => owner.clone(), - DashUrl::Contract { .. } => { + DashUrl::Id { .. } => { return Err(no_identity( - "a contract-addressed dash:// URL has no owner to pick a default key for", + "an id-addressed dash:// URL has no owner to pick a default key for", )) } }; @@ -1318,103 +1286,28 @@ fn resolve_key_path(url: &DashUrl) -> Result { #[cfg(test)] mod tests { - use super::{frozen_grants, oid_to_bytes, resolve_network, write_denied, PushOutcome}; + use super::{oid_to_bytes, resolve_network, write_denied, PushOutcome}; use forge_core::network::NetworkSettings; - use forge_core::rules::{Holdings, TokenKind, TokenOp, TokenRecord}; - - fn rec(identity: &str, token: TokenKind, op: TokenOp, created_at: u64) -> TokenRecord { - TokenRecord { - id: format!("r{created_at}"), - identity: identity.to_string(), - token, - op, - created_at, - } - } - - #[test] - fn frozen_grants_are_told_apart_from_no_grant() { - use TokenKind::{Maintain, Write}; - let frozen = [ - rec("me", Write, TokenOp::Mint, 1), - rec("me", Write, TokenOp::Freeze, 2), - ]; - assert_eq!( - frozen_grants(&frozen, "me", 10), - Holdings { - write: true, - maintain: false - } - ); - // Never granted, or granted to someone else: nothing frozen. - assert!(!frozen_grants(&[], "me", 10).any()); - assert!(!frozen_grants(&frozen, "someone-else", 10).any()); - // Revoked (destroyed) after the freeze: nothing is held any more. - let revoked = [ - rec("me", Write, TokenOp::Mint, 1), - rec("me", Write, TokenOp::Freeze, 2), - rec("me", Write, TokenOp::Destroy, 3), - ]; - assert!(!frozen_grants(&revoked, "me", 10).any()); - // A suspended maintainer who never held WRITE. - let maint = [ - rec("me", Maintain, TokenOp::Mint, 1), - rec("me", Maintain, TokenOp::Freeze, 2), - ]; - assert_eq!( - frozen_grants(&maint, "me", 10), - Holdings { - write: false, - maintain: true - } - ); - } #[test] - fn a_frozen_pusher_is_not_told_to_fork() { - let write = Holdings { - write: true, - maintain: false, - }; - let d = write_denied(write, "owner/repo", "me"); + fn a_non_member_is_pointed_at_collab_add() { + let d = write_denied("owner/repo", "me"); + assert_eq!((d.error.code, d.error.exit_code()), ("E601", 6)); let text = d.error.render("dash: ", false); - assert_eq!(d.error.code, "E602"); assert!( - text.contains("WRITE token on this repo is frozen"), - "{text}" - ); - assert!(text.contains("dg collab unsuspend owner/repo me"), "{text}"); - assert!(!text.contains("dg pr create"), "{text}"); - // Must not read as the consensus error, or e2e scenario 04 could not tell a local - // refusal from a network verdict. - assert!(!text.contains("token frozen"), "{text}"); - assert!(!d.wire.contains("token frozen")); - - let maintain = Holdings { - write: false, - maintain: true, - }; - let text = write_denied(maintain, "owner/repo", "me") - .error - .render("", false); - assert!( - text.contains("MAINTAIN token on this repo is frozen"), + text.starts_with("dash: error: push rejected: you are not a writer of owner/repo"), "{text}" ); - - let none = write_denied(Holdings::default(), "owner/repo", "me"); - assert_eq!((none.error.code, none.error.exit_code()), ("E601", 6)); - assert_eq!(none.wire, "no WRITE token on this repo"); - let text = none.error.render("dash: ", false); assert!( - text.starts_with("dash: error: push rejected: you are not a writer of owner/repo"), + text.contains("dg collab add owner/repo me --role writer"), "{text}" ); + // Must not read as the consensus error, or e2e scenario 04 could not tell a local + // refusal from a network verdict. assert!( - text.contains("dg collab add owner/repo me --role write"), + !text.contains("40120") && !text.contains("consensus refused"), "{text}" ); - assert!(text.contains("dg pr create owner/repo"), "{text}"); assert!(text.lines().all(|l| l.starts_with("dash: ")), "{text}"); } diff --git a/crates/git-remote-dash/src/main.rs b/crates/git-remote-dash/src/main.rs index f1ae7ebcc..2fb908e8a 100644 --- a/crates/git-remote-dash/src/main.rs +++ b/crates/git-remote-dash/src/main.rs @@ -14,7 +14,7 @@ //! - `push` → build a self-contained pack, store it per the repo's storage policy //! (`dash.storage` / `dash.replicas`), write the manifest, then the ref updates. //! -//! A `--`-prefixed first argument switches to admin mode (`--create-repo`, `--teardown`, +//! A `--`-prefixed first argument switches to admin mode (`--create-repo`, `--dump-refs`, //! `--balance`, `--version`) used to provision/inspect repos outside the git protocol. //! //! **Errors.** Any failure is rendered once, as the `dash: error: … [Ennn]` block of diff --git a/crates/git-remote-dash/src/url.rs b/crates/git-remote-dash/src/url.rs index f0fd30260..881495d55 100644 --- a/crates/git-remote-dash/src/url.rs +++ b/crates/git-remote-dash/src/url.rs @@ -2,14 +2,11 @@ //! //! * `dash:///` — the human form. `owner` is a base58 Dash identity id (a DPNS //! label is a later addition, resolved upstream of this parser); a trailing `.git` on the -//! repo name is stripped. Resolved through the registry's `(ownerId, normalizedName)` -//! index. -//! * `dash://` — contract-addressed, a single segment. The registry indexes -//! name-to-contract but nothing indexes contract-to-name, so a repo referenced only by -//! contract id — which is how a pull request points at the repo holding its head commit -//! (`patch.sourceContractId`) — would otherwise be unaddressable. The contract carries -//! its own owner, so this form needs no registry lookup at all, and it also works for a -//! repo whose listing is missing. +//! repo name is stripped. Resolved as a forge-v2 `repo` by `(owner, name)`, falling back to +//! the v1 registry listing (read-only repositories). +//! * `dash://` — a single segment: a forge-v2 `repo` document id, or a v1 repo contract +//! id (both are tried, v2 first; see `forge_core::resolve::resolve_id`). This is how a +//! pull request points at the repo holding its head commit. //! //! The two are unambiguous: the human form always has a `/`. @@ -21,26 +18,26 @@ pub const SCHEME: &str = "dash"; /// A parsed `dash://` remote URL. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DashUrl { - /// `dash:///` — resolved through the registry. + /// `dash:///`. Named { - /// The repository owner (base58 identity id for M1). + /// The repository owner (base58 identity id). owner: String, /// The repository name (any trailing `.git` removed). repo: String, }, - /// `dash://` — resolved directly from the contract. - Contract { - /// Base58 repo contract id. - contract_id: String, + /// `dash://` — a forge-v2 repo id or a v1 repo contract id. + Id { + /// The base58 id. + id: String, }, } impl std::fmt::Display for DashUrl { - /// `owner/repo`, or the contract id — how the push plan line names the repo. + /// `owner/repo`, or the id — how the push plan line names the repo. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DashUrl::Named { owner, repo } => write!(f, "{owner}/{repo}"), - DashUrl::Contract { contract_id } => f.write_str(contract_id), + DashUrl::Id { id } => f.write_str(id), } } } @@ -53,20 +50,17 @@ impl DashUrl { .ok_or_else(|| anyhow::anyhow!("not a {SCHEME}:// URL: {url:?}"))?; let Some((owner, repo_seg)) = rest.split_once('/') else { - // Single segment: contract-addressed. Require it to LOOK like a contract id - // rather than accepting any single word — otherwise `dash://alice` (a - // forgotten repo name, previously a clear error) would parse here and fail - // much later with a confusing "contract not found". - let contract_id = rest.strip_suffix(".git").unwrap_or(rest); - if !looks_like_contract_id(contract_id) { + // Single segment: id-addressed. Require it to LOOK like an id rather than + // accepting any single word — otherwise `dash://alice` (a forgotten repo name) + // would parse here and fail much later with a confusing "not found". + let id = rest.strip_suffix(".git").unwrap_or(rest); + if !looks_like_id(id) { bail!( "{SCHEME}:// URL is missing a repo name: {url:?} \ - (expected dash:///, or dash:// with a base58 contract id)" + (expected dash:///, or dash:// with a base58 repo id)" ); } - return Ok(Self::Contract { - contract_id: contract_id.to_string(), - }); + return Ok(Self::Id { id: id.to_string() }); }; if owner.is_empty() { @@ -91,10 +85,10 @@ impl DashUrl { } } -/// Whether `s` is plausibly a base58 32-byte id (a contract id or an identity id): 40-44 -/// characters from the base58 alphabet, which excludes `0`, `O`, `I` and `l`. Mirrors the -/// same heuristic `dg`'s `RepoRef` uses to tell an identity id from a DPNS label. -fn looks_like_contract_id(s: &str) -> bool { +/// Whether `s` is plausibly a base58 32-byte id: 40-44 characters from the base58 +/// alphabet, which excludes `0`, `O`, `I` and `l`. Mirrors the heuristic `dg`'s repo +/// references use to tell an identity id from a DPNS label. +fn looks_like_id(s: &str) -> bool { (40..=44).contains(&s.len()) && s.chars() .all(|c| c.is_ascii_alphanumeric() && !matches!(c, '0' | 'O' | 'I' | 'l')) @@ -102,15 +96,15 @@ fn looks_like_contract_id(s: &str) -> bool { #[cfg(test)] mod tests { - use super::{looks_like_contract_id, DashUrl}; + use super::{looks_like_id, DashUrl}; const ID: &str = "8hJmcHWTsdvkHyCrk4UgjbyugDAmE7QfuCTQXpXAc7nB"; - const CONTRACT: &str = "5rrwgjjVUqMghnessfiXPXubpiM2QLNNXH142Hv4PDyX"; + const REPO_ID: &str = "5rrwgjjVUqMghnessfiXPXubpiM2QLNNXH142Hv4PDyX"; fn named(u: &DashUrl) -> (&str, &str) { match u { DashUrl::Named { owner, repo } => (owner, repo), - DashUrl::Contract { .. } => panic!("expected a named URL"), + DashUrl::Id { .. } => panic!("expected a named URL"), } } @@ -127,20 +121,28 @@ mod tests { } #[test] - fn parses_contract_addressed() { - let u = DashUrl::parse(&format!("dash://{CONTRACT}")).unwrap(); + fn keeps_the_name_as_typed_for_resolution_to_normalize() { + // Case folding is the resolver's job (rules::v2::normalize_repo_name), so a remote + // added as `Dash-Forge` resolves to `dash-forge` there, not here. + let u = DashUrl::parse(&format!("dash://{ID}/Dash-Forge")).unwrap(); + assert_eq!(named(&u), (ID, "Dash-Forge")); + } + + #[test] + fn parses_id_addressed() { + let u = DashUrl::parse(&format!("dash://{REPO_ID}")).unwrap(); assert_eq!( u, - DashUrl::Contract { - contract_id: CONTRACT.to_string() + DashUrl::Id { + id: REPO_ID.to_string() } ); // `.git` is stripped here too, so a remote added with either spelling resolves. - let u = DashUrl::parse(&format!("dash://{CONTRACT}.git")).unwrap(); + let u = DashUrl::parse(&format!("dash://{REPO_ID}.git")).unwrap(); assert_eq!( u, - DashUrl::Contract { - contract_id: CONTRACT.to_string() + DashUrl::Id { + id: REPO_ID.to_string() } ); } @@ -154,8 +156,8 @@ mod tests { #[test] fn rejects_missing_components() { assert!(DashUrl::parse("dash://").is_err()); - // A single segment that is not a plausible contract id is still a missing repo - // name, not a contract-addressed URL — the contract form must not swallow typos. + // A single segment that is not a plausible id is still a missing repo name, not an + // id-addressed URL — the id form must not swallow typos. assert!(DashUrl::parse("dash://alice").is_err()); assert!(DashUrl::parse("dash://alice/").is_err()); assert!(DashUrl::parse("dash:///project").is_err()); @@ -167,12 +169,12 @@ mod tests { } #[test] - fn contract_id_shape() { - assert!(looks_like_contract_id(CONTRACT)); - assert!(looks_like_contract_id(ID)); - assert!(!looks_like_contract_id("alice")); + fn id_shape() { + assert!(looks_like_id(REPO_ID)); + assert!(looks_like_id(ID)); + assert!(!looks_like_id("alice")); // Base58 excludes these four characters, so a string containing one is not an id. - assert!(!looks_like_contract_id(&"0".repeat(44))); - assert!(!looks_like_contract_id(&"a".repeat(45))); + assert!(!looks_like_id(&"0".repeat(44))); + assert!(!looks_like_id(&"a".repeat(45))); } } diff --git a/docs/errors.md b/docs/errors.md index 4696f1038..6a196421d 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -170,13 +170,13 @@ Outside a push (collaborator admin, releases, repo config) the headline says you The helper checks this before building or paying for anything and refuses early with the same advice. -Fix: ask the owner to add you (`dg collab add / --role write`), or push to a repository of your own and open a pull request (`dg pr create`). +Fix: ask the owner to add you (`dg collab add / --role writer`), or push to a repository of your own. ## E602 -**Write access suspended.** Your WRITE or MAINTAIN token on this repository is frozen (consensus 40702): a maintainer suspended your access. +**Write access suspended.** Your WRITE or MAINTAIN token on a v1 repository is frozen (consensus 40702). v1 repositories are read only now, so this only appears for a write attempted against one. forge-v2 has no suspend: removing a member revokes access at once (E601). -Fix: ask a maintainer to run `dg collab unsuspend / `. +Fix: push to a forge-v2 repository instead. ## E603 @@ -190,6 +190,12 @@ Fix: pick another name. Issue and PR numbers are retried automatically, so this Fix: if the message does not explain it, [open an issue](https://github.com/PastaPastaPasta/dash-forge/issues) with it. +## E605 + +**v1 repository is read only.** The repository is a forge-v1 repository (one data contract per repo). v1 repositories can still be cloned, fetched and viewed, but nothing writes to them any more. The refusal happens before anything is signed. + +Fix: create a forge-v2 repository (`dg repo create `, about 0.001 DASH) and push there. `dg migrate`, which moves a v1 repository to forge-v2, is coming soon. + ## E701 **Dash Platform unreachable.** No DAPI node answered, or the quorum service could not be reached. Nodes that fail are skipped for about a minute, so an immediate retry often reaches the same dead nodes. diff --git a/e2e/README.md b/e2e/README.md index ab16b7fe4..026c5f2dc 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,26 +1,38 @@ # End-to-end suites -- `cli/run.sh`: the CLI suite, run against **live testnet** (`make e2e`). Its configuration is in `cli/config.sh`. -- `cli/seed-read-fixture.sh`: seeds the read fixture that the browser specs read (`make e2e-fixture`). The nightly runs it before Playwright. +- `cli/run.sh`: the CLI suite, run against **live devnet moutai** (forge-v2, protocol 14) with `make e2e`. Configuration is in `cli/config.sh`; fixture identities come from `~/.config/dash-forge/test-identities/devnet-moutai/` (OWNER, COLLAB, CONTRIB). Scenario 08 reads a forge-v1 repo on **testnet** to prove v1 read-compatibility (needs a testnet identity, default `test-identities/CONTRIB.identity.json`). +- `cli/seed-read-fixture.sh`: checks the testnet read fixture that the browser specs read (`make e2e-fixture`). The nightly runs it before Playwright. The fixture is a forge-v1 repo, which is read only now, so the script verifies it and can no longer reseed it; the fixture moves to forge-v2 together with the web app. - `../forge-web/e2e/`: the Playwright specs. The read specs only read the read fixture. - `cli/storage-byo.sh`: bring-your-own storage (`make storage-e2e`). A real `git push` / `git clone` whose packs go to the local MinIO + kubo from `infra/docker-compose.yml`. -## Reserved fixture repos (testnet) +| Scenario | Proves | +|---|---| +| 01 round-trip | push a branch + tag, clone it back byte-identical | +| 02 non-ff | non-fast-forward refused without `+`, accepted with it | +| 03 ref delete | `:branch` removes a ref from `ls-remote` and a fresh clone | +| 04 revoked-writer push | `dg collab add` → writer pushes → `dg collab remove` → the next push is refused **at consensus** (40120) | +| 05 non-member push | a never-member's push is refused at consensus (40120) | +| 06 third-party verify | refs and manifests read raw from Platform bind to a locally hash-verified clone | +| 07 depth / filter | `--depth` fails loudly, `--filter=blob:none` works | +| 08 v1 read-compat | a testnet v1 repo clones by name and by contract id; a push to it is refused as read only | + +## Reserved fixture repos Each fixture repo belongs to one suite. Don't push to a repo that another suite owns, and don't run ad-hoc experiments on any of them. Why this matters: a pack is only as readable as the storage its manifest names. Packs stored on local-only storage (MinIO/kubo on `127.0.0.1`) can't be read by anyone else. On 2026-09-25 a BYO-storage run pushed such packs into the CLI suite's repo, and every browser spec that browsed that repo failed with "no external URI served the range". A repo shared between suites turns one suite's storage choices into another suite's failures. -All of these repos are owned by DEPLOYER (`8hJmcHWTsdvkHyCrk4UgjbyugDAmE7QfuCTQXpXAc7nB`). +The CLI suite's repos are forge-v2 repos owned by the moutai OWNER fixture, created on first use (~0.001 DASH each; the create is resumable and never pays twice). | Repo | Written by | Read by | Notes | |---|---|---|---| -| `m1-5124` | `cli/seed-read-fixture.sh` only (never the write spec) | `forge-web/e2e/*` (read-paths, fallback-browse, zero-backend, a11y) | The read fixture. `main` holds one deterministic commit (`README.md`, `src/`, `lib/`) with its pack stored on Platform and no browse index published, so the browser takes the in-browser fallback clone. The seeder is idempotent: it force-pushes `main` only when `main` is not at that commit. Override the name with `NIGHTLY_FIXTURE_REPO` (seeder) and `E2E_FIXTURE_NAME` (Playwright). | -| `m1-75299` | `cli/scenarios/*` (`make e2e`); `forge-web/e2e/auth-write.spec.ts` with `E2E_WRITE=1` (issues only) | the same | The CLI suite's repo, and the opt-in web write spec's fixture (`WRITE_FIXTURE` in `forge-web/e2e/helpers.ts`, override `E2E_WRITE_FIXTURE_NAME`). It holds Platform-stored packs only. Each run pushes fresh `e2e//…` refs and deletes them afterwards. Override with `E2E_REPO_NAME`. It still holds seven stale external packs (MinIO/IPFS on `127.0.0.1`) from the 2026-09-25 incident; clones skip them with a warning. | -| `storage-e2e-a` | `cli/storage-byo.sh` steps 1–4 | the same script | Created by the script on its first run (repo-v1, about 1.18 tDASH once). Each run uses a fresh `e2e//byo` branch and deletes it afterwards. | -| `storage-e2e-b` | `cli/storage-byo.sh` steps 5–6 | the same script | Same, plus the step-5 "copy deleted" scenario. Step 6 restores that copy, so the repo stays clonable. | +| `e2e-cli` (moutai) | `cli/scenarios/*` (`make e2e`) | the same | The CLI suite's repo. Platform-stored packs only. Each run pushes fresh `e2e//…` refs and deletes them afterwards. Override with `E2E_REPO_NAME`. | +| `storage-e2e-a` (moutai) | `cli/storage-byo.sh` steps 1–4 | the same script | Each run uses a fresh `e2e//byo` branch and deletes it afterwards. | +| `storage-e2e-b` (moutai) | `cli/storage-byo.sh` steps 5–6 | the same script | Same, plus the step-5 "copy deleted" scenario. Step 6 restores that copy, so the repo stays clonable. | +| `m1-5124` (testnet, v1) | nothing now (v1 is read only); `cli/seed-read-fixture.sh` verifies it | `forge-web/e2e/*` (read-paths, fallback-browse, zero-backend, a11y) | The read fixture: `main` holds one deterministic commit (`README.md`, `src/`, `lib/`) with its pack stored on Platform and no browse index. DEPLOYER-owned (`8hJmcHWT…`). Override with `NIGHTLY_FIXTURE_REPO` (seeder) and `E2E_FIXTURE_NAME` (Playwright). | +| `m1-75299` (testnet, v1) | nothing now | scenario 08; `forge-web/e2e/auth-write.spec.ts` with `E2E_WRITE=1` (issues only) | The former CLI suite repo, read only now. It still holds seven stale external packs (MinIO/IPFS on `127.0.0.1`) from the 2026-09-25 incident; clones skip them with a warning. | -Override the storage repo names with `STORAGE_E2E_REPO` / `STORAGE_E2E_REPO_B`. When you add a suite that writes, give it its own repo and add a row here. `dg repo create ` costs about 1.18 tDASH once. +Override the storage repo names with `STORAGE_E2E_REPO` / `STORAGE_E2E_REPO_B`. When you add a suite that writes, give it its own repo and add a row here. ## The partial-clone rule both clients follow diff --git a/e2e/cli/config.sh b/e2e/cli/config.sh index 3159ac22a..1130084ee 100644 --- a/e2e/cli/config.sh +++ b/e2e/cli/config.sh @@ -1,54 +1,72 @@ # shellcheck shell=bash -# config.sh — fixture identities, the reused m1 test repo, and network selection. +# config.sh — network selection, fixture identities and the shared test repos. # # Sourced by lib.sh. Everything here is data: no side effects beyond exports. # -# We deliberately REUSE the already-deployed, DEPLOYER-owned m1 repo contract -# (5rrwgjjVUqMghnessfiXPXubpiM2QLNNXH142Hv4PDyX) rather than minting a fresh -# ~1.18-DASH repo — the fixture pool is low on testnet funds and InstantSend is -# flaky. Every push the harness makes is a few KB. +# The CLI suite runs against devnet MOUTAI, where forge-v2 (protocol 14) is deployed. A v2 +# repo costs ~0.001 DASH, so the suite's repo is created on the first run (by +# `harness_ensure_repo`, resumably) rather than hard-coded. Two things stay on TESTNET, +# where every repo is forge-v1 (read only now): scenario 08 (v1 read-compat) and the +# browser specs' read fixture (seed-read-fixture.sh), which move to forge-v2 with the web. # --- network ----------------------------------------------------------------- -: "${DASH_FORGE_NETWORK:=testnet}" -export DASH_FORGE_NETWORK +: "${DASH_FORGE_NETWORK:=devnet}" +: "${DASH_FORGE_DEVNET_NAME:=moutai}" +export DASH_FORGE_NETWORK DASH_FORGE_DEVNET_NAME + +# --- fixture identity files -------------------------------------------------- +# Per-network directory (tools/devnet-identities provisions devnet-moutai). The testnet +# pool lives directly under test-identities/. +if [[ "$DASH_FORGE_NETWORK" == devnet ]]; then + : "${E2E_IDENTITY_DIR:=${HOME}/.config/dash-forge/test-identities/devnet-${DASH_FORGE_DEVNET_NAME}}" +else + : "${E2E_IDENTITY_DIR:=${HOME}/.config/dash-forge/test-identities}" +fi +export E2E_IDENTITY_DIR +# The owner role: OWNER in the moutai pool, DEPLOYER in the testnet pool. +if [[ "$DASH_FORGE_NETWORK" == devnet ]]; then : "${E2E_OWNER_ROLE:=OWNER}"; else : "${E2E_OWNER_ROLE:=DEPLOYER}"; fi +export E2E_OWNER_ROLE +# OWNER owns the test repo; COLLAB is granted and revoked writer; CONTRIB is never a member. +export ID_OWNER="${E2E_IDENTITY_DIR}/${E2E_OWNER_ROLE}.identity.json" +export ID_COLLAB="${E2E_IDENTITY_DIR}/COLLAB.identity.json" +export ID_CONTRIB="${E2E_IDENTITY_DIR}/CONTRIB.identity.json" +# Scripts written before the move to devnet name the owner DEPLOYER. +export ID_DEPLOYER="$ID_OWNER" + +_idid() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["identityId"])' "$1" 2>/dev/null; } +IDID_OWNER="$(_idid "$ID_OWNER")" +IDID_COLLAB="$(_idid "$ID_COLLAB")" +IDID_CONTRIB="$(_idid "$ID_CONTRIB")" +export IDID_OWNER IDID_COLLAB IDID_CONTRIB # --- the CLI suite's repo ---------------------------------------------------- -# DEPLOYER is the owner + token granter. Reserved for `run.sh` (e2e/README.md): every -# scenario pushes Platform-stored packs to fresh `e2e//…` refs and deletes them. -# An ad-hoc run that stores packs anywhere else (local MinIO/kubo) must use its own repo — -# set E2E_REPO_NAME — so the nightly's clones never depend on someone's laptop. -export E2E_OWNER_ID="8hJmcHWTsdvkHyCrk4UgjbyugDAmE7QfuCTQXpXAc7nB" -: "${E2E_REPO_NAME:=m1-75299}" +# A forge-v2 repo owned by OWNER, reserved for `run.sh` (e2e/README.md): every scenario +# pushes Platform-stored packs to fresh `e2e//…` refs and deletes them. An ad-hoc +# run that stores packs anywhere else (local MinIO/kubo) must use its own repo — set +# E2E_REPO_NAME — so the nightly's clones never depend on someone's laptop. +: "${E2E_REPO_NAME:=e2e-cli}" export E2E_REPO_NAME +export E2E_OWNER_ID="$IDID_OWNER" export E2E_REMOTE="dash://${E2E_OWNER_ID}/${E2E_REPO_NAME}" # --- dedicated bring-your-own-storage repos (e2e/cli/storage-byo.sh) ------------ -# DEPLOYER-owned, created once by the script if absent (reserved in e2e/README.md). -# Their packs live on the LOCAL MinIO/kubo fixtures, so no other scenario may use them — -# and storage-byo.sh never touches the shared m1 repo above. +# OWNER-owned, created by the script if absent (reserved in e2e/README.md). Their packs +# live on the LOCAL MinIO/kubo fixtures, so no other scenario may use them. : "${STORAGE_E2E_REPO:=storage-e2e-a}" : "${STORAGE_E2E_REPO_B:=storage-e2e-b}" export STORAGE_E2E_REPO STORAGE_E2E_REPO_B -# --- the nightly's read fixture ---------------------------------------------- -# The repo the browser (Playwright) specs read: DEPLOYER-owned, written ONLY by -# e2e/cli/seed-read-fixture.sh, which pins `main` to one deterministic commit and publishes -# no browse index (so the in-browser fallback clone is what gets exercised). forge-web's -# e2e/helpers.ts names the same repo. +# --- the nightly's read fixture (TESTNET, forge-v1) --------------------------- +# The repo the browser (Playwright) specs read: DEPLOYER-owned on testnet, written ONLY by +# e2e/cli/seed-read-fixture.sh (which runs with DASH_FORGE_NETWORK=testnet). forge-web's +# e2e/helpers.ts names the same repo. v1 is read only now, so the seeder verifies it. : "${NIGHTLY_FIXTURE_REPO:=m1-5124}" export NIGHTLY_FIXTURE_REPO -# --- fixture identity files -------------------------------------------------- -: "${E2E_IDENTITY_DIR:=${HOME}/.config/dash-forge/test-identities}" -export E2E_IDENTITY_DIR -export ID_DEPLOYER="${E2E_IDENTITY_DIR}/DEPLOYER.identity.json" -export ID_COLLAB="${E2E_IDENTITY_DIR}/COLLAB.identity.json" -export ID_CONTRIB="${E2E_IDENTITY_DIR}/CONTRIB.identity.json" -export ID_FROZEN="${E2E_IDENTITY_DIR}/FROZEN.identity.json" - -# --- fixture identity ids (base58) ------------------------------------------- -# DEPLOYER owns; COLLAB starts with an (unfrozen) WRITE token; CONTRIB has none. -export IDID_DEPLOYER="8hJmcHWTsdvkHyCrk4UgjbyugDAmE7QfuCTQXpXAc7nB" -export IDID_COLLAB="CmGHMP2VqWZng8gtF7Nr9pPXKzmFoKpJQs4mYrCHkyVw" -export IDID_CONTRIB="6acfxhgD2c8siYVmJrHgzTE3HF5DcyU3599T4r1vPTr1" -export IDID_FROZEN="BSGeWyh3tQYT1p5Z7SaGpVhU4xsKzSEpQgAPZKTBmvv8" +# --- the testnet v1 repo for read-compat (scenario 08) ------------------------ +# The DEPLOYER-owned M1 repo contract on testnet: read-only now, cloned by 08. +export V1_TESTNET_OWNER="8hJmcHWTsdvkHyCrk4UgjbyugDAmE7QfuCTQXpXAc7nB" +export V1_TESTNET_REPO="m1-75299" +export V1_TESTNET_CONTRACT="5rrwgjjVUqMghnessfiXPXubpiM2QLNNXH142Hv4PDyX" +: "${V1_TESTNET_IDENTITY:=${HOME}/.config/dash-forge/test-identities/CONTRIB.identity.json}" +export V1_TESTNET_IDENTITY diff --git a/e2e/cli/lib.sh b/e2e/cli/lib.sh index 349ded8af..3a6bd9258 100644 --- a/e2e/cli/lib.sh +++ b/e2e/cli/lib.sh @@ -202,13 +202,14 @@ is_flake() { # is_flake is_timeout() { # is_timeout — the harness killed the command grep -q '^e2e: command timed out' "$1" } -is_consensus_frozen() { # token account frozen at consensus - grep -qiE 'token frozen|account is frozen|IdentityTokenAccountFrozen|token account is frozen|access has been suspended' "$1" +# forge-v2: a write from an identity with no writer/maintainer document is refused at +# consensus with 40120 (ReferencedEntityNotFound on the `$ownerId` path). +is_consensus_not_member() { + grep -qE '40120' "$1" && grep -qiE 'not a member|consensus refused|\$ownerId' "$1" } -is_consensus_unauthorized() { # no/insufficient token -> unauthorized at consensus - grep -qiE 'not authorized|unauthorized|Unauthorized|insufficient token|token balance|UnauthorizedTokenAction|does not have|WRITE .*token|requires a WRITE' "$1" -} -is_consensus_reject() { is_consensus_frozen "$1" || is_consensus_unauthorized "$1"; } +is_consensus_reject() { is_consensus_not_member "$1"; } +# The helper's local membership pre-check (never a consensus verdict). +is_local_precheck() { grep -qiE 'you are not a writer of .* — ask its owner' "$1"; } # --- retry --------------------------------------------------------------------- # Run (which writes its stderr to ) up to E2E_ATTEMPTS times while it @@ -310,12 +311,26 @@ harness_init() { # Preflight: fixture files present. local missing=0 f - for f in "$ID_DEPLOYER" "$ID_COLLAB" "$ID_CONTRIB"; do + for f in "$ID_OWNER" "$ID_COLLAB" "$ID_CONTRIB"; do [[ -f "$f" ]] || { log "${C_RED}missing identity:${C_RST} $f"; missing=1; } done [[ $missing -eq 0 ]] || { log "identity fixtures missing under ${E2E_IDENTITY_DIR}"; exit 1; } - info "run-id: ${RUN_ID} workroot: ${WORKROOT}" + info "run-id: ${RUN_ID} workroot: ${WORKROOT} network: ${DASH_FORGE_NETWORK}-${DASH_FORGE_DEVNET_NAME:-}" +} + +# Create the forge-v2 repo under OWNER unless it exists. Idempotent and resumable: +# `dg repo create` finishes an interrupted create without paying twice, and re-running a +# finished one writes nothing. Leaves its --json output at ${WORKROOT}/create-.json. +harness_ensure_repo() { # harness_ensure_repo + local name="$1" out="${WORKROOT}/create-$1" + if _retry "${out}.err" _dg_read "$ID_OWNER" "${out}.json" "${out}.err" --yes --json repo create "$name" \ + --description "Dash Forge CLI e2e fixture (reserved; see e2e/README.md)"; then + info "repo ${name}: $(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["status"], d["cost"]["dash"], "DASH")' "${out}.json" 2>/dev/null)" + return 0 + fi + cat "${out}.err" >&2 + return 1 } # --- scenario finish --------------------------------------------------------- diff --git a/e2e/cli/run.sh b/e2e/cli/run.sh index 5d8e97a47..f934ec263 100755 --- a/e2e/cli/run.sh +++ b/e2e/cli/run.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # run.sh — Dash Forge CLI end-to-end suite driver. # -# Runs every scenario against LIVE testnet, on the CLI suite's reserved DEPLOYER-owned repo -# (config.sh). Prints a PASS/FAIL/SKIP matrix and exits non-zero if ANY scenario +# Runs every scenario against LIVE devnet moutai (forge-v2), on the suite's reserved +# OWNER-owned repo (config.sh; created on the first run). Scenario 08 reads a v1 repo on +# testnet. Prints a PASS/FAIL/SKIP matrix and exits non-zero if ANY scenario # FAILs. A scenario SKIPs only when a check flaked on every retry; one SKIP is reported # but tolerated, more than E2E_MAX_SKIPS fails the run. # @@ -29,14 +30,18 @@ harness_init export RUN_ID WORKROOT BIN_DIR PATH DG export HARNESS_SHARED=1 +# The shared forge-v2 test repo: created (resumably, ~0.001 DASH) on the first run. +harness_ensure_repo "$E2E_REPO_NAME" || { log "${C_RED}fatal:${C_RST} could not create/resolve ${E2E_REMOTE}"; exit 1; } + SCENARIOS=( "01-round-trip" "02-non-ff" "03-ref-delete" - "04-frozen-push" - "05-no-token-push" + "04-revoked-writer-push" + "05-non-member-push" "06-third-party-verify" "07-depth-and-filter" + "08-v1-read-compat" ) # Optional subset filter (match by leading number or substring). diff --git a/e2e/cli/scenarios/01-round-trip.sh b/e2e/cli/scenarios/01-round-trip.sh index d75639e9c..480388f6b 100755 --- a/e2e/cli/scenarios/01-round-trip.sh +++ b/e2e/cli/scenarios/01-round-trip.sh @@ -6,6 +6,7 @@ SCENARIO_NAME="01 round-trip (⭐)" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" harness_init +[[ -n "${HARNESS_SHARED:-}" ]] || harness_ensure_repo "$E2E_REPO_NAME" || skip_scenario "could not create/resolve the test repo" BR="e2e/${RUN_ID}/roundtrip" TAG="e2e-${RUN_ID}-rt" diff --git a/e2e/cli/scenarios/02-non-ff.sh b/e2e/cli/scenarios/02-non-ff.sh index 4d47e1fd9..c6da75f0e 100755 --- a/e2e/cli/scenarios/02-non-ff.sh +++ b/e2e/cli/scenarios/02-non-ff.sh @@ -5,6 +5,7 @@ SCENARIO_NAME="02 non-fast-forward refused" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" harness_init +[[ -n "${HARNESS_SHARED:-}" ]] || harness_ensure_repo "$E2E_REPO_NAME" || skip_scenario "could not create/resolve the test repo" BR="e2e/${RUN_ID}/nonff" SRC="${WORKROOT}/s02-src" diff --git a/e2e/cli/scenarios/03-ref-delete.sh b/e2e/cli/scenarios/03-ref-delete.sh index 07fe73396..6381573af 100755 --- a/e2e/cli/scenarios/03-ref-delete.sh +++ b/e2e/cli/scenarios/03-ref-delete.sh @@ -5,6 +5,7 @@ SCENARIO_NAME="03 ref delete" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" harness_init +[[ -n "${HARNESS_SHARED:-}" ]] || harness_ensure_repo "$E2E_REPO_NAME" || skip_scenario "could not create/resolve the test repo" BR="e2e/${RUN_ID}/deleteme" SRC="${WORKROOT}/s03-src" diff --git a/e2e/cli/scenarios/04-frozen-push.sh b/e2e/cli/scenarios/04-frozen-push.sh deleted file mode 100755 index 71fa88254..000000000 --- a/e2e/cli/scenarios/04-frozen-push.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env bash -# Scenario 4 (⭐ headline): Frozen-push rejected AT CONSENSUS. -# -# Proves the token-ACL end-to-end through git: -# 1. COLLAB (holds an unfrozen WRITE token) pushes a tiny update -> SUCCEEDS -# 2. DEPLOYER `dg collab suspend` freezes COLLAB's WRITE token -# 3. COLLAB pushes a *fast-forward* update (client-side FF guard passes, so the -# write reaches consensus) -> REJECTED at -# consensus with a token-frozen error (NOT a client-side refusal). -# -# The CLI has no `unsuspend`, so freezing COLLAB is one-way here: on a re-run the -# harness detects COLLAB already frozen and verifies the rejection path only. -SCENARIO_NAME="04 frozen-push rejected at consensus (⭐)" -source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" -harness_init - -BR="e2e/${RUN_ID}/collab" -REPO="${E2E_OWNER_ID}/${E2E_REPO_NAME}" -SRC="${WORKROOT}/s04-src" -LOG="${WORKROOT}/s04" - -# Echo " " for COLLAB's WRITE token from a live query. -collab_write_status() { - dg_read_retry "$ID_DEPLOYER" "$LOG-list.json" "$LOG-list.err" --json collab list "$REPO" || return 1 - python3 - "$IDID_COLLAB" "$LOG-list.json" <<'PY' -import json,sys -me=sys.argv[1] -try: d=json.load(open(sys.argv[2])) -except Exception: print("no no"); sys.exit(0) -for c in d.get("collaborators",[]): - if c.get("identityId")==me: - print(("yes" if c.get("write") else "no"),("yes" if c.get("writeFrozen") else "no")); sys.exit(0) -print("no no") -PY -} - -wait_until_frozen() { - local i st - for i in $(seq 1 12); do - st="$(collab_write_status || echo '')" - [[ "$st" == "yes yes" ]] && return 0 - sleep 3 - done - return 1 -} - -step "query COLLAB's current WRITE-token status" -if ! STATUS="$(collab_write_status)"; then - cat "$LOG-list.err" >&2 || true - is_flake "$LOG-list.err" && skip_scenario "could not query collaborators: every attempt (${E2E_ATTEMPTS}) flaked" - bad "collab list failed (not a transport flake)"; finish_scenario -fi -info "COLLAB write status (present frozen): ${STATUS}" - -PRESENT="${STATUS% *}"; FROZEN="${STATUS#* }" - -if [[ "$PRESENT" == "no" ]]; then - step "COLLAB holds no WRITE token — granting one (DEPLOYER)" - if ! dg_as "$ID_DEPLOYER" -y collab add "$REPO" "$IDID_COLLAB" --role write >"$LOG-grant.out" 2>"$LOG-grant.err"; then - cat "$LOG-grant.err" >&2 || true - is_flake "$LOG-grant.err" && skip_scenario "grant failed on a transport flake" - bad "could not grant WRITE to COLLAB"; finish_scenario - fi - sleep 4 - STATUS="$(collab_write_status)"; PRESENT="${STATUS% *}"; FROZEN="${STATUS#* }" - info "COLLAB write status after grant: ${STATUS}" -fi - -seed_tiny_repo "$SRC" "$BR" >/dev/null - -if [[ "$FROZEN" == "no" ]]; then - # ---- Full narrative: push OK -> freeze -> push rejected ------------------- - step "COLLAB pushes a tiny update (expect SUCCESS)" - if ! git_dash_retry "$ID_COLLAB" "$LOG-ok" -C "$SRC" push "$E2E_REMOTE" "refs/heads/${BR}:refs/heads/${BR}"; then - cat "$LOG-ok.err" >&2 || true - is_flake "$LOG-ok.err" && skip_scenario "COLLAB's first push failed on a transport flake" - grep -qiE 'insufficient|balance' "$LOG-ok.err" && skip_scenario "COLLAB out of testnet credits" - bad "COLLAB's push with a valid WRITE token was rejected (should succeed)"; finish_scenario - fi - register_ref "refs/heads/${BR}" - ok "COLLAB push accepted with an unfrozen WRITE token" - - step "DEPLOYER freezes COLLAB's WRITE token (dg collab suspend)" - if ! dg_as "$ID_DEPLOYER" -y collab suspend "$REPO" "$IDID_COLLAB" --role write >"$LOG-susp.out" 2>"$LOG-susp.err"; then - cat "$LOG-susp.err" >&2 || true - # The freeze ST may have landed while the CLI's immediate post-broadcast verify - # read stale (read-after-write lag) → "broadcast but ... not frozen". Treat that - # as soft and let the authoritative poll below decide. A real transport flake or - # any other failure is fatal. - if is_flake "$LOG-susp.err"; then - skip_scenario "suspend failed on a transport flake" - elif grep -qiE 'broadcast but|not frozen|still frozen' "$LOG-susp.err"; then - info "suspend broadcast landed but post-verify lagged; polling for frozen status" - else - bad "suspend failed"; finish_scenario - fi - fi - step "wait for the freeze to be observable on-chain" - if wait_until_frozen; then ok "COLLAB's WRITE token now reads frozen" - else skip_scenario "freeze not observable within the poll window (eventual-consistency lag)"; fi - - # Advance the local branch so the next push is a clean fast-forward: this forces - # the rejection to happen at CONSENSUS, not at the client-side non-FF guard. - printf 'post-freeze change %s\n' "${RUN_ID}" >"$SRC/alpha.txt" - git -C "$SRC" add -A && git -C "$SRC" commit -q -m "post-freeze ff ${RUN_ID}" -else - # ---- Re-run path: COLLAB already frozen -> verify rejection only ---------- - step "COLLAB is already frozen (prior run) — verifying rejection path only" - info "no CLI unsuspend exists; the first-push/freeze steps are skipped on re-runs" - register_ref "refs/heads/${BR}" -fi - -step "COLLAB pushes again (expect CONSENSUS rejection: token frozen)" -# The helper refuses a push from a token-less or frozen identity locally, before anything -# is broadcast (a UX pre-check added 2026-09-21). This scenario exists to prove the network -# itself enforces the freeze, so it turns that pre-check off. Retried on a flake so that a -# bad DAPI node cannot turn the headline check into a SKIP; a rejection ends the retries. -if DASH_FORGE_SKIP_WRITE_PRECHECK=1 git_dash_retry "$ID_COLLAB" "$LOG-frozen" -C "$SRC" push "$E2E_REMOTE" "refs/heads/${BR}:refs/heads/${BR}"; then - bad "frozen COLLAB's push was ACCEPTED — token freeze did NOT gate at consensus" - finish_scenario -fi - -echo "----- frozen-push stderr (captured) -----" >&2 -cat "$LOG-frozen.err" >&2 -echo "-----------------------------------------" >&2 - -if is_flake "$LOG-frozen.err" && ! is_consensus_frozen "$LOG-frozen.err"; then - skip_scenario "frozen push flaked on transport on every attempt (${E2E_ATTEMPTS}) — inconclusive" -fi -if grep -qiE 'no WRITE token on this repo|tokens? on this repo (is|are) frozen' "$LOG-frozen.err"; then - bad "push was refused by the helper's local pre-check, not at consensus (DASH_FORGE_SKIP_WRITE_PRECHECK not honored?)" - finish_scenario -fi -if grep -qiE 'non-fast-forward|fetch first' "$LOG-frozen.err"; then - bad "push was refused CLIENT-SIDE (non-fast-forward), not at consensus" - finish_scenario -fi -if is_consensus_frozen "$LOG-frozen.err"; then - ok "frozen COLLAB push REJECTED AT CONSENSUS with a token-frozen error" - # Surface the exact consensus error line for the report. - FROZEN_LINE="$(grep -ioE 'account is frozen for token [A-Za-z0-9]+[^,]*|token frozen: this identity[^"]*' "$LOG-frozen.err" | head -1)" - info "consensus error: ${FROZEN_LINE}" -else - bad "push failed but without a recognizable token-frozen consensus error" -fi - -log "" -log "${C_YEL}note:${C_RST} COLLAB's WRITE token remains FROZEN after this scenario — the dg CLI" -log " exposes no 'unsuspend'. Re-runs auto-detect this and verify rejection only." - -finish_scenario diff --git a/e2e/cli/scenarios/04-revoked-writer-push.sh b/e2e/cli/scenarios/04-revoked-writer-push.sh new file mode 100755 index 000000000..8947d67e9 --- /dev/null +++ b/e2e/cli/scenarios/04-revoked-writer-push.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Scenario 4 (⭐ headline): a revoked writer's push is rejected AT CONSENSUS. +# +# Proves forge-v2 membership end-to-end through git: +# 1. OWNER `dg collab add` makes COLLAB a writer (a `writer` document) +# 2. COLLAB pushes a tiny branch -> SUCCEEDS +# 3. OWNER `dg collab remove` revokes COLLAB (the document is deleted) +# 4. COLLAB pushes a *fast-forward* (the client-side FF guard passes and the helper's +# membership pre-check is bypassed, so the write reaches consensus) +# -> REJECTED at consensus: 40120 +# (`ownerRefersTo` finds no writer/maintainer document for COLLAB) +# +# Re-runnable: step 1 is idempotent (an existing writer document is reused) and step 3 +# always leaves COLLAB revoked. +SCENARIO_NAME="04 revoked-writer push rejected at consensus (⭐)" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" +harness_init + +BR="e2e/${RUN_ID}/collab" +REPO="${E2E_OWNER_ID}/${E2E_REPO_NAME}" +SRC="${WORKROOT}/s04-src" +LOG="${WORKROOT}/s04" + +harness_ensure_repo "$E2E_REPO_NAME" || skip_scenario "could not create/resolve the test repo" + +# Echo COLLAB's role in the repo ("writer", "maintainer" or "none") from a live query. +collab_role() { + dg_read_retry "$ID_OWNER" "$LOG-list.json" "$LOG-list.err" --json collab list "$REPO" || return 1 + python3 - "$IDID_COLLAB" "$LOG-list.json" <<'PY' +import json,sys +me=sys.argv[1] +d=json.load(open(sys.argv[2])) +roles=[m["role"] for m in d.get("members",[]) if m.get("identityId")==me] +print(roles[0] if roles else "none") +PY +} + +wait_for_role() { # wait_for_role + local i + for i in $(seq 1 12); do + [[ "$(collab_role || echo '')" == "$1" ]] && return 0 + sleep 3 + done + return 1 +} + +step "OWNER adds COLLAB as a writer (dg collab add)" +if ! dg_as "$ID_OWNER" -y collab add "$REPO" "$IDID_COLLAB" --role writer >"$LOG-add.out" 2>"$LOG-add.err"; then + cat "$LOG-add.err" >&2 || true + is_flake "$LOG-add.err" && skip_scenario "grant failed on a transport flake" + bad "could not add COLLAB as a writer"; finish_scenario +fi +if wait_for_role writer; then ok "COLLAB is a writer"; else bad "COLLAB's writer document is not visible"; finish_scenario; fi + +step "COLLAB pushes a tiny branch (expect SUCCESS)" +seed_tiny_repo "$SRC" "$BR" >/dev/null +if ! git_dash_retry "$ID_COLLAB" "$LOG-ok" -C "$SRC" push "$E2E_REMOTE" "refs/heads/${BR}:refs/heads/${BR}"; then + cat "$LOG-ok.err" >&2 || true + is_flake "$LOG-ok.err" && skip_scenario "COLLAB's first push failed on a transport flake" + grep -qiE 'insufficient|balance' "$LOG-ok.err" && skip_scenario "COLLAB out of credits" + bad "a writer's push was rejected (should succeed)"; finish_scenario +fi +register_ref "refs/heads/${BR}" +ok "COLLAB push accepted as a writer" + +step "OWNER removes COLLAB (dg collab remove — deletes the writer document)" +if ! dg_as "$ID_OWNER" -y collab remove "$REPO" "$IDID_COLLAB" --role writer >"$LOG-rm.out" 2>"$LOG-rm.err"; then + cat "$LOG-rm.err" >&2 || true + is_flake "$LOG-rm.err" && skip_scenario "revoke failed on a transport flake" + bad "revoke failed"; finish_scenario +fi +if wait_for_role none; then ok "COLLAB is no longer a member"; else skip_scenario "revocation not observable within the poll window"; fi + +# Advance the local branch so the next push is a clean fast-forward: the rejection must +# come from CONSENSUS, not from the client-side non-FF guard. +printf 'post-revoke change %s\n' "${RUN_ID}" >"$SRC/alpha.txt" +git -C "$SRC" add -A && git -C "$SRC" commit -q -m "post-revoke ff ${RUN_ID}" + +step "COLLAB pushes again (expect CONSENSUS rejection: 40120)" +# The helper refuses a non-member's push locally before anything is broadcast. This scenario +# exists to prove the network itself enforces the revocation, so it turns that pre-check off. +if DASH_FORGE_SKIP_WRITE_PRECHECK=1 git_dash_retry "$ID_COLLAB" "$LOG-revoked" -C "$SRC" push "$E2E_REMOTE" "refs/heads/${BR}:refs/heads/${BR}"; then + bad "the revoked writer's push was ACCEPTED — revocation did NOT gate at consensus" + finish_scenario +fi + +echo "----- revoked-push stderr (captured) -----" >&2 +cat "$LOG-revoked.err" >&2 +echo "------------------------------------------" >&2 + +if is_flake "$LOG-revoked.err" && ! is_consensus_reject "$LOG-revoked.err"; then + skip_scenario "revoked push flaked on transport on every attempt (${E2E_ATTEMPTS}) — inconclusive" +fi +if is_local_precheck "$LOG-revoked.err"; then + bad "push was refused by the helper's local pre-check, not at consensus (DASH_FORGE_SKIP_WRITE_PRECHECK not honored?)" + finish_scenario +fi +if grep -qiE 'non-fast-forward|fetch first' "$LOG-revoked.err"; then + bad "push was refused CLIENT-SIDE (non-fast-forward), not at consensus" + finish_scenario +fi +if is_consensus_reject "$LOG-revoked.err"; then + ok "revoked writer's push REJECTED AT CONSENSUS (40120)" + info "consensus error: $(grep -oE '40120[^)]*' "$LOG-revoked.err" | head -1)" +else + bad "push failed but without a recognizable 40120 consensus error" +fi + +step "the helper's own pre-check names the fix (no pre-check bypass)" +if git_dash "$ID_COLLAB" "$LOG-pre" -C "$SRC" push "$E2E_REMOTE" "refs/heads/${BR}:refs/heads/${BR}"; then + bad "the revoked writer's push was accepted with the pre-check on" +elif is_local_precheck "$LOG-pre.err" || is_local_precheck "$LOG-pre.out"; then + ok "pre-check refuses the push and points at \`dg collab add\`" +else + cat "$LOG-pre.err" >&2 + bad "pre-check did not explain the refusal" +fi + +finish_scenario diff --git a/e2e/cli/scenarios/05-no-token-push.sh b/e2e/cli/scenarios/05-no-token-push.sh deleted file mode 100755 index 964f7436b..000000000 --- a/e2e/cli/scenarios/05-no-token-push.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# Scenario 5: No-token push rejected at consensus. CONTRIB holds no WRITE token; -# its push must be refused by the network (an unauthorized/insufficient-token -# consensus error), not silently accepted. -SCENARIO_NAME="05 no-token push rejected at consensus" -source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" -harness_init - -BR="e2e/${RUN_ID}/contrib" -REPO="${E2E_OWNER_ID}/${E2E_REPO_NAME}" -SRC="${WORKROOT}/s05-src" -LOG="${WORKROOT}/s05" - -step "sanity: confirm CONTRIB holds no WRITE token" -if dg_read_retry "$ID_DEPLOYER" "$LOG-list.json" "$LOG-list.err" --json collab list "$REPO"; then - HASW="$(python3 - "$IDID_CONTRIB" "$LOG-list.json" <<'PY' -import json,sys -me=sys.argv[1] -try: d=json.load(open(sys.argv[2])) -except Exception: print("unknown"); sys.exit(0) -for c in d.get("collaborators",[]): - if c.get("identityId")==me and c.get("write"): - print("has"); sys.exit(0) -print("none") -PY -)" - info "CONTRIB write token: ${HASW}" - [[ "$HASW" == "has" ]] && skip_scenario "CONTRIB unexpectedly holds a WRITE token; not a no-token subject" -else - info "collab list query failed (transport); proceeding — CONTRIB is a no-token fixture" -fi - -step "CONTRIB attempts a push (expect CONSENSUS rejection)" -seed_tiny_repo "$SRC" "$BR" >/dev/null -register_ref "refs/heads/${BR}" -# As in 04: bypass the helper's local no-token pre-check so the push reaches consensus, -# and retry on a flake so the scenario reaches a verdict. -if DASH_FORGE_SKIP_WRITE_PRECHECK=1 git_dash_retry "$ID_CONTRIB" "$LOG-push" -C "$SRC" push "$E2E_REMOTE" "refs/heads/${BR}:refs/heads/${BR}"; then - bad "CONTRIB's push was ACCEPTED without a WRITE token — the write path is NOT gated" - finish_scenario -fi - -echo "----- no-token push stderr (captured) -----" >&2 -cat "$LOG-push.err" >&2 -echo "-------------------------------------------" >&2 - -if is_flake "$LOG-push.err" && ! is_consensus_reject "$LOG-push.err"; then - skip_scenario "push flaked on transport on every attempt (${E2E_ATTEMPTS}) — inconclusive" -fi -if grep -qiE 'no WRITE token on this repo|tokens? on this repo (is|are) frozen' "$LOG-push.err"; then - bad "push was refused by the helper's local pre-check, not at consensus (DASH_FORGE_SKIP_WRITE_PRECHECK not honored?)" - finish_scenario -fi -if grep -qiE 'insufficient credits|InsufficientCredits' "$LOG-push.err" && ! is_consensus_reject "$LOG-push.err"; then - skip_scenario "CONTRIB lacks the credits to even submit the state transition (fund + retry)" -fi -if is_consensus_reject "$LOG-push.err"; then - ok "no-token push REJECTED AT CONSENSUS (unauthorized / insufficient WRITE token)" - REJ_LINE="$(grep -ioE 'not authorized[^\"]*|unauthorized[^\"]*|insufficient token[^\"]*|requires a WRITE[^\"]*|token[^\"]*' "$LOG-push.err" | head -1)" - info "consensus error: ${REJ_LINE}" -else - bad "push failed but without a recognizable unauthorized/token consensus error" -fi - -finish_scenario diff --git a/e2e/cli/scenarios/05-non-member-push.sh b/e2e/cli/scenarios/05-non-member-push.sh new file mode 100755 index 000000000..1ad2e3b0e --- /dev/null +++ b/e2e/cli/scenarios/05-non-member-push.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Scenario 5: a non-member's push is rejected at consensus. CONTRIB has never been a +# writer or maintainer of the test repo; its push must be refused by the network (40120: +# `ownerRefersTo` finds no membership document), not silently accepted. +SCENARIO_NAME="05 non-member push rejected at consensus" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" +harness_init + +BR="e2e/${RUN_ID}/contrib" +REPO="${E2E_OWNER_ID}/${E2E_REPO_NAME}" +SRC="${WORKROOT}/s05-src" +LOG="${WORKROOT}/s05" + +harness_ensure_repo "$E2E_REPO_NAME" || skip_scenario "could not create/resolve the test repo" + +step "sanity: confirm CONTRIB is not a member" +if dg_read_retry "$ID_OWNER" "$LOG-list.json" "$LOG-list.err" --json collab list "$REPO"; then + IS="$(python3 - "$IDID_CONTRIB" "$LOG-list.json" <<'PY' +import json,sys +me=sys.argv[1] +d=json.load(open(sys.argv[2])) +print("member" if any(m.get("identityId")==me for m in d.get("members",[])) else "none") +PY +)" + info "CONTRIB membership: ${IS}" + [[ "$IS" == "member" ]] && skip_scenario "CONTRIB is unexpectedly a member; not a non-member subject" +else + info "collab list query failed (transport); proceeding — CONTRIB is a non-member fixture" +fi + +step "CONTRIB attempts a push (expect CONSENSUS rejection)" +seed_tiny_repo "$SRC" "$BR" >/dev/null +register_ref "refs/heads/${BR}" +# As in 04: bypass the helper's local membership pre-check so the push reaches consensus, +# and retry on a flake so the scenario reaches a verdict. +if DASH_FORGE_SKIP_WRITE_PRECHECK=1 git_dash_retry "$ID_CONTRIB" "$LOG-push" -C "$SRC" push "$E2E_REMOTE" "refs/heads/${BR}:refs/heads/${BR}"; then + bad "CONTRIB's push was ACCEPTED without membership — the write path is NOT gated" + finish_scenario +fi + +echo "----- non-member push stderr (captured) -----" >&2 +cat "$LOG-push.err" >&2 +echo "---------------------------------------------" >&2 + +if is_flake "$LOG-push.err" && ! is_consensus_reject "$LOG-push.err"; then + skip_scenario "push flaked on transport on every attempt (${E2E_ATTEMPTS}) — inconclusive" +fi +if is_local_precheck "$LOG-push.err"; then + bad "push was refused by the helper's local pre-check, not at consensus (DASH_FORGE_SKIP_WRITE_PRECHECK not honored?)" + finish_scenario +fi +if grep -qiE 'insufficient credits|InsufficientCredits' "$LOG-push.err" && ! is_consensus_reject "$LOG-push.err"; then + skip_scenario "CONTRIB lacks the credits to even submit the state transition (fund + retry)" +fi +if is_consensus_reject "$LOG-push.err"; then + ok "non-member push REJECTED AT CONSENSUS (40120)" + info "consensus error: $(grep -oE '40120[^)]*' "$LOG-push.err" | head -1)" +else + bad "push failed but without a recognizable 40120 consensus error" +fi + +finish_scenario diff --git a/e2e/cli/scenarios/06-third-party-verify.sh b/e2e/cli/scenarios/06-third-party-verify.sh index 04c3c8f7b..0bc889249 100755 --- a/e2e/cli/scenarios/06-third-party-verify.sh +++ b/e2e/cli/scenarios/06-third-party-verify.sh @@ -20,6 +20,7 @@ SCENARIO_NAME="06 third-party verification (⭐)" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" harness_init +[[ -n "${HARNESS_SHARED:-}" ]] || harness_ensure_repo "$E2E_REPO_NAME" || skip_scenario "could not create/resolve the test repo" OWNER="$E2E_OWNER_ID"; RNAME="$E2E_REPO_NAME"; REPO="${OWNER}/${RNAME}" LOG="${WORKROOT}/s06" diff --git a/e2e/cli/scenarios/07-depth-and-filter.sh b/e2e/cli/scenarios/07-depth-and-filter.sh index 1b80e4a12..33649cca9 100755 --- a/e2e/cli/scenarios/07-depth-and-filter.sh +++ b/e2e/cli/scenarios/07-depth-and-filter.sh @@ -8,6 +8,7 @@ SCENARIO_NAME="07 shallow fails loudly / partial clone works" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" harness_init +[[ -n "${HARNESS_SHARED:-}" ]] || harness_ensure_repo "$E2E_REPO_NAME" || skip_scenario "could not create/resolve the test repo" LOG="${WORKROOT}/s07" diff --git a/e2e/cli/scenarios/08-v1-read-compat.sh b/e2e/cli/scenarios/08-v1-read-compat.sh new file mode 100755 index 000000000..2581d634b --- /dev/null +++ b/e2e/cli/scenarios/08-v1-read-compat.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Scenario 8: forge-v1 read compatibility on TESTNET. v1 repositories (one contract each) +# are read only now, but must stay cloneable: +# * `git clone dash:///` of the testnet M1 repo resolves through the v1 +# registry (testnet has no forge-v2 deployment) and clones cleanly (fsck); +# * `git clone dash://` resolves the same repo by its contract id; +# * a push to it is refused locally as read only, before anything is signed. +# Read-only: no Platform writes. Needs a testnet identity (any; only reads are signed-free). +SCENARIO_NAME="08 v1 read-compat (testnet clone)" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib.sh" +harness_init + +LOG="${WORKROOT}/s08" +[[ -f "$V1_TESTNET_IDENTITY" ]] || skip_scenario "no testnet identity at ${V1_TESTNET_IDENTITY}" +V1_REMOTE="dash://${V1_TESTNET_OWNER}/${V1_TESTNET_REPO}" +# Every command below targets testnet, whatever the suite's network is. +testnet() { DASH_FORGE_NETWORK=testnet DASH_FORGE_DEVNET_NAME= DASH_FORGE_DAPI_ADDRESSES= "$@"; } + +step "clone the v1 repo by name on testnet" +C1="${WORKROOT}/s08-named" +if ! testnet git_dash_retry "$V1_TESTNET_IDENTITY" "$LOG-named" clone "$V1_REMOTE" "$C1"; then + cat "$LOG-named.err" >&2 || true + is_flake "$LOG-named.err" && skip_scenario "testnet clone flaked on every attempt" + bad "v1 clone by name failed"; finish_scenario +fi +ok "cloned ${V1_REMOTE}" +if git -C "$C1" fsck --full >"$LOG-fsck.out" 2>&1; then ok "fsck clean"; else cat "$LOG-fsck.out" >&2; bad "fsck failed"; fi +NREFS="$(git -C "$C1" for-each-ref refs/remotes | wc -l | tr -d ' ')" +check "the clone has branches (${NREFS})" bash -c "[[ ${NREFS:-0} -ge 1 ]]" + +step "resolve the same repo by its contract id" +if testnet git_dash_retry "$V1_TESTNET_IDENTITY" "$LOG-id" ls-remote "dash://${V1_TESTNET_CONTRACT}"; then + HEAD_BY_ID="$(awk '$2=="HEAD"{print $1}' "$LOG-id.out")" + HEAD_BY_NAME="$(git -C "$C1" rev-parse HEAD)" + check "dash:// advertises the same HEAD" assert_eq "$HEAD_BY_NAME" "$HEAD_BY_ID" "HEAD" +else + cat "$LOG-id.err" >&2 || true + is_flake "$LOG-id.err" && skip_scenario "contract-id ls-remote flaked" + bad "dash:// did not resolve" +fi + +step "a push to the v1 repo is refused as read only (nothing signed)" +git -C "$C1" -c user.email=e2e@x -c user.name=e2e -c commit.gpgsign=false commit -q --allow-empty -m "v1 push probe ${RUN_ID}" +if testnet git_dash "$V1_TESTNET_IDENTITY" "$LOG-push" -C "$C1" push "$V1_REMOTE" "HEAD:refs/heads/e2e/${RUN_ID}/v1probe"; then + bad "a push to a v1 repo was ACCEPTED" +elif grep -qiE 'v1 repo \(read-only\)' "$LOG-push.err" "$LOG-push.out" 2>/dev/null; then + ok "push refused: v1 repo is read only" +else + cat "$LOG-push.err" >&2 + bad "push failed without the read-only message" +fi + +finish_scenario diff --git a/e2e/cli/seed-read-fixture.sh b/e2e/cli/seed-read-fixture.sh index c86ac37ac..bb9b383a3 100644 --- a/e2e/cli/seed-read-fixture.sh +++ b/e2e/cli/seed-read-fixture.sh @@ -12,9 +12,13 @@ # * no browse index is published (DASH_FORGE_NO_BROWSE_INDEX), so the web app takes the # in-browser fallback clone, which fallback-browse.spec.ts exercises. # -# The repo is created (repo-v1, ~1.18 tDASH, once) only if absent. Exit 0 = seeded or -# already seeded; 1 = could not seed. +# The fixture is a forge-v1 repo on TESTNET. v1 is read only now: the script verifies the +# fixture and fails (it cannot reseed) when it is wrong; the fixture moves to forge-v2 with +# the web app. Exit 0 = the fixture is as expected; 1 = it is not, or could not be read. SCENARIO_NAME="seed the nightly read fixture (${NIGHTLY_FIXTURE_REPO:-m1-5124})" +# The fixture lives on testnet with the testnet pool, whatever the CLI suite targets. +export DASH_FORGE_NETWORK=testnet DASH_FORGE_DEVNET_NAME= E2E_OWNER_ROLE=DEPLOYER +export E2E_IDENTITY_DIR="${E2E_TESTNET_IDENTITY_DIR:-${HOME}/.config/dash-forge/test-identities}" source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" harness_init @@ -26,13 +30,6 @@ LOG="${WORKROOT}/fixture" step "fixture repo ${FIX_REPO}" if dg_read_retry "$ID_DEPLOYER" "$LOG-view.json" "$LOG-view.err" --json repo view "$FIX_REPO"; then info "reusing ${FIX_REPO}" -elif grep -qiE 'not found|no such|does not exist|unknown repo' "$LOG-view.err"; then - info "creating ${FIX_REPO} (one-time, ~1.18 tDASH)" - dg_as "$ID_DEPLOYER" --yes --json repo create "$NIGHTLY_FIXTURE_REPO" \ - --description "Dash Forge nightly read fixture (reserved; see e2e/README.md)" \ - >"$LOG-create.out" 2>"$LOG-create.err" || { - cat "$LOG-create.err" >&2; bad "could not create ${FIX_REPO}"; finish_scenario - } else cat "$LOG-view.err" >&2; bad "could not read ${FIX_REPO}"; finish_scenario fi @@ -91,7 +88,7 @@ if [[ "$HAVE" == "$WANT" ]]; then fi finish_scenario fi -info "main is ${HAVE:-absent}; force-pushing the fixture commit" +info "main is ${HAVE:-absent}; force-pushing the fixture commit (fails: v1 is read only)" step "push the fixture (Platform storage only, no browse index)" # `-c` is git's command scope, which the helper ranks above any global or repo setting, so diff --git a/e2e/cli/storage-byo.sh b/e2e/cli/storage-byo.sh index 69530613d..77d4758ba 100644 --- a/e2e/cli/storage-byo.sh +++ b/e2e/cli/storage-byo.sh @@ -1,14 +1,15 @@ #!/usr/bin/env bash # storage-byo.sh — bring-your-own-storage end to end: a REAL `git push` / `git clone` # over dash:// whose pack bytes go to LOCAL MinIO (SigV4-signed) + kubo, with only the -# packManifest + refUpdate written to testnet. Run via `make storage-e2e`. +# packManifest + refUpdate written to Platform (devnet moutai, forge-v2 — config.sh). +# Run via `make storage-e2e`. # -# Runs against two DEDICATED repos owned by DEPLOYER — never the shared m1 test repo, +# Runs against two DEDICATED forge-v2 repos owned by OWNER — never the shared test repo, # because every pack this script stores lives on localhost MinIO/kubo and so is # unreadable to anyone else: STORAGE_E2E_REPO (steps 1-4) and STORAGE_E2E_REPO_B (steps -# 5-6), defaults in config.sh (reserved in e2e/README.md). Each is created (repo-v1, -# ~1.18 tDASH, once) only if it does not exist; every run uses a fresh branch and -# deletes it at the end. +# 5-6), defaults in config.sh (reserved in e2e/README.md). Each is created (~0.001 DASH, +# once, resumably) if it does not exist; every run uses a fresh branch and deletes it at +# the end. # # Proves: # 1. push with dash.storage=minio,kubo dash.replicas=2 → the manifest records @@ -25,8 +26,8 @@ # 6. `dg reseed --from-local` restores the exact recorded copy from the pusher's clone, # after which the re-push succeeds and a fresh clone of repo B works. # -# Spend per run: a handful of manifests + ref updates on testnet (well under 0.01 tDASH -# of the DEPLOYER identity), no chunk documents. Requires infra/docker-compose.yml up. +# Spend per run: a handful of manifests + ref updates (well under 0.01 DASH of the OWNER +# identity), no chunk documents. Requires infra/docker-compose.yml up. SCENARIO_NAME="storage-byo (MinIO + kubo, real push/clone)" source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" harness_init @@ -38,22 +39,8 @@ curl -fsS -o /dev/null "${MINIO}/minio/health/live" || skip_scenario "MinIO not curl -fsS -o /dev/null -X POST "${KUBO_API}/api/v0/version" || skip_scenario "kubo not up (make infra-up)" # --- the dedicated repos ------------------------------------------------------ -ensure_repo() { # ensure_repo — create under DEPLOYER once; reuse afterwards - local name="$1" - if DASH_FORGE_KEY="$ID_DEPLOYER" "$DG" --json repo view "${E2E_OWNER_ID}/${name}" >/dev/null 2>&1; then - info "reusing dedicated repo ${name}" - return 0 - fi - info "creating dedicated repo ${name} (one-time, ~1.18 tDASH)" - DASH_FORGE_KEY="$ID_DEPLOYER" "$DG" --yes --json repo create "$name" \ - --description "Dash Forge storage-byo e2e fixture (reserved; see e2e/README.md)" \ - >"${WORKROOT}/create-${name}.out" 2>"${WORKROOT}/create-${name}.err" || { - cat "${WORKROOT}/create-${name}.err" >&2 - skip_scenario "could not create the dedicated repo ${name} (funds/flake)" - } -} -ensure_repo "$STORAGE_E2E_REPO" -ensure_repo "$STORAGE_E2E_REPO_B" +harness_ensure_repo "$STORAGE_E2E_REPO" || skip_scenario "could not create the dedicated repo ${STORAGE_E2E_REPO}" +harness_ensure_repo "$STORAGE_E2E_REPO_B" || skip_scenario "could not create the dedicated repo ${STORAGE_E2E_REPO_B}" REMOTE_A="dash://${E2E_OWNER_ID}/${STORAGE_E2E_REPO}" REMOTE_B="dash://${E2E_OWNER_ID}/${STORAGE_E2E_REPO_B}" # lib.sh's cleanup deletes registered refs on $E2E_REMOTE; each repo is cleaned below. @@ -118,7 +105,7 @@ step "1. push with dash.storage=minio,kubo dash.replicas=2" if ! DASH_FORGE_STORAGE_CONFIG="$PUSHER_CFG" git_dash_retry "$ID_DEPLOYER" "$LOG-push" \ -C "$SRC" push -v "$REMOTE_A" "refs/heads/${BR}:refs/heads/${BR}"; then cat "$LOG-push.err" >&2 - is_flake "$LOG-push.err" && skip_scenario "testnet transport flake" + is_flake "$LOG-push.err" && skip_scenario "transport flake" bad "push failed"; finish_scenario fi register_ref "refs/heads/${BR}" @@ -207,7 +194,7 @@ if DASH_FORGE_STORAGE_CONFIG="$PUSHER_CFG" git_dash_retry "$ID_DEPLOYER" "$LOG-p check "remote ref advanced" assert_eq "$NEW_TIP" "$(cut -f1 "$LOG-lsremote2.out")" else cat "$LOG-push-n1.err" >&2 - is_flake "$LOG-push-n1.err" && skip_scenario "testnet transport flake" + is_flake "$LOG-push-n1.err" && skip_scenario "transport flake" bad "N=1 push failed" fi From 0792c99f4189546a074e379ef60ae03140e5c3f0 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 25 Sep 2026 12:58:29 -0500 Subject: [PATCH 2/6] refactor(repo): derive the locator pack space from v2_pack_list locator_pack_space and the push-index plan now use the shared rule: one entry per pack hash at its first upload, ranked representative copy, packRef counted within kind 0. Superseded packs keep their positions (v2 manifests are permanent), so a repack appends instead of renumbering; repack, reseed and fetch read every git pack from its best copy. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/dg/src/maint.rs | 5 +- crates/forge-core/src/repo.rs | 312 ++++++++++++++++++---------------- docs/contracts/forge-v2.md | 2 +- 3 files changed, 172 insertions(+), 147 deletions(-) diff --git a/crates/dg/src/maint.rs b/crates/dg/src/maint.rs index f8b705c09..b89d911de 100644 --- a/crates/dg/src/maint.rs +++ b/crates/dg/src/maint.rs @@ -40,11 +40,12 @@ pub async fn repack( let price = dash_usd_price(); let manifests = svc.read_pack_manifests(&handle).await.unwrap_or_default(); - let space = forge_core::repo::locator_pack_space(&manifests, None); + let roles = svc.copy_roles(&handle).await.unwrap_or_default(); + let space = forge_core::repo::locator_pack_space(&manifests, &roles, None); let live_bytes: u64 = space.iter().map(|m| m.size_bytes).sum(); if !ctx.json { println!( - "Repack {}: {} live pack(s), {live_bytes} bytes", + "Repack {}: {} git pack(s), {live_bytes} bytes", handle.display(), space.len() ); diff --git a/crates/forge-core/src/repo.rs b/crates/forge-core/src/repo.rs index 807c3f059..f8d56bbe1 100644 --- a/crates/forge-core/src/repo.rs +++ b/crates/forge-core/src/repo.rs @@ -31,7 +31,7 @@ use crate::platform::{ PushJournal, QueryFilter, QueryOrder, WriteEngine, WriteIntent, }; use crate::private::RefNameHasher; -use crate::rules::v2::{PackCopy, Role}; +use crate::rules::v2::{CopyKey, PackCopy, PackCopyRow, Role, V2Pack}; use crate::rules::{self, ConfigDoc, RefState}; use crate::scope::{self, DocScope, RepoRef}; use crate::storage::{PackReader, Replication, StorageTarget, UriBudget}; @@ -335,7 +335,7 @@ pub struct LocalReseed { /// A repository's current members as the pack reader rule needs them (maintainers' /// copies first): empty on v1, where every copy is the repo contract's own. -type RoleMap = BTreeMap; +pub type RoleMap = BTreeMap; /// The git data-plane service, bound to one signing identity and its keys. /// @@ -834,9 +834,10 @@ impl<'a> RepoService<'a> { /// permanent, so the superseded packs stay readable as the fallback the reader rule /// keeps (a hash proves a pack's bytes, not that it holds everything it replaces). /// - /// Flow: resolve refs → reachable tips; fetch each live kind-0 pack (best copy); - /// rebuild one self-contained pack over the tips ([`crate::pack::repack_from_packs`]); - /// upload it to `target`; write its manifest (`supersedes` every live kind-0 pack, the + /// Flow: resolve refs → reachable tips; fetch every git pack (best copy; superseded + /// ones too — a hash does not prove a consolidation is complete); rebuild one + /// self-contained pack over the tips ([`crate::pack::repack_from_packs`]); upload it to + /// `target`; write its manifest (`supersedes` the packs not already superseded, the /// resolved tips); publish the consolidated browse index (best-effort). pub async fn repack(&self, repo: &RepoRef, target: RepackTarget<'_>) -> Result { let (_, contract) = self.writable(repo).await?; @@ -850,17 +851,15 @@ impl<'a> RepoService<'a> { )); } let manifests = self.read_pack_manifests(repo).await?; - let live = live_kind0_manifests(&manifests); - if live.is_empty() { - return Err(Error::Config( - "repack: no live git packs to consolidate".into(), - )); + let git = git_pack_manifests(&manifests); + if git.is_empty() { + return Err(Error::Config("repack: no git packs to consolidate".into())); } let roles = self.copy_roles(repo).await?; let reader = PackReader::from_user_config(); let mut pack_blobs = Vec::new(); - for (hash, copies) in group_by_hash(&live) { + for (hash, copies) in group_by_hash(&git) { let (bytes, _) = self .fetch_best_copy(repo, &contract, &copies, &roles, &reader) .await @@ -880,7 +879,7 @@ impl<'a> RepoService<'a> { let object_count = consolidated.parsed.object_count() as u64; // Already a single optimal pack: nothing to gain, and nothing to write. - if live.iter().any(|m| m.pack_hash == new_pack_hash) { + if git.iter().any(|m| m.pack_hash == new_pack_hash) { return Err(Error::Config( "repack: the repo is already a single consolidated pack (nothing to do)".into(), )); @@ -907,7 +906,7 @@ impl<'a> RepoService<'a> { ) .await?; let locator_manifest_id = self - .publish_locator_best_effort(repo, &consolidated.parsed, new_pack_hash, target) + .publish_locator_best_effort(repo, &roles, &consolidated.parsed, new_pack_hash, target) .await; let balance_end = self .client @@ -1011,11 +1010,11 @@ impl<'a> RepoService<'a> { let (_, contract) = self.writable(repo).await?; let me = self.identity.id(); let manifests = self.read_pack_manifests(repo).await?; - let live = live_kind0_manifests(&manifests); + let git = git_pack_manifests(&manifests); let roles = self.copy_roles(repo).await?; let reader = PackReader::from_user_config(); let mut report = ReseedReport::default(); - for (hash, copies) in group_by_hash(&live) { + for (hash, copies) in group_by_hash(&git) { let (bytes, best) = self .fetch_best_copy(repo, &contract, &copies, &roles, &reader) .await?; @@ -1053,7 +1052,7 @@ impl<'a> RepoService<'a> { /// Restore lost external copies from a LOCAL clone (`dg reseed --from-local`). /// - /// For every live kind-0 pack (or just `only`), the pack's exact bytes are looked up in + /// For every git pack (or just `only`), the pack's exact bytes are looked up in /// `git_dir` ([`crate::storage::local::find_local_pack`]: the helper's kept copy, or a /// fetched `objects/pack/*.pack`), SHA-256-verified against the manifest, and stored /// on `targets` (≥ `required` must confirm). Storage keys are content-addressed — S3 @@ -1073,13 +1072,13 @@ impl<'a> RepoService<'a> { force: bool, ) -> Result { let manifests = self.read_pack_manifests(repo).await?; - let live: Vec = live_kind0_manifests(&manifests) + let live: Vec = git_pack_manifests(&manifests) .into_iter() .filter(|m| only.is_none_or(|h| h == m.pack_hash)) .collect(); if let (Some(h), true) = (only, live.is_empty()) { return Err(Error::Config(format!( - "no live pack {} in this repo's manifests", + "no git pack {} in this repo's manifests", hex::encode(h) ))); } @@ -1120,11 +1119,15 @@ impl<'a> RepoService<'a> { async fn publish_locator_best_effort( &self, repo: &RepoRef, + roles: &RoleMap, pack: &crate::pack::ParsedPack, pack_hash: [u8; 32], target: RepackTarget<'_>, ) -> Option { - match self.publish_locator(repo, pack, pack_hash, target).await { + match self + .publish_locator(repo, roles, pack, pack_hash, target) + .await + { Ok(id) => Some(id), Err(e) => { tracing::warn!( @@ -1140,27 +1143,29 @@ impl<'a> RepoService<'a> { /// Build and publish an `objectLocator` (kind 1) over the consolidated repack pack, /// superseding every prior locator fragment. /// - /// `pack_ref` is read from a FRESH manifest list rather than assumed to be 0: a push - /// landing between this repack's manifest read and its write stores a pack it could not - /// supersede, with an earlier `$createdAt`, which takes packRef 0. + /// `pack_ref` is read from a FRESH manifest list: the consolidated pack is appended to + /// the pack space (superseded packs keep their positions), after any pack a concurrent + /// push stored first. async fn publish_locator( &self, repo: &RepoRef, + roles: &RoleMap, pack: &crate::pack::ParsedPack, pack_hash: [u8; 32], target: RepackTarget<'_>, ) -> Result { let manifests = self.read_pack_manifests(repo).await?; - let space = locator_pack_space(&manifests, None); + let space = locator_pack_space(&manifests, roles, None); + let hash = hex::encode(pack_hash); let idx = space .iter() - .position(|m| m.pack_hash == pack_hash) + .position(|p| p.pack_hash == hash) .ok_or_else(|| { - Error::Config("repack: the consolidated pack is not in the live pack set".into()) + Error::Config("repack: the consolidated pack is not in the pack set".into()) })?; let pack_ref = u16::try_from(idx).map_err(|_| { Error::Config(format!( - "repack: live pack set has {} packs — past the locator's 16-bit packRef", + "repack: the pack set has {} packs — past the locator's 16-bit packRef", space.len() )) })?; @@ -1190,8 +1195,9 @@ impl<'a> RepoService<'a> { target: RepackTarget<'_>, ) -> Result { let manifests = self.read_pack_manifests(repo).await?; - let space_len = locator_pack_space(&manifests, None).len(); - let (pack_ref, live_locators) = match plan_push_index(&manifests, pack_hash) { + let roles = self.copy_roles(repo).await?; + let space_len = locator_pack_space(&manifests, &roles, None).len(); + let (pack_ref, live_locators) = match plan_push_index(&manifests, &roles, pack_hash) { PushIndexPlan::Skip(why) => return Ok(PushIndexOutcome::Skipped(why)), PushIndexPlan::Publish { pack_ref, @@ -1234,7 +1240,7 @@ impl<'a> RepoService<'a> { .is_some_and(|r| usize::from(r) >= space_len) { return Ok(PushIndexOutcome::Skipped( - "a published index fragment addresses a pack outside the live pack set — \ + "a published index fragment addresses a pack outside the pack set — \ run `dg repack` to rebuild the index" .into(), )); @@ -1388,35 +1394,44 @@ fn resolved_tip_oids(refs: &[(String, RefState)]) -> Vec { out } -/// Pack hashes a repack's consolidated manifest lists in `supersedes`: every live kind-0 -/// pack except the new one, oldest first. +/// Pack hashes a repack's consolidated manifest lists in `supersedes`: the git packs no +/// manifest already names in `supersedes`, except the new one, in pack-space order. /// -/// `supersedes` is a packed byteArray capped at 1024 bytes — **32 hashes**. On forge-v2 -/// nothing is deleted, so a pack superseded by an earlier repack stays superseded (the -/// manifest that says so is permanent) and needs no slot here. Past 32 the list is -/// truncated and the caller warned: the repack still consolidates, but a pack it could not -/// name stays live, so the browse index reads as behind until a later repack names it. +/// `supersedes` is a packed byteArray capped at 1024 bytes — **32 hashes**. A pack an +/// earlier repack superseded stays superseded (the manifest that says so is permanent), so +/// it needs no slot here. Past 32 the list is truncated and the caller warned: the repack +/// still consolidates, and a pack it could not name is only read whole a little longer. fn repack_supersedes(manifests: &[PackManifestInfo], new_pack_hash: [u8; 32]) -> Vec<[u8; 32]> { /// `packManifest.supersedes` is a byteArray of at most 1024 bytes. const MAX_SUPERSEDES: usize = 1024 / 32; - let mut out: Vec<[u8; 32]> = Vec::new(); - for m in locator_pack_space(manifests, None) { - if m.pack_hash != new_pack_hash && !out.contains(&m.pack_hash) { - out.push(m.pack_hash); - } - } + let claimed: BTreeSet<[u8; 32]> = manifests + .iter() + .flat_map(|m| m.supersedes.iter().copied()) + .collect(); + let new_hash = hex::encode(new_pack_hash); + let mut out: Vec<[u8; 32]> = locator_pack_space(manifests, &RoleMap::new(), None) + .iter() + .filter(|p| p.pack_hash != new_hash) + .filter_map(|p| hash32(&p.pack_hash)) + .filter(|h| !claimed.contains(h)) + .collect(); if out.len() > MAX_SUPERSEDES { tracing::warn!( wanted = out.len(), kept = MAX_SUPERSEDES, - "more packs than `supersedes` can name; the browse index will read as behind \ - until a later repack can name the rest" + "more packs than `supersedes` can name; the rest stay read whole until a later \ + repack names them" ); out.truncate(MAX_SUPERSEDES); } out } +/// A 64-hex pack hash as bytes. +fn hash32(hex_hash: &str) -> Option<[u8; 32]> { + hex::decode(hex_hash).ok()?.try_into().ok() +} + /// What [`RepoService::publish_push_locator`] should do, decided from the manifest list /// alone. Separated from the transport so the decision — which is all the interesting /// behavior — is testable without a platform connection. @@ -1438,23 +1453,28 @@ enum PushIndexPlan { /// `manifests` must already include the pack just written. Three ways this declines, each /// meaning the index would otherwise start addressing the wrong bytes: /// -/// * the pushed pack is not live — a repack superseded it between the push and this read; -/// * the live pack set outgrew the locator's 16-bit `packRef`; -/// * a live fragment indexes a pack space that is not a prefix of the current one. Between -/// repacks the live pack list only grows at the end, so this holds; a repack breaks it and -/// supersedes the fragments it consolidated, leaving only the concurrent-repack race. -fn plan_push_index(manifests: &[PackManifestInfo], pack_hash: [u8; 32]) -> PushIndexPlan { - let space = locator_pack_space(manifests, None); - let Some(idx) = space.iter().position(|m| m.pack_hash == pack_hash) else { +/// * the pushed pack is not in the git pack space (another copy re-labelled its kind); +/// * the pack space outgrew the locator's 16-bit `packRef`; +/// * a live fragment indexes a pack space that is not a prefix of the current one. The +/// space only grows at the end (a pack keeps its first-upload position, superseded or +/// not), so this holds unless a pack's kind changed under a later, higher-ranked copy. +fn plan_push_index( + manifests: &[PackManifestInfo], + roles: &RoleMap, + pack_hash: [u8; 32], +) -> PushIndexPlan { + let space = locator_pack_space(manifests, roles, None); + let hash = hex::encode(pack_hash); + let Some(idx) = space.iter().position(|p| p.pack_hash == hash) else { return PushIndexPlan::Skip( - "the pushed pack is not in the live pack set (superseded by a concurrent \ - repack?) — its index would address the wrong bytes" + "the pushed pack is not in the git pack space — its index would address the \ + wrong bytes" .into(), ); }; let Ok(pack_ref) = u16::try_from(idx) else { return PushIndexPlan::Skip(format!( - "live pack set has {} packs — past the locator's 16-bit packRef; \ + "the pack set has {} packs — past the locator's 16-bit packRef; \ run `dg maint repack` to consolidate", space.len() )); @@ -1462,7 +1482,14 @@ fn plan_push_index(manifests: &[PackManifestInfo], pack_hash: [u8; 32]) -> PushI let live_locators = live_locator_manifests(manifests); for m in &live_locators { - let as_of = locator_pack_space(manifests, Some(m.created_at)); + let as_of = locator_pack_space( + manifests, + roles, + Some(&CopyKey { + created_at: m.created_at, + id: m.document_id.clone(), + }), + ); if as_of.len() > space.len() || as_of .iter() @@ -1470,7 +1497,7 @@ fn plan_push_index(manifests: &[PackManifestInfo], pack_hash: [u8; 32]) -> PushI .any(|(a, b)| a.pack_hash != b.pack_hash) { return PushIndexPlan::Skip( - "a published index fragment no longer matches the live pack set \ + "a published index fragment no longer matches the pack set \ (a repack landed concurrently) — run `dg maint repack` to rebuild it" .into(), ); @@ -1485,48 +1512,46 @@ fn plan_push_index(manifests: &[PackManifestInfo], pack_hash: [u8; 32]) -> PushI } } -/// The pack list a locator's `packRef` indexes — THE normative definition, shared with the -/// web client (`forge-web/lib/view/browse-source.ts::locatorPackSpace`) and with whatever -/// publishes a locator. -/// -/// `packRef` is "an index into the manifest's pack list", but no pack list is stored -/// on-chain, so reader and writer must derive the same one. It is: **the LIVE kind-0 packs -/// as of `as_of`, oldest-first by `($createdAt, $id)`.** Three parts, each load-bearing: -/// -/// * *as of* — a locator only indexes packs that existed when it was built; later -/// incremental packs are outside its space. `None` means "as of now". -/// * *live* — a repack consolidates several packs into one and marks the originals -/// `supersedes`. Superseded manifests survive (v2 manifests are permanent; v1 repacks -/// deleted only the caller's own), so counting them would shift every index. Liveness is -/// computed WITHIN the as-of bound, so a later repack cannot retroactively change what an -/// older locator meant. -/// * *oldest-first by `($createdAt, $id)`* — the platform total order, not a reversed -/// `$createdAt desc` query, which drops the `$id` tiebreak on equal timestamps. -/// * *one entry per pack* — on forge-v2 each uploader has its own copy of a pack; a pack's -/// position is that of its earliest copy. +/// The repository's pack list (`FORGE_RULES_V2::v2_pack_list`, forge-v2.md §4) over its +/// manifests: every pack once, however many uploaders hold a copy, with its `packRef` among +/// the packs of its kind. `roles` ranks the copies (empty on v1, where each pack has one). +/// Copies are listed unchecked (`verified: None`), so no pack counts as superseded here; +/// supersession only changes which packs a reader fetches whole, never a position. +pub fn pack_list( + manifests: &[PackManifestInfo], + roles: &RoleMap, + as_of: Option<&CopyKey>, +) -> Vec { + let rows: Vec = manifests + .iter() + .map(|m| PackCopyRow { + id: m.document_id.clone(), + pack_hash: hex::encode(m.pack_hash), + kind: m.kind, + created_at: m.created_at, + owner_role: roles.get(&m.owner_id).copied(), + size_bytes: m.size_bytes, + object_count: m.object_count, + chunk_count: m.chunk_count, + supersedes: m.supersedes.iter().map(hex::encode).collect(), + verified: None, + }) + .collect(); + crate::rules::v2::v2_pack_list(&rows, as_of) +} + +/// The space a locator's `packRef` indexes: the git (kind-0) packs of [`pack_list`], in +/// `packRef` order. `as_of` bounds it to what existed when an older locator was built. +/// Shared with forge-web (`v2PackList`) through the `v2_pack_list__*` vectors. pub fn locator_pack_space( manifests: &[PackManifestInfo], - as_of: Option, -) -> Vec { - let bounded: Vec = match as_of { - None => manifests.to_vec(), - Some(t) => manifests - .iter() - .filter(|m| m.created_at <= t) - .cloned() - .collect(), - }; - let mut live = live_kind0_manifests(&bounded); - live.sort_by(|a, b| { - a.created_at - .cmp(&b.created_at) - .then_with(|| a.document_id.cmp(&b.document_id)) - }); - // forge-v2: several uploaders may each hold a copy of one pack. The space has one entry - // per pack, placed at its first copy — a later copy does not shift existing packRefs. - let mut seen = BTreeSet::new(); - live.retain(|m| seen.insert(m.pack_hash)); - live + roles: &RoleMap, + as_of: Option<&CopyKey>, +) -> Vec { + pack_list(manifests, roles, as_of) + .into_iter() + .filter(|p| p.kind == u64::from(crate::pack::KIND_GIT_PACK)) + .collect() } /// Decode a `packManifest` document. @@ -1587,20 +1612,13 @@ fn live_locator_manifests(manifests: &[PackManifestInfo]) -> Vec Vec { - let superseded: BTreeSet<[u8; 32]> = manifests - .iter() - .flat_map(|m| m.supersedes.iter().copied()) - .collect(); +/// Every git pack (kind 0) manifest, all copies: what a fetch, a repack or a reseed reads +/// (per pack, from its best verifying copy). Superseded packs are included — they are the +/// fallback the reader rule keeps. +fn git_pack_manifests(manifests: &[PackManifestInfo]) -> Vec { manifests .iter() .filter(|m| m.kind == u64::from(crate::pack::KIND_GIT_PACK)) - .filter(|m| !superseded.contains(&m.pack_hash)) .cloned() .collect() } @@ -1631,7 +1649,7 @@ mod tests { order_copies, plan_push_index, repack_supersedes, PackManifestInfo, PushIndexPlan, RoleMap, MAX_LOCATOR_FRAGMENTS, }; - use crate::rules::v2::Role; + use crate::rules::v2::{CopyKey, Role}; use crate::rules::ConfigDoc; /// A manifest stub carrying only what the packRef space is derived from. @@ -1656,6 +1674,18 @@ mod tests { ms.iter().map(|m| m.pack_hash[0]).collect() } + /// The first byte of each pack in the locator space (unranked copies, as of now or `t`). + fn space(ms: &[PackManifestInfo], as_of: Option<(u64, &str)>) -> Vec { + let key = as_of.map(|(created_at, id)| CopyKey { + created_at, + id: id.to_string(), + }); + locator_pack_space(ms, &RoleMap::new(), key.as_ref()) + .iter() + .map(|p| hex::decode(&p.pack_hash).unwrap()[0]) + .collect() + } + #[test] fn locator_pack_space_orders_live_packs_oldest_first_with_the_id_tiebreak() { // Query order is `$createdAt desc`; the packRef space is the reverse WITH the id @@ -1666,11 +1696,13 @@ mod tests { manifest("mm", 100, 0, 1), manifest("ll", 150, 1, 9), // a locator is not part of the pack space ]; - assert_eq!(hashes(&locator_pack_space(&manifests, None)), vec![1, 2, 3]); + assert_eq!(space(&manifests, None), vec![1, 2, 3]); } #[test] - fn locator_pack_space_excludes_superseded_packs_within_the_as_of_bound() { + fn superseded_packs_keep_their_positions_and_as_of_bounds_the_space() { + // v2 manifests are permanent: a repack appends the consolidated pack and the packs + // it supersedes keep their packRefs, so no locator is ever renumbered. let mut consolidated = manifest("cc", 300, 0, 9); consolidated.supersedes = vec![[1u8; 32], [2u8; 32]]; let manifests = vec![ @@ -1678,17 +1710,9 @@ mod tests { manifest("bb", 200, 0, 2), manifest("aa", 100, 0, 1), ]; - - // As of now: only the consolidated pack is live. - assert_eq!(hashes(&locator_pack_space(&manifests, None)), vec![9]); - - // As of a locator published BEFORE the repack, the originals are still live and - // still hold packRef 0 and 1 — a later repack must not retroactively renumber what - // an older index fragment meant. - assert_eq!( - hashes(&locator_pack_space(&manifests, Some(250))), - vec![1, 2] - ); + assert_eq!(space(&manifests, None), vec![1, 2, 9]); + // As of a locator published before the repack: only the originals. + assert_eq!(space(&manifests, Some((250, "ll"))), vec![1, 2]); } #[test] @@ -1727,7 +1751,7 @@ mod tests { /// Shorthand: what `plan_push_index` decided, as `(pack_ref, fold)` or the skip reason. fn plan(manifests: &[PackManifestInfo], hash: u8) -> Result<(u16, bool, usize), String> { - match plan_push_index(manifests, [hash; 32]) { + match plan_push_index(manifests, &RoleMap::new(), [hash; 32]) { PushIndexPlan::Publish { pack_ref, fold, @@ -1770,9 +1794,9 @@ mod tests { } #[test] - fn push_index_plan_declines_when_a_repack_superseded_the_pushed_pack() { - // The race: a repack landed between the push and this read, so the pack the push - // stored is no longer live and an index over it would address the wrong bytes. + fn push_index_plan_indexes_a_pack_a_concurrent_repack_superseded() { + // A repack landed between the push and this read. The pushed pack keeps its + // position (superseded packs are never renumbered), so its fragment is still right. let mut consolidated = manifest("cc", 300, 0, 9); consolidated.supersedes = vec![[1u8; 32], [2u8; 32]]; let manifests = vec![ @@ -1780,28 +1804,28 @@ mod tests { manifest("p2", 200, 0, 2), manifest("p1", 100, 0, 1), ]; - assert!(plan(&manifests, 2) - .unwrap_err() - .contains("not in the live pack set")); + assert_eq!(plan(&manifests, 2), Ok((1, false, 0))); } #[test] - fn push_index_plan_declines_when_a_live_fragment_predates_a_repack() { - // A fragment published concurrently with a repack survives (the repack could not - // list it in `supersedes`) but indexes a pack space the repack replaced. Folding it - // in would carry its packRefs into an index that means different packs. - let mut consolidated = manifest("cc", 300, 0, 9); - consolidated.supersedes = vec![[1u8; 32], [2u8; 32]]; + fn push_index_plan_declines_a_pack_whose_kind_another_copy_changed() { + // A maintainer's later copy labels the pushed hash a locator (kind 1): the pack is + // no longer in the git pack space, so indexing it would address the wrong bytes. + let mut relabel = manifest("m1", 300, 1, 3); + relabel.owner_id = "alice".into(); let manifests = vec![ - manifest("p4", 400, 0, 4), // the pack this push stored - consolidated, - manifest("f1", 250, 1, 8), // indexed [p1, p2] — not a prefix of [cc, p4] - manifest("p2", 200, 0, 2), + relabel, + manifest("p3", 200, 0, 3), manifest("p1", 100, 0, 1), ]; - assert!(plan(&manifests, 4) - .unwrap_err() - .contains("no longer matches the live pack set")); + let roles: RoleMap = [("alice".to_string(), Role::Maintainer)] + .into_iter() + .collect(); + let got = plan_push_index(&manifests, &roles, [3; 32]); + assert!( + matches!(&got, PushIndexPlan::Skip(why) if why.contains("not in the git pack space")), + "{got:?}" + ); } #[test] @@ -1827,7 +1851,7 @@ mod tests { prev.supersedes = vec![[1u8; 32]]; let new = manifest("new", 400, 0, 9); let out = repack_supersedes(&[new, prev, bob], [9u8; 32]); - assert_eq!(out, vec![[2u8; 32]]); + assert_eq!(out, vec![[2u8; 32]], "P1 is already superseded by `prev`"); } #[test] @@ -1850,7 +1874,7 @@ mod tests { let mut copy = manifest("c2", 300, 0, 1); copy.owner_id = "carol".into(); let manifests = vec![copy, manifest("p2", 200, 0, 2), manifest("p1", 100, 0, 1)]; - assert_eq!(hashes(&locator_pack_space(&manifests, None)), vec![1, 2]); + assert_eq!(space(&manifests, None), vec![1, 2]); assert_eq!(group_by_hash(&manifests)[0].1.len(), 2); } diff --git a/docs/contracts/forge-v2.md b/docs/contracts/forge-v2.md index 202f4a356..abc0d6c78 100644 --- a/docs/contracts/forge-v2.md +++ b/docs/contracts/forge-v2.md @@ -120,7 +120,7 @@ Protocol 14 checks references on create and replace only; **a delete is never re 4. `packRef` is the pack's index, by first upload, **among the packs of its kind**: kind-0 git packs are numbered 0..n regardless of interleaved kind-1 index fragments. 5. A pack is superseded when another listed pack's representative names it in `supersedes` **and that representative verified**; an unchecked claim supersedes nothing. Superseded packs keep their `packRef` (positions never shift); readers skip them only when fetching whole packs, and read them as a fallback. -The function is kind-agnostic: callers pass every copy and select a kind from its output (the locator space is the kind-0 packs). On forge-v1, where each pack has one copy, it reduces to the v1 rule (live kind-0 packs, oldest first). +The function is kind-agnostic: callers pass every copy and select a kind from its output (the locator space is the kind-0 packs). It differs from the v1 rule on purpose: v1 dropped superseded packs from the space (a v1 repack deleted the caller's own), while v2 keeps them, because v2 manifests are permanent and a position that never moves means no locator ever needs renumbering. Locators of v1 repositories keep the v1 rule. `release`, `label`, `webhook`, `checkRun`, `comment`, `review`, `star`, `follow` and `profile` stay deletable. Their resolution is newest-wins or per-author, so a deletion removes only the deleter's own contribution. Residual risk: a revoked maintainer can delete a release they published. Readers fall back to the next-newest release for that tag. From 3d6a414614565fb8dca956b4b6848466f802f60f Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 25 Sep 2026 12:58:29 -0500 Subject: [PATCH 3/6] fix(e2e): match the E601/E605 error format, probe the testnet deployer secret Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/testnet-nightly.yml | 1 + crates/git-remote-dash/src/helper.rs | 2 +- e2e/cli/lib.sh | 5 +++-- e2e/cli/scenarios/08-v1-read-compat.sh | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/testnet-nightly.yml b/.github/workflows/testnet-nightly.yml index effe2b6b9..c3eb58dde 100644 --- a/.github/workflows/testnet-nightly.yml +++ b/.github/workflows/testnet-nightly.yml @@ -97,6 +97,7 @@ jobs: FORGE_MOUTAI_IDENTITY_COLLAB: ${{ secrets.FORGE_MOUTAI_IDENTITY_COLLAB }} FORGE_MOUTAI_IDENTITY_CONTRIB: ${{ secrets.FORGE_MOUTAI_IDENTITY_CONTRIB }} FORGE_TEST_IDENTITY_CONTRIB: ${{ secrets.FORGE_TEST_IDENTITY_CONTRIB }} + FORGE_TEST_IDENTITY_DEPLOYER: ${{ secrets.FORGE_TEST_IDENTITY_DEPLOYER }} run: | if [ -n "$FORGE_TEST_IDENTITY_DEPLOYER" ]; then echo "deployer=true" >> "$GITHUB_OUTPUT" diff --git a/crates/git-remote-dash/src/helper.rs b/crates/git-remote-dash/src/helper.rs index a42ef041b..d27b255bc 100644 --- a/crates/git-remote-dash/src/helper.rs +++ b/crates/git-remote-dash/src/helper.rs @@ -1253,7 +1253,7 @@ fn write_denied(repo: &str, me: &str) -> Denied { .fix(format!( "ask the owner to run `dg collab add {repo} {me} --role writer`" )) - .fix("or push to a repo of your own: `dg repo create `, then `git push dash:/// `") + .fix("push to a repo of your own: `dg repo create `, then `git push dash:/// `") .note(NOTE_PRECHECK), wire: "not a writer of this repo", } diff --git a/e2e/cli/lib.sh b/e2e/cli/lib.sh index 3a6bd9258..d2070e1e2 100644 --- a/e2e/cli/lib.sh +++ b/e2e/cli/lib.sh @@ -208,8 +208,9 @@ is_consensus_not_member() { grep -qE '40120' "$1" && grep -qiE 'not a member|consensus refused|\$ownerId' "$1" } is_consensus_reject() { is_consensus_not_member "$1"; } -# The helper's local membership pre-check (never a consensus verdict). -is_local_precheck() { grep -qiE 'you are not a writer of .* — ask its owner' "$1"; } +# The helper's local membership pre-check (never a consensus verdict): E601 with the +# "checked before building or paying" note. +is_local_precheck() { grep -qiE 'checked before building or paying for anything' "$1"; } # --- retry --------------------------------------------------------------------- # Run (which writes its stderr to ) up to E2E_ATTEMPTS times while it diff --git a/e2e/cli/scenarios/08-v1-read-compat.sh b/e2e/cli/scenarios/08-v1-read-compat.sh index 2581d634b..3998bcecc 100755 --- a/e2e/cli/scenarios/08-v1-read-compat.sh +++ b/e2e/cli/scenarios/08-v1-read-compat.sh @@ -43,7 +43,7 @@ step "a push to the v1 repo is refused as read only (nothing signed)" git -C "$C1" -c user.email=e2e@x -c user.name=e2e -c commit.gpgsign=false commit -q --allow-empty -m "v1 push probe ${RUN_ID}" if testnet git_dash "$V1_TESTNET_IDENTITY" "$LOG-push" -C "$C1" push "$V1_REMOTE" "HEAD:refs/heads/e2e/${RUN_ID}/v1probe"; then bad "a push to a v1 repo was ACCEPTED" -elif grep -qiE 'v1 repo \(read-only\)' "$LOG-push.err" "$LOG-push.out" 2>/dev/null; then +elif grep -qiE 'v1 repository, which is read only|\[E605\]' "$LOG-push.err" "$LOG-push.out" 2>/dev/null; then ok "push refused: v1 repo is read only" else cat "$LOG-push.err" >&2 From e082be38be08dd1da2414ddd6f07db8f8bdb6cf2 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 25 Sep 2026 13:10:53 -0500 Subject: [PATCH 4/6] docs(forge-v2): v2_pack_list is not the v1 rule once packs are superseded Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/contracts/forge-v2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contracts/forge-v2.md b/docs/contracts/forge-v2.md index abc0d6c78..bcbd563d5 100644 --- a/docs/contracts/forge-v2.md +++ b/docs/contracts/forge-v2.md @@ -120,7 +120,7 @@ Protocol 14 checks references on create and replace only; **a delete is never re 4. `packRef` is the pack's index, by first upload, **among the packs of its kind**: kind-0 git packs are numbered 0..n regardless of interleaved kind-1 index fragments. 5. A pack is superseded when another listed pack's representative names it in `supersedes` **and that representative verified**; an unchecked claim supersedes nothing. Superseded packs keep their `packRef` (positions never shift); readers skip them only when fetching whole packs, and read them as a fallback. -The function is kind-agnostic: callers pass every copy and select a kind from its output (the locator space is the kind-0 packs). It differs from the v1 rule on purpose: v1 dropped superseded packs from the space (a v1 repack deleted the caller's own), while v2 keeps them, because v2 manifests are permanent and a position that never moves means no locator ever needs renumbering. Locators of v1 repositories keep the v1 rule. +The function is kind-agnostic: callers pass every copy and select a kind from its output (the locator space is the kind-0 packs). It is not the v1 rule: v1 drops superseded packs from the packRef space (live kind-0 packs, oldest first), while v2 keeps them in place. The two agree only when nothing is superseded. v2 manifests are permanent, and a position that never moves means no locator ever needs renumbering; locators of v1 repositories keep the v1 rule. `release`, `label`, `webhook`, `checkRun`, `comment`, `review`, `star`, `follow` and `profile` stay deletable. Their resolution is newest-wins or per-author, so a deletion removes only the deleter's own contribution. Residual risk: a revoked maintainer can delete a release they published. Readers fall back to the next-newest release for that tag. From dcb1be8d2f5eecd6f0af8c254f284237ec6fa116 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 25 Sep 2026 13:12:18 -0500 Subject: [PATCH 5/6] fix: harden the v2 write path against shared-nonce races and hostile copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: a consumed nonce is no longer taken as success (forge-v2 writes share one nonce counter per identity) — creates and deletes confirm by a proved read and re-prepare; the push chunk journal is keyed by repo and uploader; a dead or hostile copy of a pack no longer blocks an honest push (the pusher stores its own); a writer pushing a protected ref is refused before paying, and 40120 on a maintainer-only type says so; a create replay that provably never landed is discarded instead of stranding the journal, and a private repo is never adopted; fetch falls back to time order without the member list and ignores non-members' chunkless manifests; reseed skips unreadable packs; v1 names resolve to the slug. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/dg/src/maint.rs | 7 ++ crates/forge-core/src/create.rs | 105 ++++++++---------- crates/forge-core/src/error.rs | 18 +++- crates/forge-core/src/platform.rs | 126 +++++++++++++++++----- crates/forge-core/src/repo.rs | 22 +++- crates/forge-core/src/resolve.rs | 4 +- crates/forge-core/src/user_error.rs | 95 ++++++++++++++-- crates/forge-core/tests/repo_lifecycle.rs | 4 +- crates/git-remote-dash/src/helper.rs | 126 ++++++++++++++++++---- crates/git-remote-dash/src/journal.rs | 24 ++++- 10 files changed, 405 insertions(+), 126 deletions(-) diff --git a/crates/dg/src/maint.rs b/crates/dg/src/maint.rs index b89d911de..020fc139e 100644 --- a/crates/dg/src/maint.rs +++ b/crates/dg/src/maint.rs @@ -192,6 +192,7 @@ pub async fn reseed( "repoId": handle.id(), "target": target_label, "packs": reseeded_json, + "unreadable": report.unreadable.iter().map(hex::encode).collect::>(), }), || { println!( @@ -199,6 +200,12 @@ pub async fn reseed( report.reseeded.len(), handle.display() ); + for h in &report.unreadable { + println!( + " {} — no readable copy; skipped (try `dg reseed --from-local`)", + hex::encode(h) + ); + } for r in &report.reseeded { let note = if r.announced { "recorded as your copy" diff --git a/crates/forge-core/src/create.rs b/crates/forge-core/src/create.rs index 063bcf9ef..4b6070240 100644 --- a/crates/forge-core/src/create.rs +++ b/crates/forge-core/src/create.rs @@ -29,8 +29,8 @@ use crate::keystore::BridgeIdentity; use crate::members::{doc_type, MemberReader}; use crate::network::ForgeIds; use crate::platform::{ - self, FieldValue, LoadedContract, LoadedIdentity, PlatformClient, QueryFilter, WriteEngine, - WriteIntent, + self, BroadcastOutcome, FieldValue, LoadedContract, LoadedIdentity, PlatformClient, + WriteEngine, WriteIntent, }; use crate::resolve::{find_v2, repo_slug, DOC_REPO}; use crate::rules::v2::{Role, Visibility}; @@ -38,6 +38,9 @@ use crate::scope::RepoRef; /// The initial `config` document type. const DOC_CONFIG: &str = "config"; +/// How often a just-created repo is looked up through its index, and the pause between. +const FIND_ATTEMPTS: usize = 6; +const FIND_DELAY: std::time::Duration = std::time::Duration::from_millis(1500); /// What to create. #[derive(Debug, Clone)] @@ -222,14 +225,13 @@ fn repo_props(opts: &CreateRepoOpts) -> BTreeMap { /// The initial `config` document's properties (no protected patterns: an empty list is the /// same as none, and omitting it keeps the document small). -fn config_props(repo_id: [u8; 32], opts: &CreateRepoOpts) -> BTreeMap { +fn config_props(opts: &CreateRepoOpts) -> BTreeMap { let mut backend = BTreeMap::new(); backend.insert( "mode".into(), FieldValue::integer(u64::from(opts.backend_mode)), ); let mut p = BTreeMap::new(); - p.insert("repoId".into(), FieldValue::identifier(repo_id)); p.insert( "defaultBranch".into(), FieldValue::text(&opts.default_branch), @@ -239,30 +241,35 @@ fn config_props(repo_id: [u8; 32], opts: &CreateRepoOpts) -> BTreeMap, core: &LoadedContract, step: Step, intent: &WriteIntent, ) -> Result { - engine.replay(step.doc_type(), intent).await?; - // A proved read right after the landing proof can lag by a block; poll briefly. - for attempt in 0..6 { - if client - .document_exists(core, step.doc_type(), &intent.document_id) - .await? - { - return Ok(true); - } - if attempt < 5 { - tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + match engine.replay(step.doc_type(), intent).await { + Ok(BroadcastOutcome::Applied | BroadcastOutcome::AlreadyExists) => Ok(true), + Ok(BroadcastOutcome::NonceConsumed) => { + engine + .landed(core, step.doc_type(), &intent.document_id, true) + .await } + Err( + Error::StaleProtocolVersion(_) + | Error::DuplicateUniqueIndex(_) + | Error::NotAMember { .. }, + ) => Ok(false), + Err(e) => Err(e), } - Ok(false) } /// Create (or finish creating) the repository `opts` describes, owned by `identity`. @@ -282,6 +289,7 @@ pub async fn create_repo( network: target.network.key(), })?; let opts = validated(opts)?; + crate::private::for_visibility(opts.visibility)?; let owner = identity.id(); let owner_bytes = platform::decode_identifier(&owner)?; let core = client.fetch_contract(&forge.core).await?; @@ -297,7 +305,6 @@ pub async fn create_repo( // 1. repo let (repo_doc_id, outcome) = run_step( - client, &engine, &core, &mut journal, @@ -310,61 +317,49 @@ pub async fn create_repo( || repo_props(&opts), ) .await?; - steps.push(("repo", outcome)); + steps.push((Step::Repo.doc_type(), outcome)); let repo = find_repo_after_create(client, &forge, owner_bytes, &opts.name, &repo_doc_id).await?; - let repo_id = platform::decode_identifier(repo.id())?; + // An existing repo is adopted only if this client can finish it: a private repo's + // `config` must be sealed, which this version cannot write. + repo.require_readable()?; + let scope = repo.scope()?; // 2. the owner's maintainer document let (_, outcome) = run_step( - client, &engine, &core, &mut journal, Step::Maintainer, || async { Ok(MemberReader::new(client) - .roles_of(&repo, &owner) + .role_doc(&repo, &owner, Role::Maintainer) .await? - .into_iter() - .find(|m| m.role == Role::Maintainer) .map(|m| m.document_id)) }, - || { - repo.scope() - .map(|s| s.props([("memberId", FieldValue::identifier(owner_bytes))])) - .unwrap_or_default() - }, + || scope.props([("memberId", FieldValue::identifier(owner_bytes))]), ) .await?; - steps.push(("maintainer", outcome)); + steps.push((Step::Maintainer.doc_type(), outcome)); // 3. the initial config let (_, outcome) = run_step( - client, &engine, &core, &mut journal, Step::Config, || async { Ok(client - .query_documents( - &core, - DOC_CONFIG, - &[QueryFilter::eq("repoId", FieldValue::identifier(repo_id))], - &[], - 1, - None, - ) + .query_documents(&core, DOC_CONFIG, &scope.filters([]), &[], 1, None) .await? .into_iter() .next() .map(|d| d.id)) }, - || config_props(repo_id, &opts), + || scope.scoped(config_props(&opts)), ) .await?; - steps.push(("config", outcome)); + steps.push((Step::Config.doc_type(), outcome)); journal.finish(); let balance_after = client.get_balance(&owner).await.unwrap_or(balance_before); @@ -377,13 +372,6 @@ pub async fn create_repo( /// `opts` with the name normalized to its slug, or why it cannot be created. fn validated(opts: &CreateRepoOpts) -> Result { - if opts.visibility == Visibility::Private { - return Err(Error::Config( - "private repositories are not supported by this version yet; create a public \ - repository" - .into(), - )); - } if !crate::rules::is_legal_ref_name(&format!("refs/heads/{}", opts.default_branch)) { return Err(Error::Config(format!( "invalid default branch {:?}", @@ -398,7 +386,6 @@ fn validated(opts: &CreateRepoOpts) -> Result { /// One step: replay a saved transition, else adopt an existing document, else sign, save /// and broadcast a new one. Returns the document id and how the step ended. async fn run_step( - client: &PlatformClient, engine: &WriteEngine<'_>, core: &LoadedContract, journal: &mut Journal, @@ -412,7 +399,7 @@ where P: FnOnce() -> BTreeMap, { if let Some(intent) = journal.slot(step).clone() { - if replay_confirmed(client, engine, core, step, &intent).await? { + if replay_landed(engine, core, step, &intent).await? { return Ok((intent.document_id, StepOutcome::Resumed)); } tracing::warn!( @@ -445,7 +432,7 @@ async fn find_repo_after_create( name: &str, expected_id: &str, ) -> Result { - for attempt in 0..6 { + for attempt in 0..FIND_ATTEMPTS { if let Some(repo) = find_v2(client, forge, owner, name).await? { if repo.id() != expected_id { return Err(Error::Platform(format!( @@ -455,8 +442,8 @@ async fn find_repo_after_create( } return Ok(repo); } - if attempt < 5 { - tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + if attempt + 1 < FIND_ATTEMPTS { + tokio::time::sleep(FIND_DELAY).await; } } Err(Error::Platform(format!( @@ -553,8 +540,8 @@ mod tests { let p = repo_props(&opts); assert!(p.contains_key("description") && p.contains_key("displayName")); - let c = config_props([9; 32], &opts); - assert_eq!(c.get("repoId"), Some(&FieldValue::identifier([9; 32]))); + let c = config_props(&opts); + assert!(!c.contains_key("repoId"), "the scope adds repoId"); assert!(!c.contains_key("protectedPatterns")); assert!(matches!(c.get("backend"), Some(FieldValue::Object(b)) if b.contains_key("mode"))); } diff --git a/crates/forge-core/src/error.rs b/crates/forge-core/src/error.rs index 43eb14be1..faf7e69f2 100644 --- a/crates/forge-core/src/error.rs +++ b/crates/forge-core/src/error.rs @@ -128,11 +128,19 @@ pub enum Error { repo: String, }, - /// Consensus refused a write because the writer has no membership document for the - /// repository (protocol 14 `ownerRefersTo`, consensus code 40120). On forge-v2 this is - /// "not a writer/maintainer": never granted, or revoked. - #[error("not a member: {0}")] - NotAMember(String), + /// Consensus refused a write because the writer has no membership document the + /// document type needs (protocol 14 `ownerRefersTo`, consensus code 40120): not a + /// member at all, or a writer where the type is maintainer-only (`protectedRefUpdate`, + /// `config`, `release`). + #[error( + "consensus refused {document_type}: no membership document for your identity ({detail})" + )] + NotAMember { + /// The refused document type. + document_type: String, + /// The consensus error. + detail: String, + }, /// An error surfaced by the Dash Platform SDK (connect, fetch, sign, broadcast). /// diff --git a/crates/forge-core/src/platform.rs b/crates/forge-core/src/platform.rs index f7e040ded..651521099 100644 --- a/crates/forge-core/src/platform.rs +++ b/crates/forge-core/src/platform.rs @@ -1221,6 +1221,7 @@ impl<'a> WriteEngine<'a> { Ok(_proof) => return Ok(BroadcastOutcome::Applied), Err(e) => match classify_write_error(&e, &prepared.document_type) { WriteFailure::AlreadyLanded => return Ok(BroadcastOutcome::AlreadyExists), + WriteFailure::NonceConsumed => return Ok(BroadcastOutcome::NonceConsumed), WriteFailure::Retryable if attempt < MAX_BROADCAST_ATTEMPTS => { // Loop around to re-broadcast the identical signed bytes, after a // backoff so a node whose ban just lapsed is not re-picked at once. @@ -1287,24 +1288,61 @@ impl<'a> WriteEngine<'a> { properties: BTreeMap, mut persist: impl FnMut(&PreparedWrite) -> Result<()>, ) -> Result { - let prepared = self - .prepare_create(contract, document_type, properties.clone()) - .await?; - persist(&prepared)?; - match self.execute(&prepared).await { - Ok(_) => Ok(prepared), - Err(Error::StaleProtocolVersion(reason)) => { - let version = self.client.refresh_protocol_version().await?; - tracing::warn!(%reason, version, "stale protocol version; re-preparing once"); - let prepared = self - .prepare_create(contract, document_type, properties) - .await?; - persist(&prepared)?; - self.execute(&prepared).await?; - Ok(prepared) + // Two retries: a stale protocol version (nothing landed) and a nonce another write by + // this identity took first (ours can then never land). Each re-prepares with a fresh + // nonce and entropy, persisting the replacement before it is broadcast. + for attempt in 0..3 { + let prepared = self + .prepare_create(contract, document_type, properties.clone()) + .await?; + persist(&prepared)?; + match self.execute(&prepared).await { + Ok(BroadcastOutcome::NonceConsumed) => { + if self + .landed(contract, document_type, prepared.document_id(), true) + .await? + { + return Ok(prepared); + } + tracing::warn!( + document_type, + "another write by this identity took the nonce; re-preparing" + ); + } + Ok(_) => return Ok(prepared), + Err(Error::StaleProtocolVersion(reason)) if attempt == 0 => { + let version = self.client.refresh_protocol_version().await?; + tracing::warn!(%reason, version, "stale protocol version; re-preparing once"); + } + Err(e) => return Err(e), } - Err(e) => Err(e), } + Err(Error::Nonce) + } + + /// Whether a document exists (`want = true`) or is gone (`want = false`), polling briefly: + /// a proved read right after a landing can lag a block behind it. + pub async fn landed( + &self, + contract: &LoadedContract, + document_type: &str, + document_id: &str, + want: bool, + ) -> Result { + for attempt in 0..CONFIRM_ATTEMPTS { + if self + .client + .document_exists(contract, document_type, document_id) + .await? + == want + { + return Ok(true); + } + if attempt + 1 < CONFIRM_ATTEMPTS { + tokio::time::sleep(CONFIRM_DELAY).await; + } + } + Ok(false) } /// Re-broadcast a write captured earlier by [`Self::create_journaled`]. The identical @@ -1331,14 +1369,34 @@ impl<'a> WriteEngine<'a> { document_type: &str, document_id: &str, ) -> Result<()> { - let prepared = self - .prepare_delete(contract, document_type, document_id) - .await?; - self.execute(&prepared).await?; - Ok(()) + for _ in 0..2 { + let prepared = self + .prepare_delete(contract, document_type, document_id) + .await?; + match self.execute(&prepared).await? { + BroadcastOutcome::NonceConsumed => { + if self + .landed(contract, document_type, document_id, false) + .await? + { + return Ok(()); + } + tracing::warn!( + document_type, + "another write by this identity took the nonce; re-preparing the delete" + ); + } + _ => return Ok(()), + } + } + Err(Error::Nonce) } } +/// How many proved reads [`WriteEngine::landed`] makes, and the pause between them. +const CONFIRM_ATTEMPTS: usize = 6; +const CONFIRM_DELAY: std::time::Duration = std::time::Duration::from_millis(1500); + /// An SDK-free document field value, converted to the Platform value type inside this /// module. Lets callers build document properties (byteArray / integer / string / /// identifier / nested-object fields) without importing any rs-dpp type (style guide §B). @@ -1793,9 +1851,11 @@ fn select_matching_key(identity: &Identity, signer: &SingleKeySigner) -> Result< /// The classification of a broadcast error, driving the retry loop. enum WriteFailure { - /// The write already landed on-chain (a duplicate re-broadcast: consumed nonce, - /// already-present document, or gRPC AlreadyExists). Idempotent success. + /// The write already landed on-chain (a duplicate re-broadcast: already-present + /// document, or gRPC AlreadyExists). Idempotent success. AlreadyLanded, + /// The nonce was already used: this write landed earlier, or another write took it. + NonceConsumed, /// A transient failure (stale node, timeout, proof mismatch). Safe to re-broadcast /// the same signed bytes — the SDK's authoritative `CanRetry::can_retry()` says so. Retryable, @@ -1845,8 +1905,8 @@ fn classify_write_error(e: &dash_sdk::Error, document_type: &str) -> WriteFailur match state_error { // The document is already present, or the baked nonce was already consumed // by an earlier (identical) broadcast → the intended write has landed. - StateError::DocumentAlreadyPresentError(_) - | StateError::InvalidIdentityNonceError(_) => return WriteFailure::AlreadyLanded, + StateError::DocumentAlreadyPresentError(_) => return WriteFailure::AlreadyLanded, + StateError::InvalidIdentityNonceError(_) => return WriteFailure::NonceConsumed, // A resumed push re-uploading a content-addressed chunk / manifest collides on // its UNIQUE index — the content is already stored, so this is idempotent // success (never charged the storage twice), scoped to those doc types only. @@ -1872,11 +1932,13 @@ fn classify_write_error(e: &dash_sdk::Error, document_type: &str) -> WriteFailur return WriteFailure::Fatal(Error::Unauthorized) } // 40120 on the writer path: a protocol-14 `ownerRefersTo` gate found no - // membership document for the writer (forge-v2: never granted, or revoked). + // membership document for the writer (forge-v2: never granted, or revoked; or a + // writer where the type needs a maintainer). StateError::ReferencedEntityNotFoundError(err) if err.path() == "$ownerId" => { - return WriteFailure::Fatal(Error::NotAMember(format!( - "consensus refused {document_type} (40120: {err})" - ))) + return WriteFailure::Fatal(Error::NotAMember { + document_type: document_type.to_string(), + detail: format!("40120: {err}"), + }) } _ => {} } @@ -2013,6 +2075,12 @@ pub enum BroadcastOutcome { Applied, /// The transition already existed on-chain — treated as success (idempotency). AlreadyExists, + /// Platform refused the transition's nonce as already used. That is this very transition + /// having landed earlier (a re-broadcast) OR another write by the same identity having + /// taken the nonce first — on forge-v2 every write an identity makes shares one nonce + /// counter, so concurrent processes can collide. The caller must check which: + /// [`WriteEngine::create_journaled`] and [`WriteEngine::delete_document`] do. + NonceConsumed, } /// The durable idempotent-retry intent: "I intend to broadcast *these* exact signed diff --git a/crates/forge-core/src/repo.rs b/crates/forge-core/src/repo.rs index f8d56bbe1..a13e5823b 100644 --- a/crates/forge-core/src/repo.rs +++ b/crates/forge-core/src/repo.rs @@ -296,6 +296,8 @@ pub struct RepackReport { pub struct ReseedReport { /// Every pack re-uploaded. pub reseeded: Vec, + /// Packs with no readable copy (not reseeded; `dg reseed --from-local` can restore them). + pub unreadable: Vec<[u8; 32]>, } /// One pack [`RepoService::reseed`] re-uploaded. @@ -472,6 +474,13 @@ impl<'a> RepoService<'a> { Ok(out) } + /// The protected-ref globs in force now (the newest `config`). + pub async fn protected_patterns(&self, repo: &RepoRef) -> Result> { + let (scope, contract) = self.readable(repo).await?; + let configs = self.fetch_config_history(&scope, &contract).await?; + Ok(current_protected_patterns(&configs)) + } + /// The newest `config` document in `scope`, if any. async fn newest_config( &self, @@ -1015,9 +1024,18 @@ impl<'a> RepoService<'a> { let reader = PackReader::from_user_config(); let mut report = ReseedReport::default(); for (hash, copies) in group_by_hash(&git) { - let (bytes, best) = self + let (bytes, best) = match self .fetch_best_copy(repo, &contract, &copies, &roles, &reader) - .await?; + .await + { + Ok(got) => got, + Err(e) => { + // One unreadable pack must not stop the others from being reseeded. + tracing::warn!(pack = %hex::encode(hash), error = %e, "no readable copy; skipping"); + report.unreadable.push(hash); + continue; + } + }; let meta = PackMeta::for_bytes(&bytes); let uris = uri_strings(target.put(&bytes, &meta).await?); let announced = if copies.iter().any(|m| m.owner_id == me) { diff --git a/crates/forge-core/src/resolve.rs b/crates/forge-core/src/resolve.rs index b7d9d96e5..55637f03f 100644 --- a/crates/forge-core/src/resolve.rs +++ b/crates/forge-core/src/resolve.rs @@ -124,8 +124,10 @@ async fn find_v1( Ok(Some(RepoRef::V1 { contract_id: platform::encode_identifier(contract), owner_id: owner.to_string(), + // The slug (`normalizedName`), not the free-form display name: it is what + // `dash://owner/` resolves by. name: listing - .field_str("name") + .field_str("normalizedName") .unwrap_or_else(|| slug.to_string()), })) } diff --git a/crates/forge-core/src/user_error.rs b/crates/forge-core/src/user_error.rs index e0b10117a..321e9d87c 100644 --- a/crates/forge-core/src/user_error.rs +++ b/crates/forge-core/src/user_error.rs @@ -533,7 +533,13 @@ fn from_core(core: &CoreError, chain: &str, ctx: &ErrorContext<'_>) -> Option suspended(ctx, &format!("40702 {core}")), - CoreError::NotAMember(detail) => not_a_writer(ctx, detail), + CoreError::NotAMember { + document_type, + detail, + } if MAINTAINER_ONLY.contains(&document_type.as_str()) => { + needs_maintainer(ctx, document_type, detail) + } + CoreError::NotAMember { detail, .. } => not_a_writer(ctx, detail), CoreError::V1ReadOnly { repo } => UserError::new( codes::READ_ONLY, ctx.headline(&format!("{repo} is a v1 repository, which is read only")), @@ -762,6 +768,33 @@ fn suspended(ctx: &ErrorContext<'_>, why: &str) -> UserError { )) } +/// forge-core document types only a `maintainer` may create (forge-v2.md §2). +const MAINTAINER_ONLY: [&str; 4] = ["protectedRefUpdate", "config", "release", "repoKey"]; + +/// E601 for a maintainer-only write by someone who is not a maintainer (possibly a writer). +fn needs_maintainer(ctx: &ErrorContext<'_>, document_type: &str, why: &str) -> UserError { + let repo = ctx.repo_or("/"); + let what = match document_type { + "protectedRefUpdate" => "a protected ref", + "config" => "the repository's configuration", + _ => "this", + }; + UserError::new( + codes::NOT_A_WRITER, + ctx.rejected_headline(&format!( + "only maintainers of {} can change {what}", + ctx.repo_or("this repo") + )), + ) + .cause(format!( + "Platform refused the {document_type} at consensus ({why})" + )) + .fix(format!( + "ask the owner to run `dg collab add {repo} --role maintainer`" + )) + .fix("or push to a branch that is not protected") +} + fn not_a_writer(ctx: &ErrorContext<'_>, why: &str) -> UserError { let repo = ctx.repo_or("/"); // "Not a writer" is what a refused push means. Other writes (collab admin, releases, @@ -776,7 +809,7 @@ fn not_a_writer(ctx: &ErrorContext<'_>, why: &str) -> UserError { ) .cause(format!("Platform refused the write at consensus ({why})")) .fix(format!( - "ask a maintainer of {repo} to grant you the role this needs (`dg collab add {repo} --role write|maintain`)" + "ask the owner of {repo} to grant you the role this needs (`dg collab add {repo} --role writer|maintainer`)" )); } UserError::new( @@ -788,11 +821,9 @@ fn not_a_writer(ctx: &ErrorContext<'_>, why: &str) -> UserError { ) .cause(format!("Platform refused the write at consensus ({why})")) .fix(format!( - "ask the owner to run `dg collab add {repo} --role write`" - )) - .fix(format!( - "push to a repo of your own and open a pull request: `dg pr create {repo} --title --source-contract --head-oid `" + "ask the owner to run `dg collab add {repo} --role writer`" )) + .fix("push to a repo of your own: `dg repo create `, then `git push dash:/// `") } fn timed_out(ctx: &ErrorContext<'_>, retryable: bool) -> UserError { @@ -1343,6 +1374,58 @@ mod tests { /// `ownerRefersTo` gate reported on path `$ownerId`, inside the broadcast error. const V2_GATE_40120: &str = "state transition broadcast error: referenced deletable document (own contract, document type writer, found through unique index byRepoAndMember) 5DtbWjpyYyNtMd3FBwyXGr3NTZzGUBGHnPHM3gs6ndmQ not found for path $ownerId"; + #[test] + fn a_typed_40120_on_a_maintainer_only_type_asks_for_maintainer() { + // A writer pushing a protected ref: `protectedRefUpdate` is maintainer-only, so + // "you are not a writer" would be false and `--role writer` would not help. + let u = core_chain( + CoreError::NotAMember { + document_type: "protectedRefUpdate".into(), + detail: "40120: …".into(), + }, + &PUSH, + ); + assert_eq!(u.code, "E601"); + assert_eq!( + u.message, + "push rejected: only maintainers of alice/project can change a protected ref" + ); + assert!(u.fix[0].contains("--role maintainer"), "{u:?}"); + + // Any other gated type: not a member at all. + let u = core_chain( + CoreError::NotAMember { + document_type: "refUpdate".into(), + detail: "40120: …".into(), + }, + &PUSH, + ); + assert_eq!( + u.message, + "push rejected: you are not a writer of alice/project" + ); + assert!(u.fix[0].contains("--role writer"), "{u:?}"); + } + + #[test] + fn v1_writes_and_undeployed_v2_have_their_own_codes() { + let u = core_chain( + CoreError::V1ReadOnly { + repo: "alice/old".into(), + }, + &PUSH, + ); + assert_eq!((u.code, u.exit_code()), ("E605", 6)); + let u = core_chain( + CoreError::V2NotDeployed { + network: "testnet".into(), + }, + &PUSH, + ); + assert_eq!(u.code, "E702"); + assert!(u.fix[0].contains("--devnet-name moutai"), "{u:?}"); + } + #[test] fn maps_v2_writer_gate_40120_to_e601() { let u = core_chain(CoreError::Platform(V2_GATE_40120.into()), &PUSH); diff --git a/crates/forge-core/tests/repo_lifecycle.rs b/crates/forge-core/tests/repo_lifecycle.rs index 1795b2e7a..9158e5a75 100644 --- a/crates/forge-core/tests/repo_lifecycle.rs +++ b/crates/forge-core/tests/repo_lifecycle.rs @@ -210,7 +210,7 @@ async fn forge_v2_repo_lifecycle_on_moutai() { .await .expect_err("revoked writer must be refused"); println!("revoked writer: {err}"); - assert!(matches!(err, Error::NotAMember(_)), "{err}"); + assert!(matches!(err, Error::NotAMember { .. }), "{err}"); assert!(err.to_string().contains("40120"), "{err}"); // --- 5. never a member --- @@ -218,7 +218,7 @@ async fn forge_v2_repo_lifecycle_on_moutai() { .write_ref_update(&repo, "refs/heads/contrib", &[0x44; 20], None, false) .await .expect_err("non-member must be refused"); - assert!(matches!(err, Error::NotAMember(_)), "{err}"); + assert!(matches!(err, Error::NotAMember { .. }), "{err}"); let after = client.get_balance(&owner.id()).await.unwrap(); println!("OWNER balance now {after} credits"); diff --git a/crates/git-remote-dash/src/helper.rs b/crates/git-remote-dash/src/helper.rs index d27b255bc..7af4f13cf 100644 --- a/crates/git-remote-dash/src/helper.rs +++ b/crates/git-remote-dash/src/helper.rs @@ -269,12 +269,23 @@ impl Helper { let repo = &conn.repo; let contract = &svc.repo_contract(repo).await?; let reader = &PackReader::from_user_config(); - let roles = &svc.copy_roles(repo).await?; + // Membership only ranks copies; if it cannot be read, fall back to time order + // rather than failing the clone (every copy is still hash-verified). + let roles = &svc.copy_roles(repo).await.unwrap_or_else(|e| { + tracing::warn!(error = %e, "could not read the member list; trying pack copies in time order"); + forge_core::repo::RoleMap::new() + }); let packs = group_by_hash(&git_packs); let fetched: Vec>> = stream::iter(packs.iter().map(|(h, copies)| async move { let hash = hex::encode(h); let got = svc.fetch_best_copy(repo, contract, copies, roles, reader).await; - let on_chain = copies.iter().any(|m| m.storage == 0); + // A pack is required when a CURRENT MEMBER recorded it on Platform: on forge-v2 + // anyone who was a writer can post a manifest, so a stranger's chunkless + // `storage = 0` copy must not turn an unreadable pack into a failed clone (git's + // connectivity check still fails the fetch if a wanted object was in it). + let on_chain = copies.iter().any(|m| { + m.storage == 0 && (repo.is_v1() || roles.contains_key(&m.owner_id)) + }); let bytes = match got { Ok((bytes, _)) => bytes, Err(e) if on_chain => { @@ -371,7 +382,7 @@ impl Helper { // `DASH_FORGE_SKIP_WRITE_PRECHECK=1` skips the check, so a caller that needs to see // what consensus itself does with an unauthorized push (the e2e ACL scenarios) can. if !dry_run && specs.iter().any(|s| !s.src.is_empty()) && !skip_write_precheck() { - if let Some(denied) = write_access_denied(conn).await { + if let Some(denied) = write_access_denied(conn, &svc, specs).await { // The block says why and what to do; git's own `! [remote rejected]` lines // (from the per-ref reason) make the push fail. denied.error.eprint("dash: "); @@ -715,22 +726,19 @@ async fn upload_push_pack( policy::NOTE_NOTHING_STORED, )?; - // An earlier push may already have recorded this exact pack (unique packHash; a - // duplicate manifest create is treated as "already stored"). Decide BEFORE paying: - // still readable → nothing to store; unreadable → refuse now, not after new copies. - let copies = ctx - .svc - .read_pack_copies(ctx.repo, job.pack_hash) - .await - .context("checking for an existing manifest of this pack")?; - if !copies.is_empty() { + // An earlier push may already have recorded this exact pack. Decide BEFORE paying. + if already_recorded(ctx, &job).await? { // The browse index is left alone: the earlier push published (or tried to) the // fragment for this pack, and a missing one is rebuilt by the next repack. - confirm_existing_manifest(ctx, &job, &copies).await?; return Ok(Some(policy::estimate_ref_updates(ctx.refs.len()))); } - let jpath = crate::journal::journal_path(ctx.git_dir, &job.meta.pack_hash); + let jpath = crate::journal::journal_path( + ctx.git_dir, + ctx.repo.id(), + &ctx.identity, + &job.meta.pack_hash, + ); let replication = store_pack(ctx, &job, &externals, &jpath).await?; let stored_on_platform = replication.has_platform(); let actual_estimate = job.estimate(ctx, stored_on_platform); @@ -980,6 +988,34 @@ async fn record_pack( Ok(()) } +/// Whether this pack is already recorded and readable, so the push stores nothing. `false` +/// when no manifest names it, or when none is readable and none is this identity's: forge-v2 +/// gives every uploader its own manifest slot (the pack indexes include `$ownerId`) so that +/// nobody's dead or hostile copy can block an honest upload, and this push stores its own. +/// This identity's own unreadable copy cannot be replaced (its slot is taken), so that case +/// refuses, pointing at `dg reseed --from-local`. +async fn already_recorded(ctx: &PushContext<'_>, job: &PackJob<'_>) -> Result { + let copies = ctx + .svc + .read_pack_copies(ctx.repo, job.pack_hash) + .await + .context("checking for an existing manifest of this pack")?; + if copies.is_empty() { + return Ok(false); + } + match confirm_existing_manifest(ctx, job, &copies).await { + Ok(()) => Ok(true), + Err(e) if !copies.iter().any(|m| m.owner_id == ctx.identity) => { + ctx.say(&format!( + "no recorded copy of pack {} is readable ({e:#}); storing this push's own copy", + &job.meta.pack_hash[..12] + )); + Ok(false) + } + Err(e) => Err(e), + } +} + /// This pack already has a manifest (an earlier push recorded it). Accept it only if at /// least one recorded copy is readable and hash-matches; otherwise refuse — before this /// push pays for anything — naming the dead copies and the way back. @@ -1227,16 +1263,57 @@ struct Denied { /// Returns `None` — i.e. proceed — when membership cannot be determined. The read is /// advisory: consensus (`ownerRefersTo`, 40120) is the authority, and a transient read /// failure must not block a push a member is entitled to make. -async fn write_access_denied(conn: &Conn) -> Option { +async fn write_access_denied( + conn: &Conn, + svc: &RepoService<'_>, + specs: &[PushSpec], +) -> Option { let me = conn.identity.id(); let members = MemberReader::new(&conn.client) .roles_of(&conn.repo, &me) .await .ok()?; - if !members.is_empty() { + if members.is_empty() { + return Some(write_denied(&conn.repo.display(), &me)); + } + // A writer (not a maintainer) cannot update a protected ref: its `protectedRefUpdate` + // is maintainer-only at consensus. Refuse before the pack is stored and paid for. + if members + .iter() + .any(|m| m.role == forge_core::rules::v2::Role::Maintainer) + { + return None; + } + let patterns = svc.protected_patterns(&conn.repo).await.ok()?; + let protected: Vec<&str> = specs + .iter() + .map(|s| s.dst.as_str()) + .filter(|d| forge_core::rules::matches_protected(d, &patterns)) + .collect(); + if protected.is_empty() { return None; } - Some(write_denied(&conn.repo.display(), &me)) + Some(protected_denied(&conn.repo.display(), &me, &protected)) +} + +/// The push refusal for a writer updating a protected ref. +fn protected_denied(repo: &str, me: &str, refs: &[&str]) -> Denied { + Denied { + error: UserError::new( + codes::NOT_A_WRITER, + format!( + "push rejected: only maintainers of {repo} can update {}", + refs.join(", ") + ), + ) + .cause("the ref matches the repo's protected patterns, and you are a writer") + .fix(format!( + "ask the owner to run `dg collab add {repo} {me} --role maintainer`" + )) + .fix("or push to a branch that is not protected") + .note(NOTE_PRECHECK), + wire: "protected ref: maintainers only", + } } /// The push refusal for an identity that is not a member. @@ -1286,9 +1363,22 @@ fn resolve_key_path(url: &DashUrl) -> Result { #[cfg(test)] mod tests { - use super::{oid_to_bytes, resolve_network, write_denied, PushOutcome}; + use super::{oid_to_bytes, protected_denied, resolve_network, write_denied, PushOutcome}; use forge_core::network::NetworkSettings; + #[test] + fn a_writer_on_a_protected_ref_is_told_it_needs_maintainer() { + let d = protected_denied("owner/repo", "me", &["refs/heads/main"]); + let text = d.error.render("dash: ", false); + assert!( + text.contains("only maintainers of owner/repo can update refs/heads/main"), + "{text}" + ); + assert!(text.contains("--role maintainer"), "{text}"); + // Local refusal, not a consensus verdict. + assert!(text.contains("checked before building or paying"), "{text}"); + } + #[test] fn a_non_member_is_pointed_at_collab_add() { let d = write_denied("owner/repo", "me"); diff --git a/crates/git-remote-dash/src/journal.rs b/crates/git-remote-dash/src/journal.rs index a92038f23..385b1349c 100644 --- a/crates/git-remote-dash/src/journal.rs +++ b/crates/git-remote-dash/src/journal.rs @@ -11,12 +11,16 @@ use std::path::{Path, PathBuf}; use forge_core::platform::{JournalStore, PushJournal}; use forge_core::{Error, Result}; -/// The journal path for a pack under a repo's `GIT_DIR`. -pub fn journal_path(git_dir: &Path, pack_hash_hex: &str) -> PathBuf { +/// The journal path for one uploader's chunks of a pack in one repository, under the +/// clone's `GIT_DIR`. The repository and the uploader are part of the key because a chunk is +/// keyed by them on forge-v2 (`(repoId, $ownerId, packHash, seq)`): a journal from a push to +/// another repo, or by another identity, names chunks this push has not stored, and +/// resuming from it would write a manifest over missing chunks. +pub fn journal_path(git_dir: &Path, repo_id: &str, uploader: &str, pack_hash_hex: &str) -> PathBuf { git_dir .join("dash") .join("journal") - .join(format!("{pack_hash_hex}.json")) + .join(format!("{repo_id}-{uploader}-{pack_hash_hex}.json")) } /// Load an existing journal for `pack_hash_hex`, or start a fresh one. A journal whose @@ -61,7 +65,7 @@ impl JournalStore for FileJournalStore { #[cfg(test)] mod tests { - use super::{load_or_new, FileJournalStore}; + use super::{journal_path, load_or_new, FileJournalStore}; use forge_core::platform::{JournalStore, PushJournal, WriteIntent, WriteOp}; fn intent(seq: u32) -> WriteIntent { @@ -99,6 +103,18 @@ mod tests { assert!(!resumed.is_complete()); } + #[test] + fn journals_are_per_repo_and_uploader() { + // A v2 chunk is keyed (repoId, $ownerId, packHash, seq): a journal of the same pack + // pushed to another repo, or by another identity, names chunks this push has not + // stored, and must not be resumed. + let g = std::path::Path::new("/g"); + let base = journal_path(g, "R1", "alice", "p"); + assert_ne!(base, journal_path(g, "R2", "alice", "p")); + assert_ne!(base, journal_path(g, "R1", "bob", "p")); + assert_ne!(base, journal_path(g, "R1", "alice", "q")); + } + #[test] fn mismatched_pack_hash_starts_fresh() { let dir = tempfile::tempdir().unwrap(); From 5578d5f53adf7586e5a49db42d3a7291ef024151 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 25 Sep 2026 13:24:23 -0500 Subject: [PATCH 6/6] fix(helper): refuse only the protected refs of a writer's push; longer landing check Re-review follow-ups: a writer's push refuses just the refs matching protected patterns (deletes included) and pushes the rest; the proved-read check after a consumed nonce waits ~15 s before signing a replacement; the maintainer-only error suggests an unprotected branch only for refs. The live lifecycle test now covers a writer on a protected ref (typed protectedRefUpdate refusal) and an unprotected one (lands). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/forge-core/src/platform.rs | 20 ++++--- crates/forge-core/src/user_error.rs | 10 ++-- crates/forge-core/tests/repo_lifecycle.rs | 64 +++++++++++++++++++++++ crates/git-remote-dash/src/helper.rs | 53 +++++++++++++++---- e2e/cli/storage-byo.sh | 2 +- 5 files changed, 126 insertions(+), 23 deletions(-) diff --git a/crates/forge-core/src/platform.rs b/crates/forge-core/src/platform.rs index 651521099..f173bf3c7 100644 --- a/crates/forge-core/src/platform.rs +++ b/crates/forge-core/src/platform.rs @@ -17,8 +17,10 @@ //! [`WriteEngine::prepare_delete`], capturing a fixed nonce + entropy into a //! [`SignedTransition`]); [`WriteEngine::execute`] broadcasts those exact bytes and, //! on a retryable failure, RE-broadcasts the *same* bytes. A duplicate landing -//! ("already exists" / consumed nonce) is reported as [`BroadcastOutcome::AlreadyExists`], -//! never a fresh write — so a killed-mid-push retry cannot double-spend or duplicate. +//! ("already exists") is reported as [`BroadcastOutcome::AlreadyExists`], never a fresh +//! write — so a killed-mid-push retry cannot double-spend or duplicate. A consumed nonce is +//! [`BroadcastOutcome::NonceConsumed`]: the write landed earlier OR another write took the +//! nonce, and the create/delete helpers confirm which with a proved read. //! The SDK's `broadcast_and_wait` works on NATIVE Rust — the `waitForResponse` panic //! in the spikes is WASM-only (`time not implemented`); native tokio has a timer. //! - [`PushJournal`] / [`WriteIntent`] / [`JournalStore`] — the resumable-push record + @@ -1184,10 +1186,11 @@ impl<'a> WriteEngine<'a> { /// Broadcast a [`PreparedWrite`]'s signed bytes and wait for the confirmation proof, /// re-broadcasting the **identical** bytes on a retryable failure. /// - /// Returns [`BroadcastOutcome::Applied`] on a fresh landing, or - /// [`BroadcastOutcome::AlreadyExists`] when the write had already landed (a consumed - /// nonce / already-present document / gRPC AlreadyExists) — the idempotency guarantee - /// that a killed-and-retried push does not double-write. + /// Returns [`BroadcastOutcome::Applied`] on a fresh landing, + /// [`BroadcastOutcome::AlreadyExists`] when the document is already present (gRPC + /// AlreadyExists / already-present document) — the idempotency guarantee that a + /// killed-and-retried push does not double-write — or [`BroadcastOutcome::NonceConsumed`] + /// when the nonce is spent, which the caller must disambiguate. /// /// **indexOnly types (protocol 14, forge-v2 `star` / `follow`):** their proofs only /// attest the resulting state, so a create that finds an identical entry already present @@ -1393,8 +1396,9 @@ impl<'a> WriteEngine<'a> { } } -/// How many proved reads [`WriteEngine::landed`] makes, and the pause between them. -const CONFIRM_ATTEMPTS: usize = 6; +/// How many proved reads [`WriteEngine::landed`] makes, and the pause between them (about +/// 15 s: a re-broadcast of our own landed bytes must be seen before a replacement is signed). +const CONFIRM_ATTEMPTS: usize = 10; const CONFIRM_DELAY: std::time::Duration = std::time::Duration::from_millis(1500); /// An SDK-free document field value, converted to the Platform value type inside this diff --git a/crates/forge-core/src/user_error.rs b/crates/forge-core/src/user_error.rs index 321e9d87c..5c13311b8 100644 --- a/crates/forge-core/src/user_error.rs +++ b/crates/forge-core/src/user_error.rs @@ -779,7 +779,7 @@ fn needs_maintainer(ctx: &ErrorContext<'_>, document_type: &str, why: &str) -> U "config" => "the repository's configuration", _ => "this", }; - UserError::new( + let u = UserError::new( codes::NOT_A_WRITER, ctx.rejected_headline(&format!( "only maintainers of {} can change {what}", @@ -791,8 +791,12 @@ fn needs_maintainer(ctx: &ErrorContext<'_>, document_type: &str, why: &str) -> U )) .fix(format!( "ask the owner to run `dg collab add {repo} --role maintainer`" - )) - .fix("or push to a branch that is not protected") + )); + if document_type == "protectedRefUpdate" { + u.fix("or push to a branch that is not protected") + } else { + u + } } fn not_a_writer(ctx: &ErrorContext<'_>, why: &str) -> UserError { diff --git a/crates/forge-core/tests/repo_lifecycle.rs b/crates/forge-core/tests/repo_lifecycle.rs index 9158e5a75..5216ca9f4 100644 --- a/crates/forge-core/tests/repo_lifecycle.rs +++ b/crates/forge-core/tests/repo_lifecycle.rs @@ -213,6 +213,70 @@ async fn forge_v2_repo_lifecycle_on_moutai() { assert!(matches!(err, Error::NotAMember { .. }), "{err}"); assert!(err.to_string().contains("40120"), "{err}"); + // --- 4b. protected refs are maintainer-only --- + // Protect refs/heads/main (owner = maintainer), re-grant COLLAB as a writer: COLLAB may + // update other refs but its update of main is refused at consensus with the typed + // "maintainer-only" refusal. + let core = client + .fetch_contract( + &forge_core::network::NetworkSettings { + devnet_name: Some("moutai".into()), + ..Default::default() + } + .resolve() + .unwrap() + .v2 + .unwrap() + .core, + ) + .await + .unwrap(); + let mut backend = std::collections::BTreeMap::new(); + backend.insert("mode".into(), forge_core::platform::FieldValue::integer(0)); + let props = repo.scope().unwrap().props([ + ( + "defaultBranch", + forge_core::platform::FieldValue::text("main"), + ), + ( + "protectedPatterns", + forge_core::platform::FieldValue::text_list(["refs/heads/main"]), + ), + ("backend", forge_core::platform::FieldValue::Object(backend)), + ]); + forge_core::platform::WriteEngine::new(&client, &owner, owner_b.doc_op_key().unwrap()) + .unwrap() + .create_document(&core, "config", props) + .await + .expect("protect main"); + for _ in 0..8 { + if svc.protected_patterns(&repo).await.unwrap() == ["refs/heads/main"] { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + } + members + .grant(&repo, &collab.id(), Role::Writer) + .await + .expect("re-grant"); + let err = collab_svc + .write_ref_update(&repo, "refs/heads/main", &[0x55; 20], None, false) + .await + .expect_err("a writer cannot update a protected ref"); + println!("writer on protected main: {err}"); + assert!( + matches!(&err, Error::NotAMember { document_type, .. } if document_type == "protectedRefUpdate"), + "{err}" + ); + collab_svc + .write_ref_update(&repo, "refs/heads/feature", &[0x66; 20], None, false) + .await + .expect("a writer can update an unprotected ref"); + assert!(members + .revoke(&repo, &collab.id(), Role::Writer) + .await + .unwrap()); + // --- 5. never a member --- let err = RepoService::new(&client, &contrib, &contrib_b) .write_ref_update(&repo, "refs/heads/contrib", &[0x44; 20], None, false) diff --git a/crates/git-remote-dash/src/helper.rs b/crates/git-remote-dash/src/helper.rs index 7af4f13cf..ecbbd49fd 100644 --- a/crates/git-remote-dash/src/helper.rs +++ b/crates/git-remote-dash/src/helper.rs @@ -381,17 +381,17 @@ impl Helper { // // `DASH_FORGE_SKIP_WRITE_PRECHECK=1` skips the check, so a caller that needs to see // what consensus itself does with an unauthorized push (the e2e ACL scenarios) can. - if !dry_run && specs.iter().any(|s| !s.src.is_empty()) && !skip_write_precheck() { - if let Some(denied) = write_access_denied(conn, &svc, specs).await { - // The block says why and what to do; git's own `! [remote rejected]` lines - // (from the per-ref reason) make the push fail. - denied.error.eprint("dash: "); - return Ok(specs - .iter() - .map(|s| PushOutcome::Error(s.dst.clone(), denied.wire.to_string())) - .collect()); + let mut refused = Vec::new(); + let kept; + let specs = if !dry_run && !specs.is_empty() && !skip_write_precheck() { + (refused, kept) = precheck(conn, &svc, specs).await; + if kept.is_empty() { + return Ok(refused); } - } + &kept[..] + } else { + specs + }; let planned = plan_pushes(specs, &remote_refs); let progress = Progress::new(options.verbosity); @@ -478,7 +478,8 @@ impl Helper { self.report_done(progress, balance_before, est_credits) .await; } - Ok(outcomes) + refused.extend(outcomes); + Ok(refused) } /// The summary line with actuals: the balance change is what this push cost (≈: other @@ -1239,6 +1240,31 @@ fn skip_write_precheck() -> bool { ) } +/// The advisory write pre-check (see [`write_access_denied`]): the refused refs' outcomes +/// and the specs still to push. A non-member is refused everything; a writer only the +/// protected refs. +async fn precheck( + conn: &Conn, + svc: &RepoService<'_>, + specs: &[PushSpec], +) -> (Vec, Vec) { + let Some(denied) = write_access_denied(conn, svc, specs).await else { + return (Vec::new(), specs.to_vec()); + }; + // The block says why and what to do; git's own `! [remote rejected]` lines (from the + // per-ref reason) make the push fail. + denied.error.eprint("dash: "); + let (refused, allowed): (Vec, Vec) = specs + .iter() + .cloned() + .partition(|s| denied.refs.is_empty() || denied.refs.contains(&s.dst)); + let refused = refused + .into_iter() + .map(|s| PushOutcome::Error(s.dst, denied.wire.to_string())) + .collect(); + (refused, allowed) +} + /// The note on a push the helper refused before doing anything. const NOTE_PRECHECK: &str = "checked before building or paying for anything: nothing was stored"; @@ -1255,6 +1281,8 @@ fn no_identity(why: impl Into) -> anyhow::Error { struct Denied { error: UserError, wire: &'static str, + /// The refs refused; empty means the whole push. + refs: Vec, } /// `Some(refusal)` when the connected identity provably is not a member (no `writer` or @@ -1285,6 +1313,7 @@ async fn write_access_denied( return None; } let patterns = svc.protected_patterns(&conn.repo).await.ok()?; + // Deletes too: a delete of a protected ref is a `protectedRefUpdate`. let protected: Vec<&str> = specs .iter() .map(|s| s.dst.as_str()) @@ -1313,6 +1342,7 @@ fn protected_denied(repo: &str, me: &str, refs: &[&str]) -> Denied { .fix("or push to a branch that is not protected") .note(NOTE_PRECHECK), wire: "protected ref: maintainers only", + refs: refs.iter().map(|r| (*r).to_string()).collect(), } } @@ -1333,6 +1363,7 @@ fn write_denied(repo: &str, me: &str) -> Denied { .fix("push to a repo of your own: `dg repo create `, then `git push dash:/// `") .note(NOTE_PRECHECK), wire: "not a writer of this repo", + refs: Vec::new(), } } diff --git a/e2e/cli/storage-byo.sh b/e2e/cli/storage-byo.sh index 77d4758ba..ce83b8cfe 100644 --- a/e2e/cli/storage-byo.sh +++ b/e2e/cli/storage-byo.sh @@ -227,7 +227,7 @@ if [[ -n "$PACK5" && -n "$OBJ_DIR" ]]; then else check "5b: error says the pack is already recorded and unreachable" \ assert_file_contains "$LOG-push-5b.err" "already recorded at" - check "5b: error points at dg reseed --from-local" assert_file_contains "$LOG-push-5b.err" "reseed --from-local" + check "5b: error points at dg reseed --from-local" grep -qE 'dg reseed [^ ]+ --from-local|reseed --from-local' "$LOG-push-5b.err" check "5b: nothing was stored first" assert_not_file_contains "$LOG-push-5b.err" "verified)" fi DASH_FORGE_KEY="$ID_DEPLOYER" git ls-remote "$REMOTE_B" "refs/heads/${BR5}" >"$LOG-lsremote5.out" 2>/dev/null