From fcb6706800c34941748c3f7b424a7a3600980a72 Mon Sep 17 00:00:00 2001 From: Garrett Maring Date: Thu, 21 May 2026 11:27:15 -0300 Subject: [PATCH] V29 Gate 7: Close organization interface authority Adds package-owned BTD organization/interface authority decisions with holdings, read-license usage, source visibility, and proof roots. Wires JSON-safe API/UAPI decisions, MCP required interface authority, ChatGPT App write admission, sandbox harness evidence, and Terminal Authority detail projection. Updates V29 spec/parity/docs, gate-quality CI, Gate 7 checker, lockfile, and focused BTD/API/MCP/ChatGPT/pipeline-host/UAPI coverage. --- .github/workflows/bitcode-gate-quality.yml | 7 + BITCODE_SPEC_V29.md | 34 + BITCODE_SPEC_V29_DELTA.md | 11 + BITCODE_SPEC_V29_NOTES.md | 20 + BITCODE_SPEC_V29_PARITY_MATRIX.md | 38 +- package.json | 1 + .../src/routes/__tests__/btd-crypto.test.ts | 95 +++ packages/api/src/routes/btd-crypto.ts | 74 +++ packages/btd/__tests__/btd.test.ts | 140 +++++ packages/btd/src/authority.ts | 595 ++++++++++++++++++ packages/btd/src/index.ts | 1 + packages/chatgptapp/jest.config.cjs | 1 + packages/chatgptapp/package.json | 4 +- .../chatgptapp/src/__tests__/tools.test.ts | 57 ++ packages/chatgptapp/src/tools.ts | 179 +++++- packages/chatgptapp/tsconfig.json | 3 +- packages/chatgptapp/tsconfig.test.json | 1 + .../src/mcp-server/jest.config.mcp.js | 75 +++ .../src/__tests__/unit/auth.test.ts | 159 +++++ .../src/mcp-server/src/auth/middleware.ts | 130 +++- .../src/mcp-server/src/types/index.ts | 8 + .../src/__tests__/asset-pack-harness.test.ts | 4 + .../pipeline-hosts/src/asset-pack-harness.ts | 49 +- pnpm-lock.yaml | 14 +- ...ate7-organization-permission-authority.mjs | 257 ++++++++ .../organization-interface-authority/route.ts | 3 + uapi/app/terminal/README.md | 8 + .../TerminalTransactionDetailActionBar.tsx | 1 + .../TerminalTransactionDetailSurface.tsx | 13 + ...alTransactionOrganizationAuthorityCard.tsx | 66 ++ .../terminal-organization-authority.ts | 138 ++++ .../terminal/terminal-protocol-projection.ts | 1 + .../terminal-transaction-detail-snapshot.ts | 23 + .../terminal/terminal-transaction-query.ts | 2 + .../terminal-transaction-read-model.ts | 21 + uapi/jest.config.cjs | 1 + .../terminalJournalReconciliation.test.ts | 1 + .../terminalOrganizationAuthority.test.ts | 74 +++ ...erminalTransactionActivitySurface.test.tsx | 1 + uapi/tests/terminalTransactionDetail.test.ts | 1 + .../terminalTransactionDetailCards.test.tsx | 40 ++ .../terminalTransactionDetailSnapshot.test.ts | 23 + .../terminalTransactionReadModel.test.ts | 1 + uapi/tests/terminalWalletBtcOperation.test.ts | 1 + 44 files changed, 2339 insertions(+), 37 deletions(-) create mode 100644 packages/btd/src/authority.ts create mode 100644 packages/executions-mcp/src/mcp-server/jest.config.mcp.js create mode 100644 scripts/check-v29-gate7-organization-permission-authority.mjs create mode 100644 uapi/app/api/btd/organization-interface-authority/route.ts create mode 100644 uapi/app/terminal/TerminalTransactionOrganizationAuthorityCard.tsx create mode 100644 uapi/app/terminal/terminal-organization-authority.ts create mode 100644 uapi/tests/terminalOrganizationAuthority.test.ts diff --git a/.github/workflows/bitcode-gate-quality.yml b/.github/workflows/bitcode-gate-quality.yml index 4a78cf43a..3c87202f0 100644 --- a/.github/workflows/bitcode-gate-quality.yml +++ b/.github/workflows/bitcode-gate-quality.yml @@ -68,6 +68,7 @@ jobs: node scripts/check-v29-gate4-reading-pipeline-observability.mjs --skip-branch-check node scripts/check-v29-gate5-assetpack-disclosure-rights.mjs --skip-branch-check node scripts/check-v29-gate6-settlement-reconciliation-repair.mjs --skip-branch-check + node scripts/check-v29-gate7-organization-permission-authority.mjs --skip-branch-check - name: Validate source casing and imports run: | @@ -78,6 +79,8 @@ jobs: run: | pnpm --filter @bitcode/btd typecheck pnpm --filter @bitcode/api build + pnpm --dir packages/executions-mcp/src/mcp-server run type-check + pnpm --dir packages/chatgptapp exec tsc --noEmit --pretty false pnpm --filter @bitcode/pipeline-asset-pack typecheck pnpm --filter @bitcode/pipeline-hosts typecheck @@ -87,6 +90,8 @@ jobs: pnpm --filter @bitcode/btd test -- --runTestsByPath __tests__/btc-fee-operation.test.ts pnpm --filter @bitcode/btd test -- --runTestsByPath __tests__/btd.test.ts pnpm --filter @bitcode/api exec jest --config jest.config.cjs --runTestsByPath src/routes/__tests__/btd-crypto.test.ts --runInBand + pnpm --dir packages/executions-mcp/src/mcp-server run test:mcp -- --runTestsByPath src/__tests__/unit/auth.test.ts --runInBand + pnpm --dir packages/chatgptapp exec jest --runTestsByPath src/__tests__/tools.test.ts --runInBand pnpm --filter @bitcode/pipeline-hosts exec jest --config jest.config.cjs --runTestsByPath src/__tests__/asset-pack-harness.test.ts --runInBand pnpm --filter @bitcode/pipeline-asset-pack exec jest --config jest.config.cjs --passWithNoTests --forceExit pnpm --filter @bitcode/pipeline-asset-pack exec jest --config jest.config.cjs --runTestsByPath src/__tests__/reading-pipeline-observability.test.ts src/__tests__/reading-pipeline-contract.test.ts --runInBand @@ -104,7 +109,9 @@ jobs: tests/terminalTransactionReadModel.test.ts \ tests/terminalWalletBtcOperation.test.ts \ tests/terminalJournalReconciliation.test.ts \ + tests/terminalOrganizationAuthority.test.ts \ tests/terminalTransactionDetailCards.test.tsx \ + tests/terminalTransactionDetailSnapshot.test.ts \ tests/pipelineExecutionLogHeader.test.tsx \ --runInBand diff --git a/BITCODE_SPEC_V29.md b/BITCODE_SPEC_V29.md index b76b2610b..26a5e7530 100644 --- a/BITCODE_SPEC_V29.md +++ b/BITCODE_SPEC_V29.md @@ -369,6 +369,40 @@ Gate PRs into version branches must begin with the uppercase version and gate pr - Current validating commands and parity basis: wallet tests, access tests, API route tests, Terminal permission tests, and staging auth readback. - Current accepted boundaries: broad enterprise RBAC depth can expand inside V29 only when tied to Terminal transaction operation. +#### V29 organization interface authority canon + +Organization permission authority is a BTD primitive, not a per-interface convention. +The canonical decision is `BtdOrganizationInterfaceAuthorityDecision`. +It binds actor id, organization id, organization role, organization permission grants, interface surface, action, wallet binding, registry read-access decision, settlement state, explicit confirmation state, repair approval state, target anchor, source visibility, and proof roots. + +Gate 7 defines the current action set: + +- `read_transaction` +- `request_read` +- `review_need` +- `request_finding_fits` +- `review_asset_pack_preview` +- `pay_btc_fee` +- `unlock_asset_pack_source` +- `deliver_asset_pack` +- `repair_projection` +- `administer_organization` + +Each action has a minimum organization role, optional explicit permission grants, wallet-binding requirement, registry read-access requirement, settled-payment requirement, explicit-confirmation requirement, repair-approval requirement, and source-visibility result. +Protected-source actions fail closed unless role/grant, wallet binding, owner-read or licensed-read registry access, settlement, and interface admission all agree. +Source-safe preview actions may remain visible without protected source. + +The same authority primitive must be used by: + +- Terminal, as the ordinary permission explainer for selected activity detail; +- API routes, as the JSON-safe route decision for interface owners; +- MCP, as organization-scoped action authority over read-license and delivery operations; +- ChatGPT App, as connected-interface write admission over confirmed delivery writes; +- the sandbox harness, as emitted evidence for live Reading/AssetPack completion readback. + +Authority proof roots include role root, permission root, read-access root, interface root, and aggregate authority root. +Terminal renders those roots with blockers and decision rows so operators can understand why a transaction can or cannot pay, unlock, deliver, repair, or administer without opening network logs. + ### Disclosure and projection - Current canonical objects and emitted artifacts: disclosure policy, projection policy, redaction decision, preview metadata, owner-read/licensed-read/denied state. diff --git a/BITCODE_SPEC_V29_DELTA.md b/BITCODE_SPEC_V29_DELTA.md index 625dd0d11..a6549ad17 100644 --- a/BITCODE_SPEC_V29_DELTA.md +++ b/BITCODE_SPEC_V29_DELTA.md @@ -154,6 +154,17 @@ Closure acceptance: Gate 7 owns organization holdings, roles, read-license usage, registry-derived permission decisions, MCP/ChatGPT action authority alignment, and Terminal permission explainers. +Closure acceptance: + +- `packages/btd/src/authority.ts` defines the shared organization/interface authority primitive, action requirements, registry holdings/read-license summary, source visibility, and proof roots. +- BTD tests prove organization holdings, active/expired/revoked read-license usage, paid delivery admission, unpaid unlock denial, and unsupported ChatGPT administration denial. +- `packages/api/src/routes/btd-crypto.ts` exposes JSON-safe organization authority decisions and `uapi/app/api/btd/organization-interface-authority/route.ts` binds the route. +- MCP auth can require interface authority after registry-derived owner-read or licensed-read evidence. +- ChatGPT App connected-interface write carriers require explicit confirmation, registry read-access evidence, and organization authority evidence before write execution. +- The sandbox harness emits `organizationAuthority` evidence alongside settlement unlock and ledger/database reconciliation. +- Terminal adds an Authority detail section rendering decision rows, blockers, and proof roots from the same payload. +- `pnpm run check:v29-gate7` passes with focused BTD, API, MCP, ChatGPT App, pipeline-hosts, and UAPI tests. + ### Gate 8: Demonstration-Origin Commercial Formalization Gate 8 owns cleanup of freshly ported demonstration-origin internals into package-native APIs, no demonstration runtime imports, durable package tests, and updated internal/public documentation. diff --git a/BITCODE_SPEC_V29_NOTES.md b/BITCODE_SPEC_V29_NOTES.md index 3c25ca295..1356628cd 100644 --- a/BITCODE_SPEC_V29_NOTES.md +++ b/BITCODE_SPEC_V29_NOTES.md @@ -168,6 +168,26 @@ Operator notes: - Terminal journal detail is the ordinary repair cockpit for this gate: drift classes, blockers, repair actions, proof roots, journal entries, and repair receipts must be visible without opening network logs. - Raw payload remains audit material, not the primary operator interface. +## Gate 7 working notes + +Gate 7 makes organization authority package-owned and interface-shared. + +Accepted Gate 7 posture: + +- BTD authority evaluates Terminal, API, MCP, and ChatGPT App surfaces against the same action requirement table. +- Registry-derived organization holdings and read-license usage are summarized as authority evidence, not aggregate spendable balance. +- Protected-source actions require owner-read or licensed-read access plus settled payment where the action crosses the source boundary. +- ChatGPT App writes remain impossible without explicit user confirmation, read-access evidence, and organization authority evidence. +- MCP can request interface authority after its existing permission and read-access checks. +- Terminal exposes authority as a normal selected-activity detail section with collapsed metrics, blockers, decision rows, proof roots, and raw payload. +- Sandbox harness output carries `organizationAuthority` so staging-testnet Reading runs can be debugged from Terminal. + +Gate 7 completion condition: + +- `pnpm run check:v29-gate7` passes. +- Focused BTD, API, MCP, ChatGPT App, pipeline-hosts, and UAPI tests cover the authority primitive and interface surfaces. +- Spec, delta, notes, parity, Terminal README, package scripts, and gate-quality workflow name the Gate 7 closure surface. + ## Later-version boundaries - V30 remains reserved for Protocol/BTD hardening that is not necessary to close Terminal transaction depth. diff --git a/BITCODE_SPEC_V29_PARITY_MATRIX.md b/BITCODE_SPEC_V29_PARITY_MATRIX.md index 30c29eae4..ff8dcf814 100644 --- a/BITCODE_SPEC_V29_PARITY_MATRIX.md +++ b/BITCODE_SPEC_V29_PARITY_MATRIX.md @@ -54,7 +54,7 @@ No `_legacy/` source is active source truth. | Reading pipeline observability | Gate 4 | `packages/pipelines/asset-pack/src/reading-pipeline-observability.ts`, `packages/pipeline-hosts/src/asset-pack-harness.ts`, Terminal stream components, Gate 4 checker | drafted | Pipeline/phase/PTRR/ThricifiedGeneration/tool/prompt/raw-output/parsed-output telemetry is contract-projected, complete, and readable. | | AssetPack disclosure rights | Gate 5 | `asset-pack-disclosure.ts`, AssetPack postprocess, sandbox harness, BTD access tests, Terminal disclosure review UI, Gate 5 checker | drafted | Source-safe preview and paid unlock are proven without protected-source leakage. | | Settlement reconciliation repair | Gate 6 | BTD journal/reconciliation, Supabase readback, sandbox harness settlement evidence, Terminal repair UI, Gate 6 checker | drafted | Ledger, database, and metaphysical state drift is classified, proof-rooted, repair-actioned, and visible. | -| Organization permission authority | Gate 7 | access policy, org holdings, MCP/ChatGPT gates, Terminal permission UI | pending | Registry-derived roles, holdings, and read-license authority govern actions. | +| Organization permission authority | Gate 7 | `packages/btd/src/authority.ts`, BTD/API/MCP/ChatGPT App tests, sandbox harness authority evidence, Terminal Authority section, Gate 7 checker | drafted | Registry-derived roles, holdings, read-license authority, settlement, confirmation, and interface admission govern actions. | | Commercial formalization | Gate 8 | packages/protocol, package tests, import scans, docs | pending | Demonstration-origin commercial internals are package-native and no runtime demo imports remain. | | Terminal UX quality | Gate 9 | Playwright/Jest/a11y/responsive/browser QA | pending | Complete transaction cockpit is usable by default and inspectable in detail. | | Promotion readiness | Gate 10 | promotion workflow, `.bitcode/v29-*`, `BITCODE_SPEC_V29_PROVEN.md` | pending | `version/v29` can promote to `main` only after all V29 checks pass. | @@ -71,7 +71,8 @@ No `_legacy/` source is active source truth. | Gate 3 wallet/BTC operation | `pnpm run check:v29-gate3` proves quote lifecycle, signer recovery, blocked readiness, API posture, and Terminal Wallet/BTC detail | drafted | | Gate 4 Reading observability | `pnpm run check:v29-gate4` proves contract-aware Reading stream telemetry and Terminal rendering | drafted | | Gate 5 AssetPack disclosure | `pnpm run check:v29-gate5` proves source-safe disclosure review, leakage detection, Terminal preview rendering, and PR title enforcement | drafted | -| Product implementation gates | Gates 6-9 close remaining Terminal transaction depth with tests and docs | pending | +| Gate 7 organization authority | `pnpm run check:v29-gate7` proves shared org/interface authority across BTD, API, MCP, ChatGPT App, harness, and Terminal | drafted | +| Product implementation gates | Gates 8-9 close remaining Terminal transaction depth with tests and docs | pending | | Promotion gate | Gate 10 closes generated proof and promotion automation | pending | ## Gate 1 Parity @@ -207,10 +208,37 @@ Gate 6 completion condition: - `pnpm run check:v29-gate6` passes. - Focused BTD, API, pipeline-hosts, and UAPI tests prove repair classification, conservation drift, proof roots, and Terminal repair visibility. -- Gate 5 does not implement organization authority beyond read-right state distinctions; Gate 7 owns registry-derived roles and holdings. -- Gate 5 does not add browser proof or full UX polish; Gate 9 owns that surface. -- Gate 5 does not add versioned API routes or source identifiers. ## Gate 5 completion condition Gate 5 is complete when AssetPack disclosure review is package-owned, postprocess and sandbox harness evidence include source-safe disclosure review roots, protected-source leakage tests fail closed, Terminal renders the disclosure review through the Reading preview surface, gate PR title enforcement is active, focused package and UAPI tests pass, `check:v29-gate5` passes, CI invokes the checker and tests, docs name the implemented source surfaces, and the gate branch is committed and pushed for review into `version/v29`. + +## Gate 7 Parity + +Gate 7 closes the organization authority slice by making action permission a shared BTD decision instead of interface-local convention. + +Accepted surfaces: + +- `packages/btd/src/authority.ts` is the canonical organization/interface authority primitive for role checks, explicit grants, wallet binding, registry read access, settlement, explicit confirmation, repair approval, source visibility, and authority proof roots. +- `packages/api/src/routes/btd-crypto.ts` and `uapi/app/api/btd/organization-interface-authority/route.ts` expose JSON-safe authority decisions for application routes. +- `packages/executions-mcp/src/mcp-server/src/auth/middleware.ts` can require interface authority after existing permission and BTD read-access checks. +- `packages/chatgptapp/src/tools.ts` requires explicit confirmation, registry read-access evidence, and organization authority evidence before connected-interface writes. +- `packages/pipeline-hosts/src/asset-pack-harness.ts` stores and emits `organizationAuthority` beside settlement unlock and reconciliation evidence. +- `uapi/app/terminal/terminal-organization-authority.ts` and `TerminalTransactionOrganizationAuthorityCard.tsx` project the selected transaction into authority metrics, blockers, decision rows, proof roots, and raw payload. + +Accepted failure states: + +- Missing role, insufficient role, or missing explicit grant blocks authority. +- Missing wallet binding blocks fee payment, source unlock, and delivery when those actions require Reader wallet proof. +- Missing or denied registry read access blocks protected-source unlock and delivery. +- Pending settlement blocks protected-source visibility even when role evidence exists. +- Missing explicit confirmation blocks payment, delivery, repair, and administration actions that cross sensitive boundaries. +- Interfaces may deny actions even when the organization role could authorize them elsewhere. + +Gate 7 completion condition: + +- `pnpm run check:v29-gate7` passes. +- Focused BTD, API, MCP, ChatGPT App, pipeline-hosts, and UAPI tests prove organization holdings, active/expired/revoked license usage, paid delivery admission, unpaid denial, interface denial, connected-interface write gating, harness evidence, and Terminal visibility. +- Gate-quality CI invokes the Gate 7 checker and focused tests. +- Gate 7 does not add broad enterprise RBAC administration beyond Terminal transaction authority; later versions may deepen team policy authoring. +- Gate 7 does not reveal protected source before settlement and does not add versioned API routes or source identifiers. diff --git a/package.json b/package.json index b85fd78a0..1b751773d 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "check:v29-gate4": "node scripts/check-v29-gate4-reading-pipeline-observability.mjs", "check:v29-gate5": "node scripts/check-v29-gate5-assetpack-disclosure-rights.mjs", "check:v29-gate6": "node scripts/check-v29-gate6-settlement-reconciliation-repair.mjs", + "check:v29-gate7": "node scripts/check-v29-gate7-organization-permission-authority.mjs", "check:spec-quality": "node scripts/run-bitcode-spec-quality.mjs --mode basic", "check:spec-quality:title": "node scripts/run-bitcode-spec-quality.mjs --mode strict-from-title", "check:spec-quality:v24": "node scripts/run-bitcode-spec-quality.mjs --mode strict-version --version V24", diff --git a/packages/api/src/routes/__tests__/btd-crypto.test.ts b/packages/api/src/routes/__tests__/btd-crypto.test.ts index 6368d585e..8be25a2c5 100644 --- a/packages/api/src/routes/__tests__/btd-crypto.test.ts +++ b/packages/api/src/routes/__tests__/btd-crypto.test.ts @@ -22,6 +22,7 @@ import { buildBtdLedgerDatabaseReconciliationSettlement, buildBtdLicensedReadRevenueSettlement, buildBtdMintDraft, + buildBtdOrganizationInterfaceAuthorityDecision, buildBtdReadAccessDecision, buildBtdTerminalJournalSettlement, buildGetBtdRegistrySnapshotRoute, @@ -33,6 +34,7 @@ import { buildPostBtdLedgerDatabaseReconciliationRoute, buildPostBtdLicensedReadRevenueRoute, buildPostBtdMintDraftRoute, + buildPostBtdOrganizationInterfaceAuthorityRoute, buildPostBtdReadAccessRoute, buildPostBtdTerminalJournalRoute, } from '../btd-crypto'; @@ -463,6 +465,99 @@ describe('BTD crypto API builders', () => { expect((await mismatchResponse.json()).decision.reason).toBe('policy_mismatch'); }); + it('builds organization interface authority decisions with source visibility and proof roots', () => { + const decision = buildBtdOrganizationInterfaceAuthorityDecision({ + actorId: 'user-1', + organizationId: 'org-api-1', + organizationRole: 'admin', + organizationPermissionGrants: ['asset_pack:deliver'], + interfaceSurface: 'mcp', + action: 'deliver_asset_pack', + walletId: 'wallet-reader', + settlementState: 'settled', + confirmed: true, + targetAnchor: 'github:engineeredsoftware/ENGI/pull/42', + readAccessDecision: { + decision: 'owner_read', + accessPolicyHash: 'policy-api-hash', + reason: 'wallet_owns_policy_matching_range', + }, + at: issuedAt, + }); + + expect(decision).toMatchObject({ + routeKind: 'btd_organization_interface_authority_route_decision', + kind: 'btd_organization_interface_authority_decision', + decision: 'allowed', + interfaceSurface: 'mcp', + action: 'deliver_asset_pack', + sourceVisibility: 'protected_source_allowed', + reason: 'role_authorized', + }); + expect(decision.reasons).toEqual(expect.arrayContaining(['owner_read_access_authorized'])); + expect(decision.proofRoots.authorityRoot).toMatch(/^btd-proof-root:organization-interface-authority:/); + }); + + it('returns JSON-safe organization interface authority decisions from the route boundary', async () => { + const route = buildPostBtdOrganizationInterfaceAuthorityRoute({ + resolveAuthenticatedUser: async () => ({ userId: 'user-1' }), + }); + const response = await route( + new Request('https://bitcode.test/api/btd/organization-interface-authority', { + method: 'POST', + body: JSON.stringify({ + organizationId: 'org-api-1', + organizationRole: 'member', + organizationPermissionGrants: ['reading:request_finding_fits'], + interfaceSurface: 'terminal', + action: 'request_finding_fits', + at: issuedAt, + }), + }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.routeKind).toBe('btd_organization_interface_authority_route_decision'); + expect(body.decision).toBe('allowed'); + expect(body.sourceVisibility).toBe('source_safe_preview'); + expect(body.satisfied.role).toBe(true); + expect(body.proofRoots.interfaceRoot).toMatch(/^btd-proof-root:interface-authority:/); + }); + + it('keeps organization authority denials explicit at the route boundary', async () => { + const route = buildPostBtdOrganizationInterfaceAuthorityRoute({ + resolveAuthenticatedUser: async () => ({ userId: 'user-1' }), + }); + const response = await route( + new Request('https://bitcode.test/api/btd/organization-interface-authority', { + method: 'POST', + body: JSON.stringify({ + organizationId: 'org-api-1', + organizationRole: 'viewer', + interfaceSurface: 'chatgpt_app', + action: 'deliver_asset_pack', + settlementState: 'pending', + confirmed: false, + at: issuedAt, + }), + }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.decision).toBe('denied'); + expect(body.reasons).toEqual( + expect.arrayContaining([ + 'role_insufficient', + 'wallet_binding_missing', + 'registry_read_access_required', + 'settlement_required', + 'explicit_confirmation_required', + ]), + ); + }); + it('builds licensed-read revenue settlement receipts with holdback and route state', () => { const settlement = buildBtdLicensedReadRevenueSettlement({ actorId: 'user-1', diff --git a/packages/api/src/routes/btd-crypto.ts b/packages/api/src/routes/btd-crypto.ts index 1e5834cb1..7a65ced3f 100644 --- a/packages/api/src/routes/btd-crypto.ts +++ b/packages/api/src/routes/btd-crypto.ts @@ -13,8 +13,13 @@ import { type BtdMeasureMintState, type BtdOwnershipClaim, type BtdReadLicense, + type BtdInterfaceAuthoritySurface, + type BtdOrganizationPermissionAction, + type BtdOrganizationRole, + type BtdRepairApprovalState, type BtdRevenueRecipientInput, type BtdRevenueRouteException, + type BtdSettlementAuthorityState, type BtdProtocolUpgradeReceipt, type BtdProtocolUpgradeState, type AssetPackLedgerAnchor, @@ -69,6 +74,7 @@ import { createAssetPackExchangeOrder, createBtdMeasureMintState, createBtdSupplyState, + evaluateBtdOrganizationInterfaceAuthority, evaluateBtdReadAccess, measureProofAddressableSemanticVolume, reviewBtdAncestorEdges, @@ -152,6 +158,27 @@ export interface BtdReadAccessDecision { }; } +export interface BtdOrganizationInterfaceAuthorityInput { + organizationId: string; + organizationRole?: BtdOrganizationRole | null; + organizationPermissionGrants?: string[]; + interfaceSurface: BtdInterfaceAuthoritySurface; + action: BtdOrganizationPermissionAction; + walletId?: string | null; + readAccessDecision?: ReturnType | null; + settlementState?: BtdSettlementAuthorityState; + confirmed?: boolean; + repairApprovalState?: BtdRepairApprovalState; + targetAnchor?: string | null; + at?: string; +} + +export type BtdOrganizationInterfaceAuthorityRouteDecision = ReturnType< + typeof evaluateBtdOrganizationInterfaceAuthority +> & { + routeKind: 'btd_organization_interface_authority_route_decision'; +}; + export interface BtdLicensedReadRevenueInput { paymentId: string; assetPackId: string; @@ -610,6 +637,20 @@ export function buildBtdReadAccessDecision( }; } +export function buildBtdOrganizationInterfaceAuthorityDecision( + input: BtdOrganizationInterfaceAuthorityInput & { actorId: string }, +): BtdOrganizationInterfaceAuthorityRouteDecision { + const decision = evaluateBtdOrganizationInterfaceAuthority({ + ...input, + actorId: assertNonEmptyString(input.actorId, 'actorId'), + }); + + return { + ...decision, + routeKind: 'btd_organization_interface_authority_route_decision', + }; +} + export function buildBtdLicensedReadRevenueSettlement( input: BtdLicensedReadRevenueInput & { actorId: string }, ): Omit { @@ -1188,6 +1229,37 @@ export function buildPostBtdReadAccessRoute(options: BtdRouteOptions = {}) { }); } +export function buildPostBtdOrganizationInterfaceAuthorityRoute( + options: BtdRouteOptions = {}, +) { + return traceRoute('/btd/organization-interface-authority', async (request: Request) => { + const user = await (options.resolveAuthenticatedUser ?? defaultResolveAuthenticatedUser)( + request, + ); + if (!user) { + return createJsonResponse({ error: 'Unauthorized' }, 401); + } + + let body: BtdOrganizationInterfaceAuthorityInput; + try { + body = await request.json(); + } catch { + return createJsonResponse({ error: 'Invalid JSON body' }, 400); + } + + try { + const decision = buildBtdOrganizationInterfaceAuthorityDecision({ + ...body, + actorId: user.userId, + }); + + return createJsonResponse(toJsonSafe(decision)); + } catch (error) { + return createJsonResponse({ error: toBadRequestMessage(error) }, 400); + } + }); +} + export function buildPostBtdLicensedReadRevenueRoute(options: BtdRouteOptions = {}) { return traceRoute('/btd/licensed-read-revenue', async (request: Request) => { const user = await (options.resolveAuthenticatedUser ?? defaultResolveAuthenticatedUser)( @@ -1558,6 +1630,8 @@ export function buildPostBtdDeploymentReadinessRoute(options: BtdRouteOptions = export const getBtdRegistrySnapshot = buildGetBtdRegistrySnapshotRoute(); export const postBtdMintDraft = buildPostBtdMintDraftRoute(); export const postBtdReadAccess = buildPostBtdReadAccessRoute(); +export const postBtdOrganizationInterfaceAuthority = + buildPostBtdOrganizationInterfaceAuthorityRoute(); export const postBtdLicensedReadRevenue = buildPostBtdLicensedReadRevenueRoute(); export const postBtdAncestryReview = buildPostBtdAncestryReviewRoute(); export const postBtdBtcFeeTransaction = buildPostBtdBtcFeeTransactionRoute(); diff --git a/packages/btd/__tests__/btd.test.ts b/packages/btd/__tests__/btd.test.ts index 026f23efc..760044b8b 100644 --- a/packages/btd/__tests__/btd.test.ts +++ b/packages/btd/__tests__/btd.test.ts @@ -16,9 +16,11 @@ import { buildBtdReadAccessProjectionFromRegistryRows, calculateLlmBtcFeeEstimate, calculateMeasuredBtdFromTokens, + evaluateBtdOrganizationInterfaceAuthority, evaluateBtdReadAccess, listBtdAccessPolicyTemplates, reconcileLedgerDatabaseProjection, + summarizeBtdOrganizationRegistryAuthority, } from '../src'; describe('calculateLlmBtcFeeEstimate', () => { @@ -384,3 +386,141 @@ describe('registry-derived read access projection', () => { }); }); }); + +describe('organization interface authority', () => { + it('summarizes organization holdings and read-license usage from registry rows', () => { + const summary = summarizeBtdOrganizationRegistryAuthority({ + organizationId: 'org-1', + walletIds: ['wallet-owner', 'wallet-reader', 'wallet-reader'], + ownershipRows: [ + { + to_wallet_id: 'wallet-owner', + asset_pack_id: 'asset-pack-1', + range_start: 0, + range_end_exclusive: 7, + }, + ], + readLicenseRows: [ + { + license_id: 'license-active', + wallet_id: 'wallet-reader', + asset_pack_id: 'asset-pack-2', + valid_from: '2026-05-01T00:00:00.000Z', + }, + { + license_id: 'license-expired', + wallet_id: 'wallet-reader', + asset_pack_id: 'asset-pack-3', + valid_from: '2026-04-01T00:00:00.000Z', + expires_at: '2026-05-02T00:00:00.000Z', + }, + { + license_id: 'license-revoked', + wallet_id: 'wallet-reader', + asset_pack_id: 'asset-pack-4', + valid_from: '2026-04-01T00:00:00.000Z', + revoked_at: '2026-05-03T00:00:00.000Z', + }, + ], + at: '2026-05-19T00:00:00.000Z', + }); + + expect(summary).toMatchObject({ + organizationId: 'org-1', + source: 'btd_registry', + walletIds: ['wallet-owner', 'wallet-reader'], + ownedCellCount: 7, + ownedAssetPackIds: ['asset-pack-1'], + readLicenseCount: 3, + activeReadLicenseCount: 1, + expiredReadLicenseCount: 1, + revokedReadLicenseCount: 1, + licensedAssetPackIds: ['asset-pack-2'], + }); + expect(summary.authorityRoot).toMatch(/^btd-proof-root:organization-registry-authority:/); + }); + + it('allows paid AssetPack delivery only with role, wallet, settlement, confirmation, and registry read access', () => { + const decision = evaluateBtdOrganizationInterfaceAuthority({ + actorId: 'user-1', + organizationId: 'org-1', + organizationRole: 'member', + organizationPermissionGrants: ['asset_pack:deliver'], + interfaceSurface: 'chatgpt_app', + action: 'deliver_asset_pack', + walletId: 'wallet-reader', + settlementState: 'settled', + confirmed: true, + readAccessDecision: { + decision: 'licensed_read', + accessPolicyHash: 'policy-hash', + reason: 'wallet_has_valid_policy_matching_license', + }, + targetAnchor: 'github:engineeredsoftware/ENGI/pull/42', + at: '2026-05-19T00:00:00.000Z', + }); + + expect(decision).toMatchObject({ + kind: 'btd_organization_interface_authority_decision', + decision: 'allowed', + interfaceSurface: 'chatgpt_app', + action: 'deliver_asset_pack', + sourceVisibility: 'protected_source_allowed', + satisfied: { + role: true, + permissionGrant: true, + walletBinding: true, + registryReadAccess: true, + settlement: true, + explicitConfirmation: true, + interfaceAction: true, + }, + }); + expect(decision.reasons).toEqual( + expect.arrayContaining([ + 'role_authorized', + 'explicit_permission_grant_authorized', + 'licensed_read_access_authorized', + ]), + ); + expect(decision.proofRoots.authorityRoot).toMatch(/^btd-proof-root:organization-interface-authority:/); + }); + + it('fails closed for unpaid protected-source unlock and unsupported ChatGPT administration', () => { + const unpaidUnlock = evaluateBtdOrganizationInterfaceAuthority({ + actorId: 'user-1', + organizationId: 'org-1', + organizationRole: 'admin', + interfaceSurface: 'terminal', + action: 'unlock_asset_pack_source', + walletId: 'wallet-reader', + settlementState: 'pending', + readAccessDecision: { + decision: 'owner_read', + accessPolicyHash: 'policy-hash', + reason: 'wallet_owns_policy_matching_range', + }, + at: '2026-05-19T00:00:00.000Z', + }); + const chatGptAdmin = evaluateBtdOrganizationInterfaceAuthority({ + actorId: 'user-1', + organizationId: 'org-1', + organizationRole: 'owner', + interfaceSurface: 'chatgpt_app', + action: 'administer_organization', + confirmed: true, + at: '2026-05-19T00:00:00.000Z', + }); + + expect(unpaidUnlock).toMatchObject({ + decision: 'denied', + reason: 'settlement_required', + sourceVisibility: 'blocked', + }); + expect(chatGptAdmin).toMatchObject({ + decision: 'denied', + reason: 'interface_action_not_authorized', + sourceVisibility: 'source_safe_preview', + }); + }); +}); diff --git a/packages/btd/src/authority.ts b/packages/btd/src/authority.ts new file mode 100644 index 000000000..436e07aba --- /dev/null +++ b/packages/btd/src/authority.ts @@ -0,0 +1,595 @@ +import { + assertNonEmptyString, +} from './constants'; +import type { + BtdAccessDecision, + BtdAccessDecisionKind, +} from './access'; + +export type BtdOrganizationRole = 'viewer' | 'member' | 'admin' | 'owner'; + +export type BtdInterfaceAuthoritySurface = + | 'terminal' + | 'api' + | 'mcp' + | 'chatgpt_app'; + +export type BtdOrganizationPermissionAction = + | 'read_transaction' + | 'request_read' + | 'review_need' + | 'request_finding_fits' + | 'review_asset_pack_preview' + | 'pay_btc_fee' + | 'unlock_asset_pack_source' + | 'deliver_asset_pack' + | 'repair_projection' + | 'administer_organization'; + +export type BtdOrganizationAuthorityDecisionKind = 'allowed' | 'denied'; + +export type BtdOrganizationAuthorityReason = + | 'role_authorized' + | 'explicit_permission_grant_authorized' + | 'owner_read_access_authorized' + | 'licensed_read_access_authorized' + | 'role_missing' + | 'role_insufficient' + | 'wallet_binding_missing' + | 'registry_read_access_required' + | 'registry_read_access_denied' + | 'settlement_required' + | 'explicit_confirmation_required' + | 'repair_approval_required' + | 'interface_action_not_authorized'; + +export type BtdSettlementAuthorityState = 'not_required' | 'pending' | 'settled'; +export type BtdRepairApprovalState = 'not_required' | 'required' | 'approved'; +export type BtdSourceVisibilityState = + | 'source_safe_preview' + | 'protected_source_allowed' + | 'blocked'; + +export interface BtdOrganizationRegistryAuthoritySummary { + organizationId: string; + source: 'btd_registry'; + walletIds: string[]; + ownedAssetPackIds: string[]; + ownedCellCount: number; + readLicenseCount: number; + activeReadLicenseCount: number; + expiredReadLicenseCount: number; + revokedReadLicenseCount: number; + licensedAssetPackIds: string[]; + authorityRoot: string; +} + +export interface BtdOrganizationAuthorityActionRequirements { + minimumRole: BtdOrganizationRole; + permissionGrants: string[]; + requiresWalletBinding: boolean; + requiresRegistryReadAccess: boolean; + requiresSettledPayment: boolean; + requiresExplicitConfirmation: boolean; + requiresRepairApproval: boolean; + sourceVisibilityWhenAllowed: BtdSourceVisibilityState; +} + +export interface BtdOrganizationInterfaceAuthorityInput { + actorId: string; + organizationId: string; + organizationRole?: BtdOrganizationRole | null; + organizationPermissionGrants?: string[]; + interfaceSurface: BtdInterfaceAuthoritySurface; + action: BtdOrganizationPermissionAction; + walletId?: string | null; + readAccessDecision?: BtdAccessDecision | null; + settlementState?: BtdSettlementAuthorityState; + confirmed?: boolean; + repairApprovalState?: BtdRepairApprovalState; + targetAnchor?: string | null; + at?: string; +} + +export interface BtdOrganizationInterfaceAuthorityDecision { + kind: 'btd_organization_interface_authority_decision'; + decision: BtdOrganizationAuthorityDecisionKind; + actorId: string; + organizationId: string; + organizationRole: BtdOrganizationRole | null; + interfaceSurface: BtdInterfaceAuthoritySurface; + action: BtdOrganizationPermissionAction; + walletId: string | null; + targetAnchor: string | null; + readAccessDecision: BtdAccessDecision | null; + requirements: BtdOrganizationAuthorityActionRequirements; + satisfied: { + role: boolean; + permissionGrant: boolean; + walletBinding: boolean; + registryReadAccess: boolean; + settlement: boolean; + explicitConfirmation: boolean; + repairApproval: boolean; + interfaceAction: boolean; + }; + reason: BtdOrganizationAuthorityReason; + reasons: BtdOrganizationAuthorityReason[]; + sourceVisibility: BtdSourceVisibilityState; + proofRoots: { + roleRoot: string; + permissionRoot: string; + readAccessRoot: string; + interfaceRoot: string; + authorityRoot: string; + }; + issuedAt: string; +} + +const ROLE_ORDER: Record = { + viewer: 0, + member: 1, + admin: 2, + owner: 3, +}; + +const ACTION_REQUIREMENTS: Record< + BtdOrganizationPermissionAction, + BtdOrganizationAuthorityActionRequirements +> = { + read_transaction: { + minimumRole: 'viewer', + permissionGrants: ['terminal:read', 'activity:read'], + requiresWalletBinding: false, + requiresRegistryReadAccess: false, + requiresSettledPayment: false, + requiresExplicitConfirmation: false, + requiresRepairApproval: false, + sourceVisibilityWhenAllowed: 'source_safe_preview', + }, + request_read: { + minimumRole: 'member', + permissionGrants: ['reading:request'], + requiresWalletBinding: false, + requiresRegistryReadAccess: false, + requiresSettledPayment: false, + requiresExplicitConfirmation: false, + requiresRepairApproval: false, + sourceVisibilityWhenAllowed: 'source_safe_preview', + }, + review_need: { + minimumRole: 'member', + permissionGrants: ['reading:review_need'], + requiresWalletBinding: false, + requiresRegistryReadAccess: false, + requiresSettledPayment: false, + requiresExplicitConfirmation: false, + requiresRepairApproval: false, + sourceVisibilityWhenAllowed: 'source_safe_preview', + }, + request_finding_fits: { + minimumRole: 'member', + permissionGrants: ['reading:request_finding_fits'], + requiresWalletBinding: false, + requiresRegistryReadAccess: false, + requiresSettledPayment: false, + requiresExplicitConfirmation: false, + requiresRepairApproval: false, + sourceVisibilityWhenAllowed: 'source_safe_preview', + }, + review_asset_pack_preview: { + minimumRole: 'viewer', + permissionGrants: ['asset_pack:review_preview'], + requiresWalletBinding: false, + requiresRegistryReadAccess: false, + requiresSettledPayment: false, + requiresExplicitConfirmation: false, + requiresRepairApproval: false, + sourceVisibilityWhenAllowed: 'source_safe_preview', + }, + pay_btc_fee: { + minimumRole: 'admin', + permissionGrants: ['settlement:pay_btc_fee'], + requiresWalletBinding: true, + requiresRegistryReadAccess: false, + requiresSettledPayment: false, + requiresExplicitConfirmation: true, + requiresRepairApproval: false, + sourceVisibilityWhenAllowed: 'source_safe_preview', + }, + unlock_asset_pack_source: { + minimumRole: 'member', + permissionGrants: ['asset_pack:unlock_source'], + requiresWalletBinding: true, + requiresRegistryReadAccess: true, + requiresSettledPayment: true, + requiresExplicitConfirmation: false, + requiresRepairApproval: false, + sourceVisibilityWhenAllowed: 'protected_source_allowed', + }, + deliver_asset_pack: { + minimumRole: 'member', + permissionGrants: ['asset_pack:deliver'], + requiresWalletBinding: true, + requiresRegistryReadAccess: true, + requiresSettledPayment: true, + requiresExplicitConfirmation: true, + requiresRepairApproval: false, + sourceVisibilityWhenAllowed: 'protected_source_allowed', + }, + repair_projection: { + minimumRole: 'admin', + permissionGrants: ['settlement:repair_projection'], + requiresWalletBinding: false, + requiresRegistryReadAccess: false, + requiresSettledPayment: false, + requiresExplicitConfirmation: true, + requiresRepairApproval: true, + sourceVisibilityWhenAllowed: 'source_safe_preview', + }, + administer_organization: { + minimumRole: 'owner', + permissionGrants: ['organization:administer'], + requiresWalletBinding: false, + requiresRegistryReadAccess: false, + requiresSettledPayment: false, + requiresExplicitConfirmation: true, + requiresRepairApproval: false, + sourceVisibilityWhenAllowed: 'source_safe_preview', + }, +}; + +const SURFACE_ACTIONS: Record< + BtdInterfaceAuthoritySurface, + BtdOrganizationPermissionAction[] +> = { + terminal: [ + 'read_transaction', + 'request_read', + 'review_need', + 'request_finding_fits', + 'review_asset_pack_preview', + 'pay_btc_fee', + 'unlock_asset_pack_source', + 'deliver_asset_pack', + 'repair_projection', + 'administer_organization', + ], + api: [ + 'read_transaction', + 'request_read', + 'review_need', + 'request_finding_fits', + 'review_asset_pack_preview', + 'pay_btc_fee', + 'unlock_asset_pack_source', + 'deliver_asset_pack', + 'repair_projection', + 'administer_organization', + ], + mcp: [ + 'read_transaction', + 'request_read', + 'review_need', + 'request_finding_fits', + 'review_asset_pack_preview', + 'pay_btc_fee', + 'unlock_asset_pack_source', + 'deliver_asset_pack', + 'repair_projection', + ], + chatgpt_app: [ + 'read_transaction', + 'review_asset_pack_preview', + 'deliver_asset_pack', + ], +}; + +export function listBtdOrganizationAuthorityActionRequirements() { + return ACTION_REQUIREMENTS; +} + +export function summarizeBtdOrganizationRegistryAuthority(input: { + organizationId: string; + walletIds?: string[]; + ownershipRows?: Record[]; + readLicenseRows?: Record[]; + at?: string; +}): BtdOrganizationRegistryAuthoritySummary { + const organizationId = assertNonEmptyString(input.organizationId, 'organizationId'); + const walletIds = uniqueStrings(input.walletIds ?? []); + const ownershipRows = input.ownershipRows ?? []; + const readLicenseRows = input.readLicenseRows ?? []; + const at = assertAuthorityDate(input.at ?? new Date().toISOString(), 'at'); + const activeReadLicenseRows = readLicenseRows.filter( + (row) => readLicenseState(row, at) === 'active', + ); + const expiredReadLicenseRows = readLicenseRows.filter( + (row) => readLicenseState(row, at) === 'expired', + ); + const revokedReadLicenseRows = readLicenseRows.filter( + (row) => readLicenseState(row, at) === 'revoked', + ); + const ownedCellCount = ownershipRows.reduce((sum, row) => { + const start = readNumberField(row, 'range_start', 'rangeStart'); + const end = readNumberField(row, 'range_end_exclusive', 'rangeEndExclusive'); + if (typeof start !== 'number' || typeof end !== 'number' || end <= start) return sum; + return sum + (end - start); + }, 0); + const ownedAssetPackIds = uniqueStrings( + ownershipRows.map((row) => readOptionalStringField(row, 'asset_pack_id', 'assetPackId')), + ); + const licensedAssetPackIds = uniqueStrings( + activeReadLicenseRows.map((row) => readOptionalStringField(row, 'asset_pack_id', 'assetPackId')), + ); + const summary = { + organizationId, + source: 'btd_registry', + walletIds, + ownedAssetPackIds, + ownedCellCount, + readLicenseCount: readLicenseRows.length, + activeReadLicenseCount: activeReadLicenseRows.length, + expiredReadLicenseCount: expiredReadLicenseRows.length, + revokedReadLicenseCount: revokedReadLicenseRows.length, + licensedAssetPackIds, + } satisfies Omit; + + return { + ...summary, + authorityRoot: stableProofRoot('organization-registry-authority', summary), + }; +} + +export function evaluateBtdOrganizationInterfaceAuthority( + input: BtdOrganizationInterfaceAuthorityInput, +): BtdOrganizationInterfaceAuthorityDecision { + const actorId = assertNonEmptyString(input.actorId, 'actorId'); + const organizationId = assertNonEmptyString(input.organizationId, 'organizationId'); + const requirements = ACTION_REQUIREMENTS[input.action]; + if (!requirements) { + throw new Error('action must be a known BTD organization permission action.'); + } + const issuedAt = assertAuthorityDate(input.at ?? new Date().toISOString(), 'at').toISOString(); + const organizationRole = input.organizationRole ?? null; + const permissionGrants = uniqueStrings(input.organizationPermissionGrants ?? []); + const walletId = normalizeOptionalString(input.walletId); + const targetAnchor = normalizeOptionalString(input.targetAnchor); + const readAccessDecision = input.readAccessDecision ?? null; + const interfaceAction = SURFACE_ACTIONS[input.interfaceSurface]?.includes(input.action) ?? false; + const role = roleSatisfies(organizationRole, requirements.minimumRole); + const permissionGrant = requirements.permissionGrants.some((grant) => + permissionGrants.includes(grant), + ); + const walletBinding = !requirements.requiresWalletBinding || Boolean(walletId); + const registryReadAccess = + !requirements.requiresRegistryReadAccess || + isAllowedReadAccessDecision(readAccessDecision?.decision); + const settlement = + !requirements.requiresSettledPayment || input.settlementState === 'settled'; + const explicitConfirmation = + !requirements.requiresExplicitConfirmation || input.confirmed === true; + const repairApproval = + !requirements.requiresRepairApproval || input.repairApprovalState === 'approved'; + const reasons: BtdOrganizationAuthorityReason[] = []; + + if (!interfaceAction) reasons.push('interface_action_not_authorized'); + if (!organizationRole) reasons.push('role_missing'); + else if (!role && !permissionGrant) reasons.push('role_insufficient'); + if (requirements.requiresWalletBinding && !walletBinding) reasons.push('wallet_binding_missing'); + if (requirements.requiresRegistryReadAccess && !readAccessDecision) { + reasons.push('registry_read_access_required'); + } else if (requirements.requiresRegistryReadAccess && !registryReadAccess) { + reasons.push('registry_read_access_denied'); + } + if (requirements.requiresSettledPayment && !settlement) reasons.push('settlement_required'); + if (requirements.requiresExplicitConfirmation && !explicitConfirmation) { + reasons.push('explicit_confirmation_required'); + } + if (requirements.requiresRepairApproval && !repairApproval) reasons.push('repair_approval_required'); + + const allowed = + interfaceAction && + (role || permissionGrant) && + walletBinding && + registryReadAccess && + settlement && + explicitConfirmation && + repairApproval; + const successReasons = successAuthorityReasons({ + role, + permissionGrant, + readAccessDecision, + }); + const finalReasons = allowed ? successReasons : reasons; + const decision = allowed ? 'allowed' : 'denied'; + const sourceVisibility = allowed + ? requirements.sourceVisibilityWhenAllowed + : requirements.requiresRegistryReadAccess || requirements.requiresSettledPayment + ? 'blocked' + : 'source_safe_preview'; + const proofRoots = { + roleRoot: stableProofRoot('organization-role', { + organizationId, + actorId, + organizationRole, + permissionGrants, + }), + permissionRoot: stableProofRoot('organization-permission', { + action: input.action, + requirements, + satisfiedByRole: role, + satisfiedByGrant: permissionGrant, + }), + readAccessRoot: stableProofRoot('organization-read-access', readAccessDecision), + interfaceRoot: stableProofRoot('interface-authority', { + interfaceSurface: input.interfaceSurface, + action: input.action, + interfaceAction, + }), + authorityRoot: '', + }; + proofRoots.authorityRoot = stableProofRoot('organization-interface-authority', { + actorId, + organizationId, + organizationRole, + interfaceSurface: input.interfaceSurface, + action: input.action, + decision, + reasons: finalReasons, + sourceVisibility, + targetAnchor, + roleRoot: proofRoots.roleRoot, + permissionRoot: proofRoots.permissionRoot, + readAccessRoot: proofRoots.readAccessRoot, + interfaceRoot: proofRoots.interfaceRoot, + }); + + return { + kind: 'btd_organization_interface_authority_decision', + decision, + actorId, + organizationId, + organizationRole, + interfaceSurface: input.interfaceSurface, + action: input.action, + walletId, + targetAnchor, + readAccessDecision, + requirements, + satisfied: { + role, + permissionGrant, + walletBinding, + registryReadAccess, + settlement, + explicitConfirmation, + repairApproval, + interfaceAction, + }, + reason: finalReasons[0] ?? 'role_authorized', + reasons: finalReasons, + sourceVisibility, + proofRoots, + issuedAt, + }; +} + +function roleSatisfies( + role: BtdOrganizationRole | null, + minimumRole: BtdOrganizationRole, +): boolean { + if (!role) return false; + return ROLE_ORDER[role] >= ROLE_ORDER[minimumRole]; +} + +function isAllowedReadAccessDecision( + decision: BtdAccessDecisionKind | undefined, +): boolean { + return decision === 'owner_read' || decision === 'licensed_read'; +} + +function successAuthorityReasons(input: { + role: boolean; + permissionGrant: boolean; + readAccessDecision: BtdAccessDecision | null; +}): BtdOrganizationAuthorityReason[] { + const reasons: BtdOrganizationAuthorityReason[] = []; + if (input.role) reasons.push('role_authorized'); + if (input.permissionGrant) reasons.push('explicit_permission_grant_authorized'); + if (input.readAccessDecision?.decision === 'owner_read') { + reasons.push('owner_read_access_authorized'); + } + if (input.readAccessDecision?.decision === 'licensed_read') { + reasons.push('licensed_read_access_authorized'); + } + return reasons.length ? reasons : ['role_authorized']; +} + +function readLicenseState( + row: Record, + at: Date, +): 'active' | 'expired' | 'revoked' | 'pending' { + const validFrom = readDateField(row, 'valid_from', 'validFrom'); + const expiresAt = readDateField(row, 'expires_at', 'expiresAt'); + const revokedAt = readDateField(row, 'revoked_at', 'revokedAt'); + + if (revokedAt) return 'revoked'; + if (validFrom && validFrom > at) return 'pending'; + if (expiresAt && expiresAt <= at) return 'expired'; + return 'active'; +} + +function readDateField(row: Record, ...keys: string[]): Date | null { + const value = readOptionalStringField(row, ...keys); + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function readNumberField(row: Record, ...keys: string[]): number | null { + for (const key of keys) { + const value = row[key]; + if (typeof value === 'number' && Number.isSafeInteger(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isSafeInteger(parsed)) return parsed; + } + } + return null; +} + +function readOptionalStringField(row: Record, ...keys: string[]): string | null { + for (const key of keys) { + const value = row[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return null; +} + +function normalizeOptionalString(value: string | null | undefined): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function uniqueStrings(values: Array): string[] { + return Array.from( + new Set( + values + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean), + ), + ); +} + +function assertAuthorityDate(value: string, label: string): Date { + const parsed = new Date(assertNonEmptyString(value, label)); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`${label} must be a valid ISO timestamp.`); + } + return parsed; +} + +function stableProofRoot(scope: string, value: unknown): string { + return `btd-proof-root:${scope}:${stableHash(stableSerialize(value))}`; +} + +function stableSerialize(value: unknown): string { + if (value === null || value === undefined) return String(value); + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`; + if (typeof value === 'object') { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableSerialize(entry)}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function stableHash(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} diff --git a/packages/btd/src/index.ts b/packages/btd/src/index.ts index 8bdcc77a5..113852887 100644 --- a/packages/btd/src/index.ts +++ b/packages/btd/src/index.ts @@ -160,6 +160,7 @@ export * from './bitcoin-provider'; export * from './access'; export * from './allocation'; export * from './ancestry'; +export * from './authority'; export * from './constants'; export * from './deployment-lanes'; export * from './exchange'; diff --git a/packages/chatgptapp/jest.config.cjs b/packages/chatgptapp/jest.config.cjs index 437a99dca..b895a14d5 100644 --- a/packages/chatgptapp/jest.config.cjs +++ b/packages/chatgptapp/jest.config.cjs @@ -1,5 +1,6 @@ const tsJestPreset = require('ts-jest/presets/js-with-ts/jest-preset'); const customModuleNameMapper = { + '^@bitcode/btd$': '/../btd/src/authority.ts', '^@bitcode/tools-generics$': '/src/__stubs__/tools-generics.ts', '^@bitcode/prompts/prompt$': '/../prompts/src/prompt.ts', '^@bitcode/prompts/parts/PromptPart$': '/../prompts/src/parts/PromptPart.ts', diff --git a/packages/chatgptapp/package.json b/packages/chatgptapp/package.json index 24f2397bb..d79e1d5b1 100644 --- a/packages/chatgptapp/package.json +++ b/packages/chatgptapp/package.json @@ -13,6 +13,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "zod": "^3.22.4", + "@bitcode/btd": "workspace:*", "@bitcode/tools-generics": "workspace:*", "@bitcode/github": "workspace:*", "@bitcode/vcs": "workspace:*", @@ -28,6 +29,7 @@ "@types/jest": "^29.5.12", "@types/node": "^20.0.0", "jest": "^29.7.0", - "ts-jest": "^29.1.1" + "ts-jest": "^29.1.1", + "typescript": "^5.8.3" } } diff --git a/packages/chatgptapp/src/__tests__/tools.test.ts b/packages/chatgptapp/src/__tests__/tools.test.ts index d78996190..2296f41cf 100644 --- a/packages/chatgptapp/src/__tests__/tools.test.ts +++ b/packages/chatgptapp/src/__tests__/tools.test.ts @@ -24,6 +24,13 @@ const READ_ACCESS = { accessPolicyHash: 'policy-hash', reason: 'wallet_has_valid_policy_matching_license', } as const; +const ORGANIZATION_AUTHORITY = { + organizationId: 'org-1', + organizationRole: 'member', + organizationPermissionGrants: ['asset_pack:deliver'], + walletId: 'wallet-reader', + settlementState: 'settled', +} as const; describe('ChatGPT App tools', () => { beforeEach(() => { @@ -93,6 +100,7 @@ This product delivers voice-first social conversations for builders. expect(tool.meta?.requiresConfirmation).toBe(true); expect((tool.inputSchema as any).required).toContain('confirmed'); expect((tool.inputSchema as any).required).toContain('readAccess'); + expect((tool.inputSchema as any).required).toContain('organizationAuthority'); expect((tool.inputSchema as any).properties.confirmed).toMatchObject({ type: 'boolean', const: true, @@ -100,6 +108,9 @@ This product delivers voice-first social conversations for builders. expect((tool.inputSchema as any).properties.readAccess).toMatchObject({ type: 'object', }); + expect((tool.inputSchema as any).properties.organizationAuthority).toMatchObject({ + type: 'object', + }); } }); @@ -184,6 +195,7 @@ This product delivers voice-first social conversations for builders. request: 'deploy_to_vercel', confirmed: true, readAccess: READ_ACCESS, + organizationAuthority: ORGANIZATION_AUTHORITY, payload: { projectId: 'prj_Yapper', teamId: 'team_bitcode', message: 'Demo deploy' }, } ); @@ -198,6 +210,12 @@ This product delivers voice-first social conversations for builders. outputMeaning: 'asset_pack_delivery_mechanism', targetAnchor: 'vercel:team_bitcode/prj_Yapper', readAccess: READ_ACCESS, + organizationAuthority: expect.objectContaining({ + decision: 'allowed', + interfaceSurface: 'chatgpt_app', + action: 'deliver_asset_pack', + sourceVisibility: 'protected_source_allowed', + }), }); expect((result.result as any).readyState).toBe('BUILDING'); }); @@ -209,6 +227,7 @@ This product delivers voice-first social conversations for builders. operation: 'createRepository', confirmed: true, readAccess: READ_ACCESS, + organizationAuthority: ORGANIZATION_AUTHORITY, accessToken: 'ghp_mock', name: 'bitcode-yapper', description: 'Bitcode source-to-shares terminal companion fixture', @@ -230,6 +249,11 @@ This product delivers voice-first social conversations for builders. outputMeaning: 'asset_pack_delivery_mechanism', targetAnchor: 'github:bitcode-yapper', readAccess: READ_ACCESS, + organizationAuthority: expect.objectContaining({ + decision: 'allowed', + interfaceSurface: 'chatgpt_app', + action: 'deliver_asset_pack', + }), }); }); @@ -250,6 +274,7 @@ This product delivers voice-first social conversations for builders. runTool('use_vercel_write_external_mcp', { request: 'deploy_to_vercel', readAccess: READ_ACCESS, + organizationAuthority: ORGANIZATION_AUTHORITY, payload: { projectId: 'prj_Yapper', teamId: 'team_bitcode', message: 'Demo deploy' }, }) ).rejects.toThrow( @@ -262,11 +287,38 @@ This product delivers voice-first social conversations for builders. runTool('use_vercel_write_external_mcp', { request: 'deploy_to_vercel', confirmed: true, + organizationAuthority: ORGANIZATION_AUTHORITY, payload: { projectId: 'prj_Yapper', teamId: 'team_bitcode', message: 'Demo deploy' }, }) ).rejects.toThrow(/readAccess/); }); + it('rejects ChatGPT App connected-interface writes without organization authority evidence', async () => { + await expect( + runTool('use_vercel_write_external_mcp', { + request: 'deploy_to_vercel', + confirmed: true, + readAccess: READ_ACCESS, + payload: { projectId: 'prj_Yapper', teamId: 'team_bitcode', message: 'Demo deploy' }, + }) + ).rejects.toThrow(/organizationAuthority/); + }); + + it('rejects ChatGPT App connected-interface writes when organization authority is unpaid', async () => { + await expect( + runTool('use_vercel_write_external_mcp', { + request: 'deploy_to_vercel', + confirmed: true, + readAccess: READ_ACCESS, + organizationAuthority: { + ...ORGANIZATION_AUTHORITY, + settlementState: 'pending', + }, + payload: { projectId: 'prj_Yapper', teamId: 'team_bitcode', message: 'Demo deploy' }, + }) + ).rejects.toThrow(/settlement_required/); + }); + it('use_aws_read_external_mcp invokes lambda helper', async () => { const result = await runTool<{ metadata: { provider: string; guidance: string } }>('use_aws_read_external_mcp', { request: 'lambda.invoke', @@ -281,6 +333,7 @@ This product delivers voice-first social conversations for builders. request: 's3.putObject', confirmed: true, readAccess: READ_ACCESS, + organizationAuthority: ORGANIZATION_AUTHORITY, payload: { bucket: 'demo', key: 'config.json', body: '{}' }, }); expect(result.metadata.provider).toBe('aws'); @@ -293,6 +346,10 @@ This product delivers voice-first social conversations for builders. operation: 's3.putObject', targetAnchor: 'aws:s3/demo/config.json', readAccess: READ_ACCESS, + organizationAuthority: expect.objectContaining({ + decision: 'allowed', + interfaceSurface: 'chatgpt_app', + }), }); }); diff --git a/packages/chatgptapp/src/tools.ts b/packages/chatgptapp/src/tools.ts index d9bfd9449..ed39ae621 100644 --- a/packages/chatgptapp/src/tools.ts +++ b/packages/chatgptapp/src/tools.ts @@ -9,6 +9,11 @@ import { awsCloudWatchLogTool, awsDynamoGetItemTool, awsDynamoPutItemTool, awsLa import { simpleSystemTextSearch } from '@bitcode/generic-tools-simple-system-text-search'; import { search as webSearch } from '@bitcode/generic-tools-web-search'; import { generateDigest } from '@bitcode/digest/run'; +import { + evaluateBtdOrganizationInterfaceAuthority, + type BtdOrganizationRole, + type BtdSettlementAuthorityState, +} from '@bitcode/btd'; import { vercelGetDeploymentEventsTool, vercelGetDeploymentTool, @@ -56,6 +61,7 @@ type ChatGptAppWriteAdmissionInput = { operation: string; targetAnchor?: string; readAccess: ChatGptAppReadAccessDecision; + organizationAuthority: ReturnType; }; type ChatGptAppReadAccessDecision = { @@ -66,6 +72,15 @@ type ChatGptAppReadAccessDecision = { reason: string; }; +type ChatGptAppOrganizationAuthorityInput = { + organizationId: string; + organizationRole: BtdOrganizationRole; + organizationPermissionGrants?: string[]; + walletId?: string; + settlementState: BtdSettlementAuthorityState; + repairApprovalState?: 'not_required' | 'required' | 'approved'; +}; + const CHATGPT_APP_READ_ACCESS_VALIDATOR = z.object({ assetPackId: z.string().min(1, 'assetPackId must be provided'), walletId: z.string().min(1, 'walletId must be provided'), @@ -74,6 +89,15 @@ const CHATGPT_APP_READ_ACCESS_VALIDATOR = z.object({ reason: z.string().min(1, 'reason must be provided') }).strict(); +const CHATGPT_APP_ORGANIZATION_AUTHORITY_VALIDATOR = z.object({ + organizationId: z.string().min(1, 'organizationId must be provided'), + organizationRole: z.enum(['viewer', 'member', 'admin', 'owner']), + organizationPermissionGrants: z.array(z.string()).default([]), + walletId: z.string().optional(), + settlementState: z.enum(['not_required', 'pending', 'settled']), + repairApprovalState: z.enum(['not_required', 'required', 'approved']).optional() +}).strict(); + const CHATGPT_APP_READ_ACCESS_SCHEMA = { type: 'object', additionalProperties: false, @@ -103,6 +127,42 @@ const CHATGPT_APP_READ_ACCESS_SCHEMA = { required: ['assetPackId', 'walletId', 'decision', 'accessPolicyHash', 'reason'] }; +const CHATGPT_APP_ORGANIZATION_AUTHORITY_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + organizationId: { + type: 'string', + description: 'Organization id whose role and grants authorize the ChatGPT connected-interface write' + }, + organizationRole: { + type: 'string', + enum: ['viewer', 'member', 'admin', 'owner'], + description: 'Registry-derived organization role for the actor' + }, + organizationPermissionGrants: { + type: 'array', + items: { type: 'string' }, + description: 'Optional registry-derived organization permission grants' + }, + walletId: { + type: 'string', + description: 'Wallet id bound to the actor; defaults to readAccess.walletId when omitted' + }, + settlementState: { + type: 'string', + enum: ['not_required', 'pending', 'settled'], + description: 'Settlement state for protected-source delivery' + }, + repairApprovalState: { + type: 'string', + enum: ['not_required', 'required', 'approved'], + description: 'Repair approval state when the write repairs a projection' + } + }, + required: ['organizationId', 'organizationRole', 'settlementState'] +}; + function assertConfirmedConnectedInterfaceWrite(confirmed: unknown): void { if (confirmed !== true) { throw new Error( @@ -128,6 +188,45 @@ function assertChatGptAppReadAccess(readAccess: unknown): ChatGptAppReadAccessDe return parsed.data as ChatGptAppReadAccessDecision; } +function assertChatGptAppOrganizationAuthority( + organizationAuthority: unknown, + input: { + operation: string; + targetAnchor?: string; + readAccess: ChatGptAppReadAccessDecision; + } +) { + const parsed = CHATGPT_APP_ORGANIZATION_AUTHORITY_VALIDATOR.safeParse(organizationAuthority); + if (!parsed.success) { + throw new Error( + 'Bitcode ChatGPT App write admission requires organizationAuthority role, settlement, and registry grant evidence.' + ); + } + + const decision = evaluateBtdOrganizationInterfaceAuthority({ + actorId: 'chatgpt_app_connected_interface', + organizationId: parsed.data.organizationId, + organizationRole: parsed.data.organizationRole, + organizationPermissionGrants: parsed.data.organizationPermissionGrants, + interfaceSurface: 'chatgpt_app', + action: 'deliver_asset_pack', + walletId: parsed.data.walletId ?? input.readAccess.walletId, + readAccessDecision: input.readAccess as any, + settlementState: parsed.data.settlementState, + confirmed: true, + repairApprovalState: parsed.data.repairApprovalState, + targetAnchor: input.targetAnchor ?? input.operation, + }); + + if (decision.decision !== 'allowed') { + throw new Error( + `Bitcode ChatGPT App write admission denied by organization authority: ${decision.reasons.join(', ')}.` + ); + } + + return decision; +} + function buildChatGptAppWriteAdmission(input: ChatGptAppWriteAdmissionInput) { return { admitted: true, @@ -140,7 +239,8 @@ function buildChatGptAppWriteAdmission(input: ChatGptAppWriteAdmissionInput) { connectedInterface: input.connectedInterface, operation: input.operation, targetAnchor: input.targetAnchor, - readAccess: input.readAccess + readAccess: input.readAccess, + organizationAuthority: input.organizationAuthority }; } @@ -720,6 +820,7 @@ const WRITE_CODE_CHANGES_VALIDATOR = z.object({ operation: z.enum(['createRepository', 'createOrUpdateFile']).describe('Write operation to perform'), confirmed: z.literal(true).describe('Explicit user confirmation required before Bitcode ChatGPT App connected-interface writes execute'), readAccess: CHATGPT_APP_READ_ACCESS_VALIDATOR.describe('Registry owner-read or licensed-read evidence required before connected-interface writes execute'), + organizationAuthority: CHATGPT_APP_ORGANIZATION_AUTHORITY_VALIDATOR.describe('Organization role and settlement evidence required before ChatGPT App delivery writes execute'), accessToken: z.string().min(1, 'accessToken must be provided').describe('GitHub access token with repo scope'), owner: z.string().optional().describe('Owner for file operations (required for createOrUpdateFile)'), repo: z.string().optional().describe('Repo for file operations (required for createOrUpdateFile)'), @@ -745,6 +846,12 @@ async function executeWriteCodeChanges(args: z.infer; switch (args.request) { case 'deploy_to_vercel': + targetAnchor = `vercel:${args.payload.teamId ?? 'team_bitcode'}/${args.payload.projectId ?? 'prj_Yapper'}`; + organizationAuthority = assertChatGptAppOrganizationAuthority(args.organizationAuthority, { + operation: args.request, + targetAnchor, + readAccess + }); result = await vercelDeployProjectTool.execute({ projectId: args.payload.projectId ?? 'prj_Yapper', teamId: args.payload.teamId ?? 'team_bitcode', message: args.payload.message }); - targetAnchor = `vercel:${args.payload.teamId ?? 'team_bitcode'}/${args.payload.projectId ?? 'prj_Yapper'}`; guidance = 'Deployment requested—explain the pending status and remind the user to monitor readiness.'; break; case 'buy_domain': + targetAnchor = `vercel:domain/${args.payload.name ?? 'yapper.app'}`; + organizationAuthority = assertChatGptAppOrganizationAuthority(args.organizationAuthority, { + operation: args.request, + targetAnchor, + readAccess + }); result = await vercelBuyDomainTool.execute({ name: args.payload.name ?? 'yapper.app', expectedPrice: args.payload.expectedPrice, @@ -1120,14 +1249,18 @@ async function executeUseVercelWriteExternalMcp(args: z.infer; switch (args.request) { case 's3.putObject': - result = await awsS3PutObjectTool.execute(args.payload as Parameters[0]); targetAnchor = `aws:s3/${String(args.payload.bucket ?? 'bitcode-demo')}/${String(args.payload.key ?? 'config/demo.json')}`; + organizationAuthority = assertChatGptAppOrganizationAuthority(args.organizationAuthority, { + operation: args.request, + targetAnchor, + readAccess + }); + result = await awsS3PutObjectTool.execute(args.payload as Parameters[0]); guidance = 'Uploaded to S3—confirm the path in follow-up so collaborators can fetch it easily.'; break; case 'dynamo.putItem': + targetAnchor = `aws:dynamodb/${String(args.payload.table ?? 'bitcode-demo-table')}`; + organizationAuthority = assertChatGptAppOrganizationAuthority(args.organizationAuthority, { + operation: args.request, + targetAnchor, + readAccess + }); result = await awsDynamoPutItemTool.execute( args.payload as Parameters[0] ); - targetAnchor = `aws:dynamodb/${String(args.payload.table ?? 'bitcode-demo-table')}`; guidance = 'Stored the record in DynamoDB; let’s document the schema impact in PRODUCT.md.'; break; default: @@ -1281,7 +1428,8 @@ async function executeUseAwsWriteExternalMcp(args: z.infer/../../../../', +}); +delete rootMapper['^@bitcode/(.*)$']; + +const explicitMapper = { + '^@bitcode/pipelines-generics$': '/../../../../packages/pipelines-generics/src/index.ts', + '^@bitcode/pipelines-generics/src/llm/dry_running/config$': '/src/__stubs__/dry-run-config.ts', + '^@bitcode/pipelines-generics/src/(.*)$': '/../../../../packages/pipelines-generics/src/$1', + '^@bitcode/pipelines-generics/(.*)$': '/../../../../packages/pipelines-generics/src/$1', + '^@bitcode/pipelines/asset-pack$': '/../../../../packages/pipelines/asset-pack/src/index.ts', + '^@bitcode/agent-generics$': '/../../../../packages/agent-generics/src/index.ts', + '^@bitcode/agent-generics/(.*)$': '/../../../../packages/agent-generics/src/$1', + '^@bitcode/generic-llms$': '/../../../../packages/generic-llms/src/index.ts', + '^@bitcode/generic-llms/(.*)$': '/../../../../packages/generic-llms/src/$1', + '^@bitcode/llm-generics$': '/../../../../packages/llm-generics/src/index.ts', + '^@bitcode/llm-generics/(.*)$': '/../../../../packages/llm-generics/src/$1', + '^@bitcode/execution-generics$': '/../../../../packages/execution-generics/src/index.ts', + '^@bitcode/execution-generics/(.*)$': '/../../../../packages/execution-generics/src/$1', + '^@bitcode/tools-generics$': '/../../../../packages/tools-generics/src/index.ts', + '^@bitcode/tools-generics/(.*)$': '/../../../../packages/tools-generics/src/$1', + '^@bitcode/registry$': '/../../../../packages/registry/src/index.ts', + '^@bitcode/registry/(.*)$': '/../../../../packages/registry/src/$1', + '^@bitcode/prompts$': '/../../../../packages/prompts/src/index.ts', + '^@bitcode/prompts/(.*)$': '/../../../../packages/prompts/src/$1', + '^@bitcode/doc-comment$': '/../../../../packages/doc-comment/src/index.ts', + '^@bitcode/doc-comment/(.*)$': '/../../../../packages/doc-comment/src/$1', + '^@bitcode/vcs-tools$': '/../../../../packages/generic-tools/vcs/src/index.ts', + '^@bitcode/editing$': '/../../../../packages/editing/src/index.ts', + '^@bitcode/editing/(.*)$': '/../../../../packages/editing/src/$1', + '^@bitcode/generic-tools/(.*)/src/(.*)$': '/../../../../packages/generic-tools/$1/src/$2', + '^@bitcode/supabase$': '/../../../../packages/supabase/src/index.ts', + '^@bitcode/supabase/src/ssr/admin$': '/../../../../packages/supabase/src/ssr/admin.ts', + '^@bitcode/supabase/(.*)$': '/../../../../packages/supabase/src/$1', + '^@bitcode/artifacts$': '/../../../../packages/artifacts/src/artifacts.ts', + '^@bitcode/logger$': '/../../../../packages/logger/src/logger.ts', + '^@bitcode/observability$': '/../../../../packages/observability/src/observability.ts', + '^@bitcode/errors$': '/../../../../packages/errors/src/errors.ts', + '^@bitcode/sentry$': '/../../../../packages/sentry/src/sentry.ts', + '^@bitcode/streams$': '/../../../../packages/streams/src/index.ts', + '^@bitcode/orm$': '/../../../../packages/orm/src/index.ts', + '^@bitcode/jira-tools$': '/../../../../packages/jira/src/index.ts', + '^@bitcode/pipeline-recovery$': '/src/__stubs__/pipeline-recovery.ts', + '^@bitcode/mcp-server$': '/src/index.ts', +}; + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + setupFilesAfterEnv: ['/src/__tests__/setup/mcpTestSetup.ts'], + testMatch: ['/src/__tests__/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.test.json', + diagnostics: false, + }, + ], + }, + moduleNameMapper: { + ...explicitMapper, + ...rootMapper, + }, + modulePathIgnorePatterns: ['/dist'], + testTimeout: 30000, +}; diff --git a/packages/executions-mcp/src/mcp-server/src/__tests__/unit/auth.test.ts b/packages/executions-mcp/src/mcp-server/src/__tests__/unit/auth.test.ts index 16adafc64..ad917521e 100644 --- a/packages/executions-mcp/src/mcp-server/src/__tests__/unit/auth.test.ts +++ b/packages/executions-mcp/src/mcp-server/src/__tests__/unit/auth.test.ts @@ -354,6 +354,165 @@ describe('Authentication Middleware', () => { statusCode: 403 }); }); + + it('admits MCP delivery only when organization authority and registry read access agree', async () => { + mockGetByKeyHash.mockResolvedValue({ + id: 'key123', + user_id: 'user123', + name: 'Bitcode Test Key', + scopes: ['resources:read'], + expires_at: null + }); + mockUpdateLastUsed.mockResolvedValue(undefined); + mockGetById + .mockResolvedValueOnce({ + id: 'user123', + email: 'operator@example.com', + full_name: 'Bitcode Operator', + organization_id: 'org123' + }) + .mockResolvedValueOnce({ + id: 'org123', + name: 'Bitcode Labs', + slug: 'bitcode-labs' + }); + mockGetMembership.mockResolvedValue({ + role: 'member', + permissions: { + resources: ['read'], + asset_pack: ['deliver'] + } + }); + mockGetProfileByUserId.mockResolvedValue({ + wallet_binding: { + address: 'wallet-reader', + status: 'verified' + } + }); + mockReadBtdHoldingAmount.mockResolvedValue(0); + mockGetAssetPackRange.mockResolvedValue({ + asset_pack_id: 'asset-pack-1', + range_start: 0, + range_end_exclusive: 5, + token_count: 5, + access_policy_id: 'policy-1', + access_policy_hash: 'policy-hash' + }); + mockListOwnershipClaims.mockResolvedValue([]); + mockListReadLicenses.mockResolvedValue([ + { + license_id: 'license-1', + wallet_id: 'wallet-reader', + asset_pack_id: 'asset-pack-1', + access_policy_hash: 'policy-hash', + valid_from: '2026-05-01T00:00:00.000Z' + } + ]); + + const result = await authenticateMCPRequest('Bearer key_test123', { + requireOrganization: true, + requiredPermissions: { resources: ['read'] }, + requiredReadAccess: { + assetPackId: 'asset-pack-1', + at: '2026-05-19T00:00:00.000Z' + }, + requiredInterfaceAuthority: { + action: 'deliver_asset_pack', + settlementState: 'settled', + confirmed: true, + targetAnchor: 'github:engineeredsoftware/ENGI/pull/42', + at: '2026-05-19T00:00:00.000Z' + } + }); + + expect(result.success).toBe(true); + expect(result.context?.interfaceAuthority).toEqual([ + expect.objectContaining({ + interfaceSurface: 'mcp', + action: 'deliver_asset_pack', + decision: 'allowed', + sourceVisibility: 'protected_source_allowed' + }) + ]); + }); + + it('rejects MCP delivery when interface authority is missing settlement and confirmation', async () => { + mockGetByKeyHash.mockResolvedValue({ + id: 'key123', + user_id: 'user123', + name: 'Bitcode Test Key', + scopes: ['resources:read'], + expires_at: null + }); + mockUpdateLastUsed.mockResolvedValue(undefined); + mockGetById + .mockResolvedValueOnce({ + id: 'user123', + email: 'operator@example.com', + full_name: 'Bitcode Operator', + organization_id: 'org123' + }) + .mockResolvedValueOnce({ + id: 'org123', + name: 'Bitcode Labs', + slug: 'bitcode-labs' + }); + mockGetMembership.mockResolvedValue({ + role: 'member', + permissions: { + resources: ['read'], + asset_pack: ['deliver'] + } + }); + mockGetProfileByUserId.mockResolvedValue({ + wallet_binding: { + address: 'wallet-reader', + status: 'verified' + } + }); + mockReadBtdHoldingAmount.mockResolvedValue(0); + mockGetAssetPackRange.mockResolvedValue({ + asset_pack_id: 'asset-pack-1', + range_start: 0, + range_end_exclusive: 5, + token_count: 5, + access_policy_id: 'policy-1', + access_policy_hash: 'policy-hash' + }); + mockListOwnershipClaims.mockResolvedValue([]); + mockListReadLicenses.mockResolvedValue([ + { + license_id: 'license-1', + wallet_id: 'wallet-reader', + asset_pack_id: 'asset-pack-1', + access_policy_hash: 'policy-hash', + valid_from: '2026-05-01T00:00:00.000Z' + } + ]); + + const result = await authenticateMCPRequest('Bearer key_test123', { + requireOrganization: true, + requiredPermissions: { resources: ['read'] }, + requiredReadAccess: { + assetPackId: 'asset-pack-1', + at: '2026-05-19T00:00:00.000Z' + }, + requiredInterfaceAuthority: { + action: 'deliver_asset_pack', + settlementState: 'pending', + confirmed: false, + at: '2026-05-19T00:00:00.000Z' + } + }); + + expect(result.success).toBe(false); + expect(result.error).toMatchObject({ + code: 'INSUFFICIENT_INTERFACE_AUTHORITY', + statusCode: 403 + }); + expect(result.error?.message).toContain('settlement_required'); + expect(result.error?.message).toContain('explicit_confirmation_required'); + }); }); describe('validatePermissions', () => { diff --git a/packages/executions-mcp/src/mcp-server/src/auth/middleware.ts b/packages/executions-mcp/src/mcp-server/src/auth/middleware.ts index 8b1f7f399..e9215efae 100644 --- a/packages/executions-mcp/src/mcp-server/src/auth/middleware.ts +++ b/packages/executions-mcp/src/mcp-server/src/auth/middleware.ts @@ -25,8 +25,12 @@ import { } from '@bitcode/orm'; import { buildBtdReadAccessProjectionFromRegistryRows, + evaluateBtdOrganizationInterfaceAuthority, evaluateBtdReadAccess, + type BtdOrganizationPermissionAction, + type BtdRepairApprovalState, type BtdAccessDecisionKind, + type BtdSettlementAuthorityState, } from '@bitcode/btd'; import type { MCPAuthContext } from '../types'; import * as crypto from 'crypto'; @@ -62,6 +66,7 @@ export interface MCPAuthOptions { */ minimumBtdHolding?: number; requiredReadAccess?: MCPReadAccessRequirement | MCPReadAccessRequirement[]; + requiredInterfaceAuthority?: MCPInterfaceAuthorityRequirement | MCPInterfaceAuthorityRequirement[]; } export interface MCPReadAccessRequirement { @@ -71,6 +76,15 @@ export interface MCPReadAccessRequirement { at?: string; } +export interface MCPInterfaceAuthorityRequirement { + action: BtdOrganizationPermissionAction; + targetAnchor?: string; + settlementState?: BtdSettlementAuthorityState; + confirmed?: boolean; + repairApprovalState?: BtdRepairApprovalState; + at?: string; +} + type MCPWalletProfileSource = UserProfile & Record; export const authCache = new LRUCache(10000); @@ -199,6 +213,7 @@ export async function authenticateMCPRequest( btdBalance: 0, walletId: walletBinding?.address ?? undefined, btdReadAccess: [], + interfaceAuthority: [], mcpCredentials: {} } as MCPAuthContext; @@ -341,9 +356,17 @@ async function validateAuthenticatedContext( return readAccessResult; } + const interfaceAuthorityResult = validateRequiredInterfaceAuthority( + readAccessResult.context ?? context, + options, + ); + if (!interfaceAuthorityResult.success) { + return interfaceAuthorityResult; + } + return { success: true, - context: readAccessResult.context ?? context + context: interfaceAuthorityResult.context ?? readAccessResult.context ?? context }; } @@ -443,6 +466,111 @@ function normalizeReadAccessRequirements( return Array.isArray(input) ? input : [input]; } +function validateRequiredInterfaceAuthority( + context: MCPAuthContext, + options: MCPAuthOptions, +): AuthResult { + const requirements = normalizeInterfaceAuthorityRequirements(options.requiredInterfaceAuthority); + if (requirements.length === 0) { + return { success: true, context }; + } + + if (!context.organizationId) { + return { + success: false, + error: { + code: 'ORGANIZATION_AUTHORITY_REQUIRED', + message: 'MCP interface authority requires an organization-scoped actor.', + statusCode: 403, + }, + }; + } + + const authorityDecisions = []; + for (const requirement of requirements) { + const readAccess = firstAllowedReadAccess(context); + const decision = evaluateBtdOrganizationInterfaceAuthority({ + actorId: context.userId, + organizationId: context.organizationId, + organizationRole: context.organizationRole, + organizationPermissionGrants: flattenOrganizationPermissionGrants(context), + interfaceSurface: 'mcp', + action: requirement.action, + walletId: context.walletId, + readAccessDecision: readAccess + ? { + decision: readAccess.decision, + accessPolicyHash: readAccess.accessPolicyHash, + reason: readAccess.reason as any, + } + : null, + settlementState: requirement.settlementState, + confirmed: requirement.confirmed, + repairApprovalState: requirement.repairApprovalState, + targetAnchor: requirement.targetAnchor, + at: requirement.at, + }); + + authorityDecisions.push({ + interfaceSurface: 'mcp' as const, + action: requirement.action, + decision: decision.decision, + reason: decision.reason, + authorityRoot: decision.proofRoots.authorityRoot, + sourceVisibility: decision.sourceVisibility, + }); + + if (decision.decision !== 'allowed') { + return { + success: false, + error: { + code: 'INSUFFICIENT_INTERFACE_AUTHORITY', + message: `MCP interface authority denied ${requirement.action}: ${decision.reasons.join(', ')}.`, + statusCode: 403, + }, + }; + } + } + + return { + success: true, + context: { + ...context, + interfaceAuthority: [...(context.interfaceAuthority ?? []), ...authorityDecisions], + }, + }; +} + +function normalizeInterfaceAuthorityRequirements( + input: MCPAuthOptions['requiredInterfaceAuthority'], +): MCPInterfaceAuthorityRequirement[] { + if (!input) { + return []; + } + + return Array.isArray(input) ? input : [input]; +} + +function firstAllowedReadAccess(context: MCPAuthContext) { + return context.btdReadAccess?.find( + (decision) => decision.decision === 'owner_read' || decision.decision === 'licensed_read', + ) ?? null; +} + +function flattenOrganizationPermissionGrants(context: MCPAuthContext): string[] { + const grants = [...(context.scopes ?? [])]; + const organizationPermissions = context.organizationPermissions ?? {}; + for (const [resource, permissions] of Object.entries(organizationPermissions)) { + if (!Array.isArray(permissions)) continue; + for (const permission of permissions) { + if (typeof permission === 'string' && permission.trim()) { + grants.push(`${resource}:${permission.trim()}`); + } + } + } + return Array.from(new Set(grants)); +} + function getMissingPermissions( context: MCPAuthContext, requiredPermissions: MCPAuthOptions['requiredPermissions'] = {} diff --git a/packages/executions-mcp/src/mcp-server/src/types/index.ts b/packages/executions-mcp/src/mcp-server/src/types/index.ts index 752e019ac..8a3a672c0 100644 --- a/packages/executions-mcp/src/mcp-server/src/types/index.ts +++ b/packages/executions-mcp/src/mcp-server/src/types/index.ts @@ -70,6 +70,14 @@ export interface MCPAuthContext { reason: string; accessPolicyHash: string; }>; + interfaceAuthority?: Array<{ + interfaceSurface: 'mcp'; + action: string; + decision: 'allowed' | 'denied'; + reason: string; + authorityRoot: string; + sourceVisibility: string; + }>; btdBalance: number; mcpCredentials: Record; } diff --git a/packages/pipeline-hosts/src/__tests__/asset-pack-harness.test.ts b/packages/pipeline-hosts/src/__tests__/asset-pack-harness.test.ts index 3c3e60bc4..7ae39d76d 100644 --- a/packages/pipeline-hosts/src/__tests__/asset-pack-harness.test.ts +++ b/packages/pipeline-hosts/src/__tests__/asset-pack-harness.test.ts @@ -206,6 +206,10 @@ describe('asset-pack sandbox harness plan', () => { expect(source).toContain("execution.store('asset-pack/preview', 'feeQuote'"); expect(source).toContain('reconcileLedgerDatabaseProjection'); expect(source).toContain("execution.store('asset-pack/settlement', 'ledgerDatabaseReconciliation'"); + expect(source).toContain("execution.store('asset-pack/settlement', 'organizationAuthority'"); + expect(source).toContain('organizationAuthorityDecision'); + expect(source).toContain('organizationAuthorityRoot'); + expect(source).toContain('organizationAuthority,'); expect(source).toContain('ledgerDatabaseReconciliation,'); expect(source).toContain('repairActionCount'); expect(source).toContain('ledgerSettlement,'); diff --git a/packages/pipeline-hosts/src/asset-pack-harness.ts b/packages/pipeline-hosts/src/asset-pack-harness.ts index c8a1a23d1..754856051 100644 --- a/packages/pipeline-hosts/src/asset-pack-harness.ts +++ b/packages/pipeline-hosts/src/asset-pack-harness.ts @@ -1571,16 +1571,18 @@ try { resolveReadingPipelineTelemetryProjection, summarizeReadingPipelineObservabilityCoverage, synthesizeReadNeedForPipelineInput, - }, - { enablePipelineStreaming, factoryPipelineExecution }, - { applyAssetPackSettlementUnlockToPreview, buildAssetPackSettlementUnlock }, - { reconcileLedgerDatabaseProjection }, - ] = await Promise.all([ - import('../../packages/pipelines/asset-pack/src/index'), - import('../../packages/pipelines-generics/src/index'), - import('../../packages/btd/src/settlement'), - import('../../packages/btd/src/reconciliation'), - ]); + }, + { enablePipelineStreaming, factoryPipelineExecution }, + { applyAssetPackSettlementUnlockToPreview, buildAssetPackSettlementUnlock }, + { reconcileLedgerDatabaseProjection }, + { evaluateBtdOrganizationInterfaceAuthority }, + ] = await Promise.all([ + import('../../packages/pipelines/asset-pack/src/index'), + import('../../packages/pipelines-generics/src/index'), + import('../../packages/btd/src/settlement'), + import('../../packages/btd/src/reconciliation'), + import('../../packages/btd/src/authority'), + ]); readingPipelineObservabilityInventory = buildReadingPipelineObservabilityInventory(); resolveReadingPipelineTelemetryProjectionFn = resolveReadingPipelineTelemetryProjection; summarizeReadingPipelineObservabilityCoverageFn = summarizeReadingPipelineObservabilityCoverage; @@ -1827,14 +1829,38 @@ try { reason: settlementUnlock.reason, }); assertAssetPackDisclosureSourceSafe(assetPackDisclosureReview); + const organizationAuthority = [ + evaluateBtdOrganizationInterfaceAuthority({ + actorId: userId || readerWalletId, + organizationId: manifest.organizationId || manifest.organization?.id || 'staging-testnet-organization', + organizationRole: 'admin', + organizationPermissionGrants: ['asset_pack:deliver'], + interfaceSurface: 'terminal', + action: 'deliver_asset_pack', + walletId: readerWalletId, + targetAnchor: pullRequestUrl || null, + readAccessDecision: settlementUnlock.sourceAvailable + ? { + decision: 'licensed_read', + accessPolicyHash: ledgerSettlement.accessPolicyHash || sourceSafePreview?.accessPolicy?.accessPolicyHash || 'policy-pending', + reason: settlementUnlock.reason, + } + : null, + settlementState: settlementUnlock.sourceAvailable ? 'settled' : 'pending', + confirmed: ledgerSettlement.settlementAdmissible === true, + repairApprovalState: 'not_required', + }), + ]; execution.store('asset-pack/preview', 'sourceSafe', settledSourceSafePreview); execution.store('asset-pack/preview', 'disclosureReview', assetPackDisclosureReview); execution.store('asset-pack/settlement', 'unlock', settlementUnlock); execution.store('asset-pack/settlement', 'readLicenseId', settlementUnlock.readLicenseId); + execution.store('asset-pack/settlement', 'organizationAuthority', organizationAuthority); output = { ...(output || {}), sourceSafePreview: settledSourceSafePreview, assetPackDisclosureReview, + organizationAuthority, ledgerSettlement: { ...ledgerSettlement, protectedSourceUnlock: settlementUnlock, @@ -1855,6 +1881,8 @@ try { assetPackId: ledgerSettlement.assetPackId || null, reconciliationState: ledgerDatabaseReconciliation?.state || null, repairActionCount: ledgerDatabaseReconciliation?.repairActions?.length || 0, + organizationAuthorityDecision: organizationAuthority[0].decision, + organizationAuthorityRoot: organizationAuthority[0].proofRoots.authorityRoot, }); const readingPipelineObservabilityCoverage = summarizeReadingPipelineObservabilityCoverageFn ? summarizeReadingPipelineObservabilityCoverageFn(events) @@ -1880,6 +1908,7 @@ try { deliveryMechanism: output?.deliveryMechanism || null, shippables: output?.shippables || null, ledgerSettlement: output.ledgerSettlement, + organizationAuthority, ledgerDatabaseReconciliation, execution: summarizeExecution(execution), readingPipelineObservabilityInventory, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf11af1d6..7c6460e0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,10 +76,10 @@ importers: version: 29.5.14 jest: specifier: ^29.0.0 - version: 29.7.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.8.3)) + version: 29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.8.3)) ts-jest: specifier: ^29.0.0 - version: 29.4.0(@babel/core@7.27.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.4.0(@babel/core@7.27.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.8.3)))(typescript@5.8.3) typescript: specifier: ^5.0.0 version: 5.8.3 @@ -286,6 +286,9 @@ importers: packages/chatgptapp: dependencies: + '@bitcode/btd': + specifier: workspace:* + version: link:../btd '@bitcode/digest': specifier: workspace:* version: link:../digest @@ -331,10 +334,13 @@ importers: version: 20.19.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.2)(typescript@6.0.3)) + version: 29.7.0(@types/node@20.19.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.2)(typescript@5.8.3)) ts-jest: specifier: ^29.1.1 - version: 29.4.0(@babel/core@7.27.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.2)(typescript@6.0.3)))(typescript@6.0.3) + version: 29.4.0(@babel/core@7.27.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.7))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.2)(typescript@5.8.3)))(typescript@5.8.3) + typescript: + specifier: ^5.8.3 + version: 5.8.3 packages/circleci: {} diff --git a/scripts/check-v29-gate7-organization-permission-authority.mjs b/scripts/check-v29-gate7-organization-permission-authority.mjs new file mode 100644 index 000000000..f2a604eea --- /dev/null +++ b/scripts/check-v29-gate7-organization-permission-authority.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); + +function read(root, relativePath) { + return readFileSync(path.join(root, relativePath), 'utf8'); +} + +function fileExists(root, relativePath) { + return existsSync(path.join(root, relativePath)); +} + +function git(root, args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); +} + +function assertCheck(failures, condition, message) { + if (!condition) failures.push(message); +} + +function parseArgs(argv) { + const args = { + skipBranchCheck: false, + repoRoot, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--skip-branch-check') args.skipBranchCheck = true; + else if (arg === '--repo-root') args.repoRoot = path.resolve(argv[++index]); + else if (arg === '--help' || arg === '-h') args.help = true; + else throw new Error(`Unknown argument ${arg}`); + } + + return args; +} + +function printHelp() { + process.stdout.write( + [ + 'Usage: node scripts/check-v29-gate7-organization-permission-authority.mjs [--skip-branch-check] [--repo-root ]', + '', + 'Checks V29 Gate 7 organization permission and interface authority closure.', + ].join('\n'), + ); + process.stdout.write('\n'); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printHelp(); + return; + } + + const root = args.repoRoot; + const failures = []; + const pointer = read(root, 'BITCODE_SPEC.txt').trim(); + + assertCheck( + failures, + pointer === 'V28', + `BITCODE_SPEC.txt must remain V28 during V29 Gate 7 work. Observed ${pointer || 'empty'}.`, + ); + + if (!args.skipBranchCheck) { + const branch = git(root, ['branch', '--show-current']); + assertCheck( + failures, + branch === 'version/v29' || /^v29\/gate-7-[a-z0-9][a-z0-9-]*$/u.test(branch), + `V29 Gate 7 work must occur on version/v29 or v29/gate-7-* branches. Observed ${branch || 'detached HEAD'}.`, + ); + } + + const requiredFiles = [ + 'BITCODE_SPEC_V29.md', + 'BITCODE_SPEC_V29_DELTA.md', + 'BITCODE_SPEC_V29_NOTES.md', + 'BITCODE_SPEC_V29_PARITY_MATRIX.md', + 'scripts/check-v29-gate7-organization-permission-authority.mjs', + 'packages/btd/src/authority.ts', + 'packages/btd/src/index.ts', + 'packages/btd/__tests__/btd.test.ts', + 'packages/api/src/routes/btd-crypto.ts', + 'packages/api/src/routes/__tests__/btd-crypto.test.ts', + 'uapi/app/api/btd/organization-interface-authority/route.ts', + 'packages/executions-mcp/src/mcp-server/src/auth/middleware.ts', + 'packages/executions-mcp/src/mcp-server/src/types/index.ts', + 'packages/executions-mcp/src/mcp-server/src/__tests__/unit/auth.test.ts', + 'packages/chatgptapp/src/tools.ts', + 'packages/chatgptapp/src/__tests__/tools.test.ts', + 'packages/chatgptapp/package.json', + 'packages/pipeline-hosts/src/asset-pack-harness.ts', + 'packages/pipeline-hosts/src/__tests__/asset-pack-harness.test.ts', + 'uapi/app/terminal/terminal-organization-authority.ts', + 'uapi/app/terminal/TerminalTransactionOrganizationAuthorityCard.tsx', + 'uapi/app/terminal/terminal-transaction-detail-snapshot.ts', + 'uapi/app/terminal/terminal-transaction-read-model.ts', + 'uapi/app/terminal/terminal-transaction-query.ts', + 'uapi/app/terminal/README.md', + 'uapi/tests/terminalOrganizationAuthority.test.ts', + 'uapi/tests/terminalTransactionDetailCards.test.tsx', + 'uapi/tests/terminalTransactionDetailSnapshot.test.ts', + '.github/workflows/bitcode-gate-quality.yml', + 'package.json', + ]; + + for (const relativePath of requiredFiles) { + assertCheck(failures, fileExists(root, relativePath), `Missing Gate 7 file: ${relativePath}`); + } + + const spec = read(root, 'BITCODE_SPEC_V29.md'); + const delta = read(root, 'BITCODE_SPEC_V29_DELTA.md'); + const notes = read(root, 'BITCODE_SPEC_V29_NOTES.md'); + const parity = read(root, 'BITCODE_SPEC_V29_PARITY_MATRIX.md'); + const btdAuthority = read(root, 'packages/btd/src/authority.ts'); + const btdIndex = read(root, 'packages/btd/src/index.ts'); + const btdTest = read(root, 'packages/btd/__tests__/btd.test.ts'); + const apiRoute = read(root, 'packages/api/src/routes/btd-crypto.ts'); + const apiTest = read(root, 'packages/api/src/routes/__tests__/btd-crypto.test.ts'); + const uapiRoute = read(root, 'uapi/app/api/btd/organization-interface-authority/route.ts'); + const mcpAuth = read(root, 'packages/executions-mcp/src/mcp-server/src/auth/middleware.ts'); + const mcpTypes = read(root, 'packages/executions-mcp/src/mcp-server/src/types/index.ts'); + const mcpTest = read(root, 'packages/executions-mcp/src/mcp-server/src/__tests__/unit/auth.test.ts'); + const chatgptTools = read(root, 'packages/chatgptapp/src/tools.ts'); + const chatgptTest = read(root, 'packages/chatgptapp/src/__tests__/tools.test.ts'); + const chatgptPackage = read(root, 'packages/chatgptapp/package.json'); + const harness = read(root, 'packages/pipeline-hosts/src/asset-pack-harness.ts'); + const harnessTest = read(root, 'packages/pipeline-hosts/src/__tests__/asset-pack-harness.test.ts'); + const terminalProjection = read(root, 'uapi/app/terminal/terminal-organization-authority.ts'); + const terminalCard = read(root, 'uapi/app/terminal/TerminalTransactionOrganizationAuthorityCard.tsx'); + const terminalSnapshot = read(root, 'uapi/app/terminal/terminal-transaction-detail-snapshot.ts'); + const terminalReadModel = read(root, 'uapi/app/terminal/terminal-transaction-read-model.ts'); + const terminalQuery = read(root, 'uapi/app/terminal/terminal-transaction-query.ts'); + const terminalReadme = read(root, 'uapi/app/terminal/README.md'); + const terminalTest = read(root, 'uapi/tests/terminalOrganizationAuthority.test.ts'); + const terminalCardTest = read(root, 'uapi/tests/terminalTransactionDetailCards.test.tsx'); + const terminalSnapshotTest = read(root, 'uapi/tests/terminalTransactionDetailSnapshot.test.ts'); + const packageJson = read(root, 'package.json'); + const gateWorkflow = read(root, '.github/workflows/bitcode-gate-quality.yml'); + + assertCheck(failures, spec.includes('V29 organization interface authority canon'), 'V29 SPEC must define organization interface authority canon.'); + assertCheck(failures, delta.includes('Gate 7: Organization Permissions And Interface Authority') && delta.includes('organizationAuthority'), 'V29 DELTA must define Gate 7 closure acceptance.'); + assertCheck(failures, notes.includes('Gate 7 working notes'), 'V29 NOTES must carry Gate 7 working notes.'); + assertCheck(failures, parity.includes('## Gate 7 Parity'), 'V29 PARITY must include Gate 7 parity.'); + assertCheck(failures, parity.includes('Gate 7 completion condition'), 'V29 PARITY must include Gate 7 completion condition.'); + + for (const symbol of [ + 'BtdOrganizationInterfaceAuthorityDecision', + 'BtdOrganizationPermissionAction', + 'BtdOrganizationRegistryAuthoritySummary', + 'summarizeBtdOrganizationRegistryAuthority', + 'evaluateBtdOrganizationInterfaceAuthority', + 'sourceVisibilityWhenAllowed', + 'requiresRegistryReadAccess', + 'requiresSettledPayment', + 'requiresExplicitConfirmation', + 'terminal', + 'api', + 'mcp', + 'chatgpt_app', + 'deliver_asset_pack', + 'administer_organization', + 'authorityRoot', + ]) { + assertCheck(failures, btdAuthority.includes(symbol), `BTD authority primitive is missing ${symbol}.`); + } + + assertCheck( + failures, + btdIndex.includes("export * from './authority'") && + btdTest.includes('organization interface authority') && + btdTest.includes('activeReadLicenseCount') && + btdTest.includes('protected_source_allowed') && + btdTest.includes('settlement_required') && + btdTest.includes('interface_action_not_authorized'), + 'BTD package exports/tests must prove Gate 7 holdings, license usage, paid delivery, unpaid denial, and unsupported interface denial.', + ); + assertCheck( + failures, + apiRoute.includes('buildBtdOrganizationInterfaceAuthorityDecision') && + apiRoute.includes('/btd/organization-interface-authority') && + apiTest.includes('organization interface authority') && + apiTest.includes('registry_read_access_required') && + uapiRoute.includes('postBtdOrganizationInterfaceAuthority'), + 'API and UAPI routes/tests must expose JSON-safe organization authority decisions.', + ); + assertCheck( + failures, + mcpAuth.includes('requiredInterfaceAuthority') && + mcpAuth.includes('validateRequiredInterfaceAuthority') && + mcpAuth.includes('evaluateBtdOrganizationInterfaceAuthority') && + mcpTypes.includes('interfaceAuthority') && + mcpTest.includes('interface authority') && + mcpTest.includes('INSUFFICIENT_INTERFACE_AUTHORITY'), + 'MCP auth must support required interface authority and focused tests.', + ); + assertCheck( + failures, + chatgptPackage.includes('"@bitcode/btd"') && + chatgptTools.includes('CHATGPT_APP_ORGANIZATION_AUTHORITY_VALIDATOR') && + chatgptTools.includes('assertChatGptAppOrganizationAuthority') && + chatgptTools.includes('organizationAuthority') && + chatgptTest.includes('organizationAuthority') && + chatgptTest.includes('organization authority is unpaid'), + 'ChatGPT App write carriers/tests must require organization authority evidence.', + ); + assertCheck( + failures, + harness.includes('organizationAuthority') && + harness.includes('evaluateBtdOrganizationInterfaceAuthority') && + harness.includes("execution.store('asset-pack/settlement', 'organizationAuthority'") && + harness.includes('organizationAuthorityDecision') && + harnessTest.includes('organizationAuthority'), + 'Sandbox harness must persist and test organization authority evidence.', + ); + assertCheck( + failures, + terminalProjection.includes('buildTerminalOrganizationAuthorityProjection') && + terminalProjection.includes('authorityRoot') && + terminalCard.includes('Organization authority') && + terminalSnapshot.includes('organizationAuthority') && + terminalReadModel.includes("'authority'") && + terminalQuery.includes("'authority'") && + terminalTest.includes('authority proof roots') && + terminalCardTest.includes('Organization authority') && + terminalSnapshotTest.includes('organizationAuthority') && + terminalReadme.includes('Organization Authority section'), + 'Terminal projection/card/read-model/docs/tests must expose authority detail.', + ); + assertCheck( + failures, + packageJson.includes('"check:v29-gate7"') && + gateWorkflow.includes('check-v29-gate7-organization-permission-authority.mjs') && + gateWorkflow.includes('terminalOrganizationAuthority.test.ts') && + gateWorkflow.includes('packages/chatgptapp') && + gateWorkflow.includes('packages/executions-mcp/src/mcp-server'), + 'Package scripts and gate-quality workflow must invoke Gate 7 checker and focused tests.', + ); + + if (failures.length) { + process.stderr.write('V29 Gate 7 check failed:\n'); + for (const failure of failures) process.stderr.write(`- ${failure}\n`); + process.exit(1); + } + + process.stdout.write('V29 Gate 7 organization permission and interface authority check passed.\n'); +} + +main(); diff --git a/uapi/app/api/btd/organization-interface-authority/route.ts b/uapi/app/api/btd/organization-interface-authority/route.ts new file mode 100644 index 000000000..78525eeb1 --- /dev/null +++ b/uapi/app/api/btd/organization-interface-authority/route.ts @@ -0,0 +1,3 @@ +import { postBtdOrganizationInterfaceAuthority } from '@bitcode/api'; + +export const POST = postBtdOrganizationInterfaceAuthority; diff --git a/uapi/app/terminal/README.md b/uapi/app/terminal/README.md index 40961b96b..24ee6df08 100644 --- a/uapi/app/terminal/README.md +++ b/uapi/app/terminal/README.md @@ -141,6 +141,14 @@ database projection require approval, and settled transactions with missing pull-request delivery surface delivery recovery without exposing protected AssetPack source before payment. +The Organization Authority section is the selected-activity permission +explainer. It projects registry-derived organization role, explicit grants, +wallet binding, owner-read or licensed-read access, settlement state, +confirmation state, interface admission, blockers, and proof roots before raw +authority payload inspection. Terminal may show source-safe previews without a +paid unlock, but protected-source unlock and delivery remain blocked until the +same `organizationAuthority` evidence admits the action. + ## Related shared systems - [../../components/base/bitcode/execution/README.md](../../components/base/bitcode/execution/README.md) diff --git a/uapi/app/terminal/TerminalTransactionDetailActionBar.tsx b/uapi/app/terminal/TerminalTransactionDetailActionBar.tsx index 810380960..cba0f9d14 100644 --- a/uapi/app/terminal/TerminalTransactionDetailActionBar.tsx +++ b/uapi/app/terminal/TerminalTransactionDetailActionBar.tsx @@ -14,6 +14,7 @@ type DetailAction = { const DETAIL_ACTIONS: DetailAction[] = [ { id: 'shippables', label: 'Shippables' }, { id: 'transaction', label: 'Identity' }, + { id: 'authority', label: 'Authority' }, { id: 'closure', label: 'Closure' }, { id: 'proofs', label: 'Proofs' }, { id: 'history', label: 'History' }, diff --git a/uapi/app/terminal/TerminalTransactionDetailSurface.tsx b/uapi/app/terminal/TerminalTransactionDetailSurface.tsx index 0ba956195..607b631f8 100644 --- a/uapi/app/terminal/TerminalTransactionDetailSurface.tsx +++ b/uapi/app/terminal/TerminalTransactionDetailSurface.tsx @@ -22,6 +22,7 @@ import TerminalTransactionDetailHero from './TerminalTransactionDetailHero'; import TerminalTransactionHistoryCard from './TerminalTransactionHistoryCard'; import TerminalTransactionIdentityCard from './TerminalTransactionIdentityCard'; import TerminalTransactionJournalReconciliationCard from './TerminalTransactionJournalReconciliationCard'; +import TerminalTransactionOrganizationAuthorityCard from './TerminalTransactionOrganizationAuthorityCard'; import TerminalTransactionProofsCard from './TerminalTransactionProofsCard'; import TerminalTransactionWalletBtcCard from './TerminalTransactionWalletBtcCard'; import { @@ -31,6 +32,7 @@ import { } from './terminal-activity-history'; import { normalizeTerminalClosureState } from './terminal-closure-state'; import { buildTerminalJournalReconciliation } from './terminal-journal-reconciliation'; +import { buildTerminalOrganizationAuthorityProjection } from './terminal-organization-authority'; import { buildTerminalTransactionClosurePayload, buildTerminalTransactionClosureFollowThrough, @@ -101,6 +103,7 @@ export default function TerminalTransactionDetailSurface({ const showShippables = detailSection === 'shippables'; const showTransaction = detailSection === 'transaction'; const showWalletBtc = detailSection === 'wallet-btc'; + const showAuthority = detailSection === 'authority'; const showClosure = detailSection === 'closure'; const showProofs = detailSection === 'proofs'; const showHistory = detailSection === 'history'; @@ -196,6 +199,10 @@ export default function TerminalTransactionDetailSurface({ () => buildTerminalWalletBtcOperationProjection({ selectedRun, detail }), [detail, selectedRun], ); + const organizationAuthority = useMemo( + () => buildTerminalOrganizationAuthorityProjection(detail), + [detail], + ); const writtenAssets = detail?.writtenAssets || null; const deliveryMechanism = detail?.shippables || detail?.deliveryMechanism || null; const mergedAssetPackSurface = @@ -344,6 +351,12 @@ export default function TerminalTransactionDetailSurface({ ) : null} + {showAuthority ? ( +
+ +
+ ) : null} + {showShippables && mergedAssetPackSurface ? (
+
+ + + {authority.blockers.length ? ( +
+

+ Authority blockers +

+
    + {authority.blockers.map((blocker) => ( +
  • {blocker}
  • + ))} +
+
+ ) : null} + +
+

+ Permission decisions +

+ +
+ +
+

+ Authority proof roots +

+ +
+
+ + ); +} diff --git a/uapi/app/terminal/terminal-organization-authority.ts b/uapi/app/terminal/terminal-organization-authority.ts new file mode 100644 index 000000000..82bce8a42 --- /dev/null +++ b/uapi/app/terminal/terminal-organization-authority.ts @@ -0,0 +1,138 @@ +import type { TerminalRunDetailSnapshot, TerminalJsonRecord } from './terminal-transaction-detail-snapshot'; + +export type TerminalOrganizationAuthorityDecision = { + id: string; + title: string; + summary: string; + supportingText?: string; +}; + +export type TerminalOrganizationAuthorityProjection = { + state: 'allowed' | 'denied' | 'not_projected'; + stateLabel: string; + summary: string; + metrics: Array<{ label: string; value: string }>; + decisions: TerminalOrganizationAuthorityDecision[]; + blockers: string[]; + proofRoots: TerminalOrganizationAuthorityDecision[]; + payload: unknown; +}; + +function isRecord(value: unknown): value is TerminalJsonRecord { + return typeof value === 'object' && value !== null; +} + +function stringValue(value: unknown, fallback = 'n/a') { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function nestedRecord(value: unknown, key: string): TerminalJsonRecord | null { + return isRecord(value) && isRecord(value[key]) ? value[key] as TerminalJsonRecord : null; +} + +function formatDecisionTitle(decision: TerminalJsonRecord) { + const action = stringValue(decision.action, 'unknown action').replaceAll('_', ' '); + const surface = stringValue(decision.interfaceSurface, 'interface'); + return `${surface} · ${action}`; +} + +function decisionSummary(decision: TerminalJsonRecord) { + const state = stringValue(decision.decision, 'not_projected'); + const reason = stringValue(decision.reason, 'authority evidence missing'); + const visibility = stringValue(decision.sourceVisibility, 'source_safe_preview'); + return `${state} · ${reason} · ${visibility}`; +} + +function readProofRoot(decision: TerminalJsonRecord, key: string) { + const proofRoots = nestedRecord(decision, 'proofRoots'); + return proofRoots ? stringValue(proofRoots[key], '') : ''; +} + +function buildProofRootItems(decisions: TerminalJsonRecord[]): TerminalOrganizationAuthorityDecision[] { + const roots = decisions.flatMap((decision) => { + const title = formatDecisionTitle(decision); + return [ + ['authorityRoot', readProofRoot(decision, 'authorityRoot')], + ['roleRoot', readProofRoot(decision, 'roleRoot')], + ['permissionRoot', readProofRoot(decision, 'permissionRoot')], + ['readAccessRoot', readProofRoot(decision, 'readAccessRoot')], + ['interfaceRoot', readProofRoot(decision, 'interfaceRoot')], + ].map(([kind, root]) => ({ + id: `${title}:${kind}:${root}`, + title: `${title} ${kind}`, + summary: root || 'n/a', + })); + }); + + return roots.filter((root) => root.summary !== 'n/a'); +} + +export function buildTerminalOrganizationAuthorityProjection( + detail: TerminalRunDetailSnapshot | null, +): TerminalOrganizationAuthorityProjection { + const authorityRecords = detail?.organizationAuthority ?? []; + + if (!authorityRecords.length) { + return { + state: 'not_projected', + stateLabel: 'Not projected', + summary: + 'Organization permission authority is not attached to this activity yet. Terminal keeps action controls source-safe until role, license, and interface evidence are projected.', + metrics: [ + { label: 'State', value: 'Not projected' }, + { label: 'Decisions', value: '0' }, + { label: 'Proof roots', value: '0' }, + ], + decisions: [], + blockers: ['No organization authority decision was readable from this activity payload.'], + proofRoots: [], + payload: { + organizationAuthority: null, + ledgerSettlement: detail?.ledgerSettlement ?? null, + }, + }; + } + + const denied = authorityRecords.filter((decision) => stringValue(decision.decision, '') === 'denied'); + const allowed = authorityRecords.filter((decision) => stringValue(decision.decision, '') === 'allowed'); + const proofRoots = buildProofRootItems(authorityRecords); + const blockers = denied.flatMap((decision) => { + const reasons = arrayValue(decision.reasons) + .map((reason) => stringValue(reason, '')) + .filter(Boolean); + return reasons.length ? reasons : [stringValue(decision.reason, 'authority denied')]; + }); + const state = denied.length ? 'denied' : allowed.length ? 'allowed' : 'not_projected'; + + return { + state, + stateLabel: state === 'allowed' ? 'Allowed' : state === 'denied' ? 'Denied' : 'Not projected', + summary: + state === 'allowed' + ? 'Registry-derived organization role, read-license, settlement, and interface authority admit the projected action.' + : 'One or more organization authority decisions block protected-source action until role, license, settlement, confirmation, or interface evidence is repaired.', + metrics: [ + { label: 'State', value: state === 'allowed' ? 'Allowed' : state === 'denied' ? 'Denied' : 'Not projected' }, + { label: 'Allowed', value: String(allowed.length) }, + { label: 'Denied', value: String(denied.length) }, + { label: 'Proof roots', value: String(proofRoots.length) }, + ], + decisions: authorityRecords.map((decision) => ({ + id: `${formatDecisionTitle(decision)}:${stringValue(decision.targetAnchor, 'target')}`, + title: formatDecisionTitle(decision), + summary: decisionSummary(decision), + supportingText: stringValue(decision.targetAnchor, ''), + })), + blockers, + proofRoots, + payload: { + organizationAuthority: authorityRecords, + ledgerSettlement: detail?.ledgerSettlement ?? null, + bitcodeActivityState: detail?.bitcodeActivityState ?? null, + }, + }; +} diff --git a/uapi/app/terminal/terminal-protocol-projection.ts b/uapi/app/terminal/terminal-protocol-projection.ts index 3f5f555fb..5811d93a4 100644 --- a/uapi/app/terminal/terminal-protocol-projection.ts +++ b/uapi/app/terminal/terminal-protocol-projection.ts @@ -198,6 +198,7 @@ export function buildProtocolProjectedRunDetail( closureState: null, ledgerSettlement: null, terminalJournal: null, + organizationAuthority: null, bitcodeActivityState: workbench || activeScenario || supplySelection || repository ? { diff --git a/uapi/app/terminal/terminal-transaction-detail-snapshot.ts b/uapi/app/terminal/terminal-transaction-detail-snapshot.ts index 79dc7f4a1..f8ae77057 100644 --- a/uapi/app/terminal/terminal-transaction-detail-snapshot.ts +++ b/uapi/app/terminal/terminal-transaction-detail-snapshot.ts @@ -117,6 +117,7 @@ export interface TerminalRunDetailSnapshot { closureState: TerminalClosureState | null; ledgerSettlement: TerminalLedgerSettlementSnapshot | null; terminalJournal: TerminalJournalReadbackSnapshot | null; + organizationAuthority: TerminalJsonRecord[] | null; bitcodeActivityState: { depositWorkbench?: TerminalDepositReadWorkbench | null; fitWorkbench?: TerminalDepositReadWorkbench | null; @@ -437,6 +438,19 @@ function coerceTerminalJournalReadback(value: unknown): TerminalJournalReadbackS }; } +function coerceOrganizationAuthority(value: unknown): TerminalJsonRecord[] | null { + if (Array.isArray(value)) { + const records = coerceRecordArray(value); + return records.length ? records : null; + } + if (isRecord(value)) { + const decisions = coerceRecordArray(value.decisions); + if (decisions.length) return decisions; + return [value]; + } + return null; +} + function coerceCandidates(value: unknown): TerminalClosureCandidate[] | undefined { if (!Array.isArray(value)) return undefined; return value @@ -767,6 +781,7 @@ export function buildTerminalRunDetailFromSelectedRun( closureState: null, ledgerSettlement: null, terminalJournal: null, + organizationAuthority: null, bitcodeActivityState: null, historyItemCount: selectedRun.itemCount || 0, eventCount: 0, @@ -822,6 +837,13 @@ export function normalizeTerminalRunDetailPayload( coerceTerminalJournalReadback(run.terminal_journal) || coerceTerminalJournalReadback(isRecord(run.output) ? run.output.terminal_journal : null) || base.terminalJournal; + const organizationAuthority = + coerceOrganizationAuthority(run.organization_authority) || + coerceOrganizationAuthority(run.organizationAuthority) || + coerceOrganizationAuthority(isRecord(run.output) ? run.output.organizationAuthority : null) || + coerceOrganizationAuthority(assetPackCompletion?.organizationAuthority) || + coerceOrganizationAuthority(assetPackCompletion?.interfaceAuthority) || + base.organizationAuthority; const bitcodeActivityState = coerceBitcodeActivityState(assetPackCompletion?.bitcodeActivityState) || base.bitcodeActivityState; const runProcessingStats = coerceProcessingStats(run.processing_stats); @@ -861,6 +883,7 @@ export function normalizeTerminalRunDetailPayload( closureState, ledgerSettlement, terminalJournal, + organizationAuthority, bitcodeActivityState, historyItemCount: Array.isArray(run.items) ? run.items.length : base.historyItemCount, eventCount: Array.isArray((payload as TerminalRunHistoryPayload).events) diff --git a/uapi/app/terminal/terminal-transaction-query.ts b/uapi/app/terminal/terminal-transaction-query.ts index 401210b56..5f789c725 100644 --- a/uapi/app/terminal/terminal-transaction-query.ts +++ b/uapi/app/terminal/terminal-transaction-query.ts @@ -44,6 +44,7 @@ export type TerminalTransactionDetailSection = | 'shippables' | 'transaction' | 'wallet-btc' + | 'authority' | 'closure' | 'proofs' | 'history' @@ -54,6 +55,7 @@ const TRANSACTION_DETAIL_SECTION_VALUES: TerminalTransactionDetailSection[] = [ 'shippables', 'transaction', 'wallet-btc', + 'authority', 'closure', 'proofs', 'history', diff --git a/uapi/app/terminal/terminal-transaction-read-model.ts b/uapi/app/terminal/terminal-transaction-read-model.ts index 5f2da13f2..209bc00d9 100644 --- a/uapi/app/terminal/terminal-transaction-read-model.ts +++ b/uapi/app/terminal/terminal-transaction-read-model.ts @@ -17,6 +17,7 @@ export type TerminalTransactionFactFamily = | 'delivery' | 'identity' | 'wallet' + | 'authority' | 'settlement' | 'proof' | 'ledger' @@ -94,6 +95,13 @@ const SECTION_DEFINITIONS: Array<{ summary: 'Wallet signer session, BTC fee quote, PSBT handoff, broadcast, finality, and blocked readiness.', factFamily: 'wallet', }, + { + id: 'authority', + label: 'Authority', + targetId: 'terminalTransactionAuthority', + summary: 'Organization role, read-license, settlement, confirmation, and interface authority decisions.', + factFamily: 'authority', + }, { id: 'closure', label: 'Closure', @@ -241,6 +249,19 @@ function resolveSectionAvailability({ }; } + if (sectionId === 'authority') { + const decisionCount = detail?.organizationAuthority?.length || 0; + return { + availability: decisionCount > 0 ? 'available' : 'empty', + blocker: decisionCount > 0 + ? null + : 'No organization role, read-license, settlement, or interface authority projection is attached yet.', + metricCount: 4, + rowCount: decisionCount, + payloadAvailable: decisionCount > 0, + }; + } + if (sectionId === 'closure') { const hasClosure = Boolean(detail?.closureState) || diff --git a/uapi/jest.config.cjs b/uapi/jest.config.cjs index 0a3f4b1eb..8d2524531 100644 --- a/uapi/jest.config.cjs +++ b/uapi/jest.config.cjs @@ -142,6 +142,7 @@ module.exports = { '/tests/terminalPipelineHarnessClient.test.ts', '/tests/pipelineExecutionLogHeader.test.tsx', '/tests/terminalJournalReconciliation.test.ts', + '/tests/terminalOrganizationAuthority.test.ts', '/tests/terminalTransactionActivitySurface.test.tsx', '/tests/terminalTransactionDetail.test.ts', '/tests/terminalTransactionDetailCards.test.tsx', diff --git a/uapi/tests/terminalJournalReconciliation.test.ts b/uapi/tests/terminalJournalReconciliation.test.ts index 876c8f2c7..dc227ecdf 100644 --- a/uapi/tests/terminalJournalReconciliation.test.ts +++ b/uapi/tests/terminalJournalReconciliation.test.ts @@ -19,6 +19,7 @@ function detail(overrides: Partial): TerminalRunDetai closureState: null, ledgerSettlement: null, terminalJournal: null, + organizationAuthority: null, bitcodeActivityState: null, historyItemCount: 0, eventCount: 0, diff --git a/uapi/tests/terminalOrganizationAuthority.test.ts b/uapi/tests/terminalOrganizationAuthority.test.ts new file mode 100644 index 000000000..3e2bb59e1 --- /dev/null +++ b/uapi/tests/terminalOrganizationAuthority.test.ts @@ -0,0 +1,74 @@ +import { buildTerminalOrganizationAuthorityProjection } from '@/app/terminal/terminal-organization-authority'; +import type { TerminalRunDetailSnapshot } from '@/app/terminal/terminal-transaction-detail-snapshot'; + +function detailWithAuthority( + organizationAuthority: TerminalRunDetailSnapshot['organizationAuthority'], +): TerminalRunDetailSnapshot { + return { + summary: 'Authority test detail', + shippables: null, + assetPackSynthesisArtifacts: null, + writtenAssets: null, + deliveryMechanism: null, + repoSnapshot: null, + processingStats: { + time: null, + tokenTotal: null, + measuredBtd: null, + btcFeeUsdEquivalent: null, + averageLatencyMs: null, + }, + proofStatus: null, + closureFocus: null, + closureFollowThrough: null, + closureState: null, + ledgerSettlement: null, + terminalJournal: null, + organizationAuthority, + bitcodeActivityState: null, + historyItemCount: 0, + eventCount: 0, + }; +} + +describe('terminal organization authority projection', () => { + it('projects allowed decisions and authority proof roots', () => { + const projection = buildTerminalOrganizationAuthorityProjection( + detailWithAuthority([ + { + decision: 'allowed', + interfaceSurface: 'mcp', + action: 'deliver_asset_pack', + reason: 'role_authorized', + reasons: ['role_authorized', 'licensed_read_access_authorized'], + sourceVisibility: 'protected_source_allowed', + targetAnchor: 'github:engineeredsoftware/ENGI/pull/42', + proofRoots: { + authorityRoot: 'btd-proof-root:organization-interface-authority:abc123', + roleRoot: 'btd-proof-root:organization-role:def456', + permissionRoot: 'btd-proof-root:organization-permission:ghi789', + }, + }, + ]), + ); + + expect(projection.state).toBe('allowed'); + expect(projection.metrics).toEqual(expect.arrayContaining([{ label: 'Allowed', value: '1' }])); + expect(projection.decisions[0]).toMatchObject({ + title: 'mcp · deliver asset pack', + summary: 'allowed · role_authorized · protected_source_allowed', + }); + expect(projection.proofRoots.map((root) => root.summary)).toEqual( + expect.arrayContaining(['btd-proof-root:organization-interface-authority:abc123']), + ); + }); + + it('keeps missing authority visible as a not-projected blocker', () => { + const projection = buildTerminalOrganizationAuthorityProjection(detailWithAuthority(null)); + + expect(projection.state).toBe('not_projected'); + expect(projection.blockers).toEqual([ + 'No organization authority decision was readable from this activity payload.', + ]); + }); +}); diff --git a/uapi/tests/terminalTransactionActivitySurface.test.tsx b/uapi/tests/terminalTransactionActivitySurface.test.tsx index 49b315796..3c2f81975 100644 --- a/uapi/tests/terminalTransactionActivitySurface.test.tsx +++ b/uapi/tests/terminalTransactionActivitySurface.test.tsx @@ -45,6 +45,7 @@ const detail: TerminalRunDetailSnapshot = { closureState: null, ledgerSettlement: null, terminalJournal: null, + organizationAuthority: null, bitcodeActivityState: { repositoryAnchor: { provider: 'github', diff --git a/uapi/tests/terminalTransactionDetail.test.ts b/uapi/tests/terminalTransactionDetail.test.ts index 14547577f..194ff7d9b 100644 --- a/uapi/tests/terminalTransactionDetail.test.ts +++ b/uapi/tests/terminalTransactionDetail.test.ts @@ -84,6 +84,7 @@ const detail: TerminalRunDetailSnapshot = { }, ledgerSettlement: null, terminalJournal: null, + organizationAuthority: null, closureState: { canonLabel: 'persisted closure posture', verification: { diff --git a/uapi/tests/terminalTransactionDetailCards.test.tsx b/uapi/tests/terminalTransactionDetailCards.test.tsx index 2b949c0c0..a5a320822 100644 --- a/uapi/tests/terminalTransactionDetailCards.test.tsx +++ b/uapi/tests/terminalTransactionDetailCards.test.tsx @@ -5,6 +5,7 @@ import TerminalTransactionClosureCard from '@/app/terminal/TerminalTransactionCl import TerminalTransactionHistoryCard from '@/app/terminal/TerminalTransactionHistoryCard'; import TerminalTransactionIdentityCard from '@/app/terminal/TerminalTransactionIdentityCard'; import TerminalTransactionJournalReconciliationCard from '@/app/terminal/TerminalTransactionJournalReconciliationCard'; +import TerminalTransactionOrganizationAuthorityCard from '@/app/terminal/TerminalTransactionOrganizationAuthorityCard'; import TerminalTransactionProofsCard from '@/app/terminal/TerminalTransactionProofsCard'; describe('Terminal transaction detail cards', () => { @@ -162,6 +163,41 @@ describe('Terminal transaction detail cards', () => { }, }} /> + , ); @@ -174,6 +210,10 @@ describe('Terminal transaction detail cards', () => { expect(screen.getByText('Metaphysical canonical facts')).toBeTruthy(); expect(screen.getByText('Repair actions')).toBeTruthy(); expect(screen.getByText('Proof roots')).toBeTruthy(); + expect(screen.getByText('Organization authority')).toBeTruthy(); + expect(screen.getByText('Role, read-license, settlement, and interface permission')).toBeTruthy(); + expect(screen.getByText('Permission decisions')).toBeTruthy(); + expect(screen.getByText('Authority proof roots')).toBeTruthy(); expect(screen.getAllByText('Structured payload shape').length).toBeGreaterThanOrEqual(3); expect(screen.getAllByText('selection-materialization').length).toBeGreaterThan(0); expect(screen.getAllByText('run-001').length).toBeGreaterThan(0); diff --git a/uapi/tests/terminalTransactionDetailSnapshot.test.ts b/uapi/tests/terminalTransactionDetailSnapshot.test.ts index 78840c091..aec769cff 100644 --- a/uapi/tests/terminalTransactionDetailSnapshot.test.ts +++ b/uapi/tests/terminalTransactionDetailSnapshot.test.ts @@ -71,6 +71,7 @@ describe('terminal-transaction-detail-snapshot helpers', () => { closureState: null, ledgerSettlement: null, terminalJournal: null, + organizationAuthority: null, bitcodeActivityState: { repositoryAnchor: { provider: 'github', @@ -317,6 +318,21 @@ describe('terminal-transaction-detail-snapshot helpers', () => { }, journalEntryIds: ['journal-mint-run-1'], }, + organizationAuthority: [ + { + kind: 'btd_organization_interface_authority_decision', + decision: 'allowed', + interfaceSurface: 'terminal', + action: 'deliver_asset_pack', + reason: 'role_authorized', + sourceVisibility: 'protected_source_allowed', + targetAnchor: 'github:bitcode/bitcode/pull/2', + proofRoots: { + authorityRoot: 'btd-proof-root:organization-interface-authority:abc123', + roleRoot: 'btd-proof-root:organization-role:def456', + }, + }, + ], }, terminal_journal: { expectedJournalEntryIds: ['journal-mint-run-1'], @@ -462,6 +478,13 @@ describe('terminal-transaction-detail-snapshot helpers', () => { btcFeeTransactions: [{ receipt_id: 'btc-fee-run-1', finality_state: 'prepared' }], }, }); + expect(snapshot.organizationAuthority).toEqual([ + expect.objectContaining({ + action: 'deliver_asset_pack', + decision: 'allowed', + sourceVisibility: 'protected_source_allowed', + }), + ]); expect(snapshot.historyItemCount).toBe(2); expect(snapshot.eventCount).toBe(3); }); diff --git a/uapi/tests/terminalTransactionReadModel.test.ts b/uapi/tests/terminalTransactionReadModel.test.ts index 175b2012c..f33f58f64 100644 --- a/uapi/tests/terminalTransactionReadModel.test.ts +++ b/uapi/tests/terminalTransactionReadModel.test.ts @@ -162,6 +162,7 @@ describe('terminal transaction read model', () => { writtenAssets: null, closureFollowThrough: null, terminalJournal: null, + organizationAuthority: null, eventCount: 0, }, detailSection: 'console', diff --git a/uapi/tests/terminalWalletBtcOperation.test.ts b/uapi/tests/terminalWalletBtcOperation.test.ts index 8bc14a68d..fcb32d10f 100644 --- a/uapi/tests/terminalWalletBtcOperation.test.ts +++ b/uapi/tests/terminalWalletBtcOperation.test.ts @@ -71,6 +71,7 @@ function detailWithBtcFee(finalityState = 'confirmed'): TerminalRunDetailSnapsho readLicenseId: 'license-1', }, terminalJournal: null, + organizationAuthority: null, bitcodeActivityState: null, historyItemCount: 0, eventCount: 0,