Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions deployments/modules/earn/access-policy/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { profile: unknown; pending?: boolean }>) {
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',
})
})
})
146 changes: 119 additions & 27 deletions deployments/modules/earn/access-policy/cli.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -10,6 +10,9 @@ import { configuredTempoChain } from '../../../chain/tempo.js'
import {
loadDeploymentEnvironment,
loadEarnProfile,
loadEarnProfileBundle,
loadReviewedDeploymentIdentity,
pendingDeploymentPath,
profileDeploymentPath,
profileRepositoryRoot,
resolveProfilePath,
Expand Down Expand Up @@ -82,7 +85,10 @@ async function access(argv: readonly string[], output: Pick<Console, 'log' | 'wa
chain: configuredTempoChain(stack),
transport,
})
const siblings = command === 'remove' ? await policySiblingStacks(path, stack) : []
const siblings =
command === 'remove'
? await policySiblingStacks(path, stack, bundle.record.target.genesisHash)
: []
print(
await setAccessEligibility({
client: publicClient,
Expand All @@ -96,45 +102,131 @@ async function access(argv: readonly string[], output: Pick<Console, 'log' | 'wa
}

/**
* Every other deployed profile beside `profilePath` (the other `<id>/profile.json` entries in its
* parent directory) on the same chain that attaches the same TIP-403 policy, loaded live with its
* own `.env.<id>`. A sibling's reviewed identity comes only from its own `.env.<id>`, 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 `<id>/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.<id>`,
* 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
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
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.<id>` 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<string, string | undefined> = {
...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]
Comment thread
tempoxyz-bot marked this conversation as resolved.
Resolved
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
Expand Down
9 changes: 9 additions & 0 deletions deployments/modules/earn/access-policy/membership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading