diff --git a/deployments/modules/earn/access-policy/cli.test.ts b/deployments/modules/earn/access-policy/cli.test.ts new file mode 100644 index 0000000..a3840e3 --- /dev/null +++ b/deployments/modules/earn/access-policy/cli.test.ts @@ -0,0 +1,187 @@ +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { EarnStack } from '../../../core/stack.js' +import { policySiblingStacks, siblingEnvironment } from './cli.js' + +const venue = '0x0d136e26b4d9e4836fc5cb202baeb49cb6f5df48' + +/** A profile repository with the selected profile and the given neighbours. */ +function repository(neighbours: Record) { + const root = mkdtempSync(join(tmpdir(), 'access-siblings-')) + const write = (directory: string, id: string, profile: unknown, pending = false) => { + mkdirSync(join(directory, id), { recursive: true }) + writeFileSync(join(directory, id, 'profile.json'), JSON.stringify(profile)) + if (pending) writeFileSync(join(directory, id, 'deployment.pending.json'), '{}') + } + const profiles = join(root, 'profiles') + write(profiles, 'selected-moderato', authored('selected-moderato')) + for (const [id, { profile, pending }] of Object.entries(neighbours)) { + write(profiles, id, profile, pending) + } + return { root, profiles, selected: join(profiles, 'selected-moderato', 'profile.json') } +} + +function authored(id: string, policy = '469') { + return { + version: 1, + id, + chainId: 42431, + tip403PolicyId: policy, + engine: { kind: 'erc4626', venue }, + } +} + +const selected = { + id: 'selected-moderato', + network: { chainId: 42431 }, + access: { kind: 'tip403-whitelist', transferPolicyId: '469' }, +} as unknown as EarnStack +const genesis = `0x${'11'.repeat(32)}` + +describe('policy sibling discovery', () => { + it('stops on an unpublished deployment that shares the policy, naming it', async () => { + const { selected: path } = repository({ + 'other-moderato': { profile: authored('other-moderato'), pending: true }, + }) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile other-moderato: it has an unpublished deployment on policy 469/, + ) + }) + + it('ignores an unpublished deployment on another policy', async () => { + const { selected: path } = repository({ + 'other-moderato': { profile: authored('other-moderato', '470'), pending: true }, + }) + await expect(policySiblingStacks(path, selected, genesis)).resolves.toEqual([]) + }) + + it('names an unparseable neighbour instead of failing anonymously', async () => { + const { selected: path } = repository({ + 'broken-moderato': { profile: { id: 'broken-moderato' }, pending: true }, + }) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile broken-moderato:/, + ) + }) + + it('follows a symlinked neighbour directory', async () => { + const { profiles, selected: path } = repository({}) + const elsewhere = mkdtempSync(join(tmpdir(), 'access-sibling-target-')) + mkdirSync(join(elsewhere, 'linked-moderato')) + writeFileSync( + join(elsewhere, 'linked-moderato', 'profile.json'), + JSON.stringify(authored('linked-moderato')), + ) + writeFileSync(join(elsewhere, 'linked-moderato', 'deployment.pending.json'), '{}') + symlinkSync(join(elsewhere, 'linked-moderato'), join(profiles, 'linked-moderato')) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile linked-moderato: it has an unpublished deployment/, + ) + }) + + it('counts a symlink alias of the selected profile or a neighbour once', async () => { + const { profiles, selected: path } = repository({ + 'other-moderato': { profile: authored('other-moderato'), pending: true }, + }) + symlinkSync(join(profiles, 'selected-moderato'), join(profiles, 'latest')) + symlinkSync(join(profiles, 'other-moderato'), join(profiles, 'zz-alias')) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile other-moderato: it has an unpublished deployment/, + ) + }) + + it('skips a recorded neighbour on another chain without loading it strictly', async () => { + const { profiles, selected: path } = repository({ + 'other-mainnet': { profile: { ...authored('other-mainnet'), chainId: 4217 } }, + }) + writeFileSync( + join(profiles, 'other-mainnet', 'deployment.json'), + JSON.stringify({ target: { chainId: 4217, genesisHash: `0x${'22'.repeat(32)}` } }), + ) + await expect(policySiblingStacks(path, selected, genesis)).resolves.toEqual([]) + }) + + it.each([ + ['an empty genesis hash', { chainId: 42431, genesisHash: '' }, 42431], + ['a truncated genesis hash', { chainId: 42431, genesisHash: '0x1234' }, 42431], + ['a chain that disagrees with its profile', { chainId: 4217, genesisHash: 'G' }, 42431], + ])('strictly loads a neighbour whose record has %s', async (_, target, authoredChain) => { + const { profiles, selected: path } = repository({ + 'other-moderato': { profile: { ...authored('other-moderato'), chainId: authoredChain } }, + }) + const record = { + ...target, + genesisHash: target.genesisHash === 'G' ? '0x' + '22'.repeat(32) : target.genesisHash, + } + writeFileSync( + join(profiles, 'other-moderato', 'deployment.json'), + JSON.stringify({ target: record }), + ) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile other-moderato:/, + ) + }) + + it('strictly loads a recorded neighbour on the same chain and genesis', async () => { + const { profiles, selected: path } = repository({ + 'other-moderato': { profile: authored('other-moderato', '470') }, + }) + writeFileSync( + join(profiles, 'other-moderato', 'deployment.json'), + JSON.stringify({ target: { chainId: 42431, genesisHash: genesis } }), + ) + await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow( + /profile other-moderato:/, + ) + }) +}) + +describe('sibling environment', () => { + afterEach(() => vi.unstubAllEnvs()) + + it.each([ + ['a blank URL', 'TEMPO_RPC_URL=\n'], + ['only a URL', 'TEMPO_RPC_URL=https://sibling.example\n'], + ])('takes the whole RPC group from a file naming %s', (_, file) => { + const { root } = repository({}) + writeFileSync(join(root, '.env.other-moderato'), file) + vi.stubEnv('TEMPO_RPC_URL', 'https://shell.example') + vi.stubEnv('TEMPO_RPC_USERNAME', 'shell-user') + vi.stubEnv('TEMPO_RPC_PASSWORD', 'shell-pass') + const values = siblingEnvironment(root, 'other-moderato') + expect(values.TEMPO_RPC_USERNAME).toBeUndefined() + expect(values.TEMPO_RPC_PASSWORD).toBeUndefined() + expect(values.TEMPO_RPC_URL).not.toBe('https://shell.example') + }) + + it.each([ + ['credentials only', 'TEMPO_RPC_USERNAME=u\nTEMPO_RPC_PASSWORD=p\n'], + [ + 'a blank URL with credentials', + 'TEMPO_RPC_URL=\nTEMPO_RPC_USERNAME=u\nTEMPO_RPC_PASSWORD=p\n', + ], + ])('refuses a file with %s', (_, file) => { + const { root } = repository({}) + writeFileSync(join(root, '.env.other-moderato'), file) + vi.stubEnv('TEMPO_RPC_URL', 'https://private.example') + expect(() => siblingEnvironment(root, 'other-moderato')).toThrow( + 'sets TEMPO_RPC credentials without TEMPO_RPC_URL', + ) + }) + + it('keeps the shell RPC group when the file names none of it', () => { + const { root } = repository({}) + writeFileSync(join(root, '.env.other-moderato'), 'OTHER=1\n') + vi.stubEnv('TEMPO_RPC_URL', 'https://shell.example') + vi.stubEnv('TEMPO_RPC_USERNAME', 'shell-user') + vi.stubEnv('TEMPO_RPC_PASSWORD', 'shell-pass') + expect(siblingEnvironment(root, 'other-moderato')).toMatchObject({ + TEMPO_RPC_URL: 'https://shell.example', + TEMPO_RPC_USERNAME: 'shell-user', + TEMPO_RPC_PASSWORD: 'shell-pass', + }) + }) +}) diff --git a/deployments/modules/earn/access-policy/cli.ts b/deployments/modules/earn/access-policy/cli.ts index 5323ce5..4616282 100644 --- a/deployments/modules/earn/access-policy/cli.ts +++ b/deployments/modules/earn/access-policy/cli.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs' import { dirname, join } from 'node:path' import { parseArgs } from 'node:util' @@ -10,6 +10,9 @@ import { configuredTempoChain } from '../../../chain/tempo.js' import { loadDeploymentEnvironment, loadEarnProfile, + loadEarnProfileBundle, + loadReviewedDeploymentIdentity, + pendingDeploymentPath, profileDeploymentPath, profileRepositoryRoot, resolveProfilePath, @@ -82,7 +85,10 @@ async function access(argv: readonly string[], output: Pick/profile.json` entries in its - * parent directory) on the same chain that attaches the same TIP-403 policy, loaded live with its - * own `.env.`. A sibling's reviewed identity comes only from its own `.env.`, never from - * the shell, which names the selected profile's. A profile without a generated record has nothing - * deployed to protect; any other load failure stops the removal rather than skipping it. + * Every other deployed stack beside `profilePath` (each `/profile.json` in its parent + * directory, symlinked or not) on the same chain and genesis that attaches the same TIP-403 policy, + * loaded live. Sharing is decided from each neighbour's verified authored/generated pair, so a + * neighbour whose profile no longer matches its record, or that fails to load, stops the removal + * and is named. A neighbour with an unpublished deployment on this policy also stops it: its + * contracts may already be bound to the policy. Each neighbour runs with its own `.env.`, + * which alone supplies its reviewed identity and, when it sets any, its RPC settings. */ -async function policySiblingStacks(profilePath: string, stack: EarnStack) { +export async function policySiblingStacks( + profilePath: string, + stack: EarnStack, + genesisHash: string, +) { if (stack.access.kind !== 'tip403-whitelist') return [] const root = profileRepositoryRoot(profilePath) - const profilesDirectory = dirname(dirname(profilePath)) + const directory = dirname(dirname(profilePath)) const policyId = BigInt(stack.access.transferPolicyId) const siblings: EarnStack[] = [] - for (const entry of readdirSync(profilesDirectory, { withFileTypes: true })) { - if (!entry.isDirectory()) continue - const path = join(profilesDirectory, entry.name, 'profile.json') - if (path === profilePath || !existsSync(path) || !existsSync(profileDeploymentPath(path))) { - continue + // Entries resolve through symlinks; aliases of a stack, including the selected one, count once. + const seen = new Set([realpathSync(dirname(profilePath))]) + for (const name of readdirSync(directory).sort()) { + const entry = join(directory, name) + if (!existsSync(join(entry, 'profile.json'))) continue + const real = realpathSync(entry) + if (seen.has(real)) continue + seen.add(real) + const path = join(real, 'profile.json') + const recorded = existsSync(profileDeploymentPath(path)) + if (!recorded && !existsSync(pendingDeploymentPath(path))) continue + const fail = (reason: unknown): never => { + const detail = reason instanceof Error ? reason.message : String(reason) + throw new Error(`Cannot check removal safety against profile ${name}: ${detail}`, { + cause: reason, + }) } - const profile = loadEarnProfile(path) - if (profile.id === stack.id || profile.chainId !== stack.network.chainId) continue - if (transferPolicyId(profile) !== policyId) continue try { - const values = loadDeploymentEnvironment(root, profile.id, process.env) - const own = loadDeploymentEnvironment(root, profile.id, {}) - const siblingValues = { - ...values, - EARN_TRUSTED_DEPLOYMENT_IDENTITY: own.EARN_TRUSTED_DEPLOYMENT_IDENTITY, + const authored = loadEarnProfile(path) + if (authored.id === stack.id) continue + if (!recorded) { + if (authored.chainId === stack.network.chainId && transferPolicyId(authored) === policyId) { + fail(`it has an unpublished deployment on policy ${policyId}; publish its record first`) + } + continue } - siblings.push((await loadHostedProfileStack(path, siblingValues)).stack) + // Only a record on this chain and genesis can share the policy; others need no strict load. + if (!sameTarget(path, authored.chainId, stack.network.chainId, genesisHash)) continue + const values = siblingEnvironment(root, authored.id) + const identityPath = values.EARN_TRUSTED_DEPLOYMENT_IDENTITY?.trim() + const identity = identityPath + ? loadReviewedDeploymentIdentity(resolveProfilePath(identityPath)) + : undefined + const { profile, record } = loadEarnProfileBundle(path, identity) + if (record.target.chainId !== stack.network.chainId) continue + if (record.target.genesisHash.toLowerCase() !== genesisHash.toLowerCase()) continue + if (transferPolicyId(profile) !== policyId) continue + siblings.push((await loadHostedProfileStack(path, values, identity)).stack) } catch (error) { - throw new Error( - `Cannot check removal safety for ${profile.id}, which shares policy ${policyId}`, - { cause: error }, - ) + if (error instanceof Error && error.message.startsWith('Cannot check removal safety')) { + throw error + } + fail(error) } } return siblings } +/** + * Whether a generated record may target this chain and genesis, read before the strict load so an + * unrelated neighbour never blocks a removal. Only a well-formed target that agrees with its + * authored chain excludes a neighbour; anything malformed or inconsistent counts as possibly the + * same, so the strict load rejects it. + */ +function sameTarget( + profilePath: string, + authoredChainId: number, + chainId: number, + genesisHash: string, +) { + try { + const { target } = JSON.parse(readFileSync(profileDeploymentPath(profilePath), 'utf8')) as { + target?: { chainId?: unknown; genesisHash?: unknown } + } + if (typeof target?.chainId !== 'number' || target.chainId !== authoredChainId) return true + if (typeof target.genesisHash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(target.genesisHash)) { + return true + } + return ( + target.chainId === chainId && target.genesisHash.toLowerCase() === genesisHash.toLowerCase() + ) + } catch { + return true + } +} + +const rpcKeys = ['TEMPO_RPC_URL', 'TEMPO_RPC_USERNAME', 'TEMPO_RPC_PASSWORD'] as const + +/** + * A neighbour's runtime values: its own `.env.` wins over the shell. Its reviewed identity + * comes only from that file, and RPC settings move as a group: all from the file when it names any + * of them, even blank, else all from the shell, so credentials never reach another endpoint. File + * credentials without a file URL are refused rather than sent to the public fallback. + */ +export function siblingEnvironment(root: string, profileId: string) { + const own = loadDeploymentEnvironment(root, profileId, {}) + const shell = loadDeploymentEnvironment(root, profileId, process.env) + const values: Record = { + ...shell, + ...own, + EARN_TRUSTED_DEPLOYMENT_IDENTITY: own.EARN_TRUSTED_DEPLOYMENT_IDENTITY, + } + const fromFile = rpcKeys.some((key) => key in own) + if ( + fromFile && + !own.TEMPO_RPC_URL?.trim() && + (own.TEMPO_RPC_USERNAME || own.TEMPO_RPC_PASSWORD) + ) { + // Without its own URL the hosted public endpoint would receive these private credentials. + throw new Error( + `.env.${profileId} sets TEMPO_RPC credentials without TEMPO_RPC_URL; set the URL there too`, + ) + } + for (const key of rpcKeys) values[key] = fromFile ? own[key] : shell[key] + return values +} + /** * The access administrator key from the profile's runtime config. Like hosted E2E, an unset * `EARN_ACCESS_ADMIN_PRIVATE_KEY` falls back to `DEPLOYER_PRIVATE_KEY`, announced on stderr so the diff --git a/deployments/modules/earn/access-policy/membership.test.ts b/deployments/modules/earn/access-policy/membership.test.ts index 7d7263f..6e6e1ec 100644 --- a/deployments/modules/earn/access-policy/membership.test.ts +++ b/deployments/modules/earn/access-policy/membership.test.ts @@ -240,6 +240,15 @@ describe('access removal across stacks sharing a policy', () => { expect(client.getLogs).toHaveBeenCalledTimes(1) }) + it('names the sibling stack that blocks a removal', async () => { + const sibling = siblingDeployment() + await expect( + assertAccessRemovalSafe(removalClient(), protectedDeployment(), sibling.contracts.earnVault, [ + sibling, + ]), + ).rejects.toThrow(/for stack sibling, EarnVault 0x0+13, which shares policy 469/) + }) + it('refuses before signing when only the sibling needs the target', async () => { const sibling = siblingDeployment() const request = vi.fn(async () => { diff --git a/deployments/modules/earn/access-policy/membership.ts b/deployments/modules/earn/access-policy/membership.ts index 0794d71..f69fa29 100644 --- a/deployments/modules/earn/access-policy/membership.ts +++ b/deployments/modules/earn/access-policy/membership.ts @@ -156,8 +156,17 @@ export async function assertAccessRemovalSafe( siblings: readonly EarnStack[] = [], ) { const { access } = requireProtectedAccess(deployment) - for (const stack of [deployment, ...sharingPolicy(deployment, access, siblings)]) { - await assertStackRemovalSafe(client, stack, target) + await assertStackRemovalSafe(client, deployment, target) + for (const stack of sharingPolicy(deployment, access, siblings)) { + try { + await assertStackRemovalSafe(client, stack, target) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `${detail} (for stack ${stack.id}, EarnVault ${stack.contracts.earnVault}, which shares policy ${access.transferPolicyId})`, + { cause: error }, + ) + } } } diff --git a/docs/earn/modules/access.md b/docs/earn/modules/access.md index 1474dcd..cd1149d 100644 --- a/docs/earn/modules/access.md +++ b/docs/earn/modules/access.md @@ -75,13 +75,15 @@ virtual aliases for both required members and receivers. An unregistered claim r policy identity and does not block unrelated removals; registry failures still stop the command. A TIP-403 policy can be shared: several stacks may attach the same policy on one chain, and a removal applies to all of them. `remove` therefore runs these checks for every deployed profile in -the same profiles directory (each `/profile.json` beside the selected one with a generated -`deployment.json`) whose chain and `tip403PolicyId` match, each loaded with its own `.env.`, -which alone supplies its `EARN_TRUSTED_DEPLOYMENT_IDENTITY`; a sibling that fails to load stops -the command. Stacks attaching the policy from profiles kept elsewhere are not discovered. -The scan requires a valid deployment-block anchor, reads request and closure events in bounded -block ranges, and checks only outstanding claims, with at most 16 concurrent reads. Each change is -simulated, sent, and read back against the verified stack and policy bindings. +the same profiles directory (each `/profile.json` beside the selected one, symlinked or not) +whose verified profile and record match the selected chain, genesis, and `tip403PolicyId`. Each +runs with its own `.env.`, which wins over the shell, alone supplies its +`EARN_TRUSTED_DEPLOYMENT_IDENTITY`, and supplies its RPC settings as a group when it names any of them. A +refusal names the stack that needs the target. Any neighbour with a record that fails to load, and +any neighbour with an unpublished deployment on the same policy, stops the command and is named. +Stacks without a record beside a profile (one kept elsewhere, or an earlier version superseded by a +redeployment that is still bound to the policy) are not discovered; check them before removing a +member they may need. ## Tests