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
132 changes: 125 additions & 7 deletions deployments/modules/earn/access-policy/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { loadEarnProfile } from '../../../core/bundle.js'
import { deploymentIntentHash } from '../../../core/profile.js'
import { resolveEarnFactoryRelease } from '../../../core/releases.js'
import type { EarnStack } from '../../../core/stack.js'
import { policySiblingStacks, siblingEnvironment } from './cli.js'
import { policySiblingStacks, redactUrls, siblingEnvironment } from './cli.js'

const venue = '0x0d136e26b4d9e4836fc5cb202baeb49cb6f5df48'

Expand All @@ -14,7 +17,7 @@ function repository(neighbours: Record<string, { profile: unknown; pending?: boo
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'), '{}')
if (pending) writePending(join(directory, id))
}
const profiles = join(root, 'profiles')
write(profiles, 'selected-moderato', authored('selected-moderato'))
Expand All @@ -24,6 +27,29 @@ function repository(neighbours: Record<string, { profile: unknown; pending?: boo
return { root, profiles, selected: join(profiles, 'selected-moderato', 'profile.json') }
}

const deployer = '0x00000000000000000000000000000000000000D1'

/** A pending deployment bound to the authored profile in `directory`, as `pnpm earn deploy` writes. */
function writePending(directory: string) {
const path = join(directory, 'profile.json')
const preliminary = loadEarnProfile(path)
const factories = resolveEarnFactoryRelease(preliminary.chainId)
const profile = loadEarnProfile(path, { deployer, factories })
writeFileSync(
join(directory, 'deployment.pending.json'),
JSON.stringify({
version: 3,
profileId: profile.id,
deployer: profile.deployer,
owner: profile.owner,
factories: profile.factories,
deploymentIntentHash: deploymentIntentHash(profile),
outputs: {},
actions: {},
}),
)
}

function authored(id: string, policy = '469') {
return {
version: 1,
Expand All @@ -47,21 +73,35 @@ describe('policy sibling discovery', () => {
'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/,
/profile other-moderato: it has an unpublished deployment/,
)
})

it('ignores an unpublished deployment on another policy', async () => {
it('ignores a pending deployment bound to 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('stops when a pending deployment no longer matches its edited profile', async () => {
const { profiles, selected: path } = repository({
'other-moderato': { profile: authored('other-moderato', '470'), pending: true },
})
writeFileSync(
join(profiles, 'other-moderato', 'profile.json'),
JSON.stringify(authored('other-moderato', '471')),
)
await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow(
/profile other-moderato: Pending deployment .* does not match this profile/,
)
})

it('names an unparseable neighbour instead of failing anonymously', async () => {
const { selected: path } = repository({
'broken-moderato': { profile: { id: 'broken-moderato' }, pending: true },
'broken-moderato': { profile: { id: 'broken-moderato' } },
})
writeFileSync(join(dirname(path), '..', 'broken-moderato', 'deployment.pending.json'), '{}')
await expect(policySiblingStacks(path, selected, genesis)).rejects.toThrow(
/profile broken-moderato:/,
)
Expand All @@ -75,7 +115,7 @@ describe('policy sibling discovery', () => {
join(elsewhere, 'linked-moderato', 'profile.json'),
JSON.stringify(authored('linked-moderato')),
)
writeFileSync(join(elsewhere, 'linked-moderato', 'deployment.pending.json'), '{}')
writePending(join(elsewhere, 'linked-moderato'))
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/,
Expand Down Expand Up @@ -125,6 +165,44 @@ describe('policy sibling discovery', () => {
)
})

describe('a recorded neighbour naming another genesis of this chain', () => {
const earnShare = '0x00000000000000000000000000000000000000E5'
const setup = () => {
const { profiles, selected: path } = repository({
'other-moderato': { profile: authored('other-moderato') },
})
writeFileSync(
join(profiles, 'other-moderato', 'deployment.json'),
JSON.stringify({
target: { chainId: 42431, genesisHash: '0x' + '33'.repeat(32) },
addresses: { earnShare },
}),
)
return path
}

it('is skipped when its EarnShare has no code here', async () => {
const getCode = vi.fn(async () => undefined)
await expect(policySiblingStacks(setup(), selected, genesis, { getCode })).resolves.toEqual(
[],
)
expect(getCode).toHaveBeenCalledWith({ address: earnShare })
})

it('stops the removal when its EarnShare has code here', async () => {
const getCode = vi.fn(async () => '0x6080' as const)
await expect(policySiblingStacks(setup(), selected, genesis, { getCode })).rejects.toThrow(
/profile other-moderato: its record names genesis 0x3333.*may be live on this chain/,
)
})

it('stops the removal when liveness cannot be checked', async () => {
await expect(policySiblingStacks(setup(), selected, genesis)).rejects.toThrow(
/may be live on this chain/,
)
})
})

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') },
Expand Down Expand Up @@ -185,3 +263,43 @@ describe('sibling environment', () => {
})
})
})

describe('neighbour failure messages', () => {
it('redact a whole URL whose path contains parentheses', () => {
expect(
redactUrls('failed: https://rpc.example/(v2)/fake-api-secret?key=fake-query-secret.'),
).toBe('failed: https://rpc.example/[redacted].')
})

it('redact a whole URL whose credentials contain quotes or angle brackets', () => {
for (const secret of ["pa'ss", 'pa"ss', 'pa<ss', 'pa>ss']) {
const out = redactUrls(`failed: https://user:${secret}@rpc.example/v2 next`)
expect(out).toBe('failed: https://rpc.example/[redacted] next')
}
})

it('keep quotes and angle brackets around a URL outside it', () => {
expect(redactUrls('url "https://rpc.example/v2/key" or <https://rpc.example>.')).toBe(
'url "https://rpc.example/[redacted]" or <https://rpc.example>.',
)
})

it('handle a long punctuation run in linear time', () => {
const run = '.'.repeat(200_000)
const started = performance.now()
expect(redactUrls(`https://rpc.example/${run}x${run}`)).toBe(
`https://rpc.example/[redacted]${run}`,
)
expect(performance.now() - started).toBeLessThan(1_000)
})

it('keep only the origin of each URL', () => {
expect(
redactUrls(
'HTTP request failed. URL: https://rpc.example/v2/sk_live_123?key=abc Details: x (https://rpc.example)',
),
).toBe(
'HTTP request failed. URL: https://rpc.example/[redacted] Details: x (https://rpc.example)',
)
})
})
96 changes: 73 additions & 23 deletions deployments/modules/earn/access-policy/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { parseArgs } from 'node:util'

import { getAddress } from 'viem'
import { getAddress, isAddress, type Address, type PublicClient } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { createClient } from 'viem/tempo'

Expand All @@ -11,6 +11,7 @@ import {
loadDeploymentEnvironment,
loadEarnProfile,
loadEarnProfileBundle,
loadEarnProfileForRead,
loadReviewedDeploymentIdentity,
pendingDeploymentPath,
profileDeploymentPath,
Expand Down Expand Up @@ -87,7 +88,7 @@ async function access(argv: readonly string[], output: Pick<Console, 'log' | 'wa
})
const siblings =
command === 'remove'
? await policySiblingStacks(path, stack, bundle.record.target.genesisHash)
? await policySiblingStacks(path, stack, bundle.record.target.genesisHash, publicClient)
: []
print(
await setAccessEligibility({
Expand All @@ -106,14 +107,18 @@ async function access(argv: readonly string[], output: Pick<Console, 'log' | 'wa
* 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.
* and is named. A neighbour with only an unpublished deployment stops it when that deployment,
* bound to its authored profile through the pending intent hash, targets this chain and policy, or
* when that binding fails: its contracts may already be bound to the policy. A record naming
* another genesis excludes a neighbour only when its EarnShare has no code on this chain. Each
* neighbour runs with its own `.env.<id>`, which alone supplies its reviewed identity and, when it
* names any, its RPC settings.
*/
export async function policySiblingStacks(
profilePath: string,
stack: EarnStack,
genesisHash: string,
client?: Pick<PublicClient, 'getCode'>,
) {
if (stack.access.kind !== 'tip403-whitelist') return []
const root = profileRepositoryRoot(profilePath)
Expand All @@ -132,7 +137,7 @@ export async function policySiblingStacks(
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)
const detail = redactUrls(reason instanceof Error ? reason.message : String(reason))
throw new Error(`Cannot check removal safety against profile ${name}: ${detail}`, {
cause: reason,
})
Expand All @@ -141,21 +146,31 @@ export async function policySiblingStacks(
const authored = loadEarnProfile(path)
if (authored.id === stack.id) continue
if (!recorded) {
if (authored.chainId === stack.network.chainId && transferPolicyId(authored) === policyId) {
// Bind the authored profile to its pending intent first, so an edited profile cannot hide it.
const pending = loadEarnProfileForRead(path)
if (pending.chainId === stack.network.chainId && transferPolicyId(pending) === policyId) {
fail(`it has an unpublished deployment on policy ${policyId}; publish its record first`)
}
continue
}
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
// 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
// Only a record that may target this chain can share the policy; others need no strict load.
const target = recordTarget(path, authored.chainId, stack.network.chainId, genesisHash)
if (target.kind === 'other-chain') continue
if (target.kind === 'other-genesis') {
// A wrong genesis must not hide a live stack: skip only when its EarnShare is not here.
const code = client && (await client.getCode({ address: target.earnShare }))
if (client && (!code || code === '0x')) continue
fail(
`its record names genesis ${target.genesisHash}, but its EarnShare may be live on this chain`,
)
}
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) {
Expand All @@ -169,33 +184,68 @@ export async function policySiblingStacks(
}

/**
* 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.
* Classifies a generated record's target before the strict load, so a neighbour on another chain
* never blocks a removal. Only a well-formed target that agrees with its authored chain is trusted
* to differ; anything malformed or inconsistent counts as possibly the same, so the strict load
* rejects it.
*/
function sameTarget(
function recordTarget(
profilePath: string,
authoredChainId: number,
chainId: number,
genesisHash: string,
) {
):
| { kind: 'possibly-same' }
| { kind: 'other-chain' }
| { kind: 'other-genesis'; genesisHash: string; earnShare: Address } {
try {
const { target } = JSON.parse(readFileSync(profileDeploymentPath(profilePath), 'utf8')) as {
const record = JSON.parse(readFileSync(profileDeploymentPath(profilePath), 'utf8')) as {
target?: { chainId?: unknown; genesisHash?: unknown }
addresses?: { earnShare?: unknown }
}
const target = record.target
if (typeof target?.chainId !== 'number' || target.chainId !== authoredChainId) {
return { kind: 'possibly-same' }
}
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 { kind: 'possibly-same' }
}
if (target.chainId !== chainId) return { kind: 'other-chain' }
if (target.genesisHash.toLowerCase() === genesisHash.toLowerCase()) {
return { kind: 'possibly-same' }
}
const earnShare = record.addresses?.earnShare
if (typeof earnShare !== 'string' || !isAddress(earnShare)) return { kind: 'possibly-same' }
return {
kind: 'other-genesis',
genesisHash: target.genesisHash,
earnShare: getAddress(earnShare),
}
return (
target.chainId === chainId && target.genesisHash.toLowerCase() === genesisHash.toLowerCase()
)
} catch {
return true
return { kind: 'possibly-same' }
}
}

/** Keeps only each URL's origin, so RPC paths and query tokens never reach the terminal. */
export function redactUrls(message: string) {
// Take the whole non-space token, since quotes, brackets and other punctuation are valid in
// paths and credentials, and keep only trailing punctuation outside the redacted URL.
return message.replace(/https?:\/\/\S+/g, (token) => {
// Scan back from the end: linear, unlike an unanchored trailing-run regex.
let end = token.length
while (end > 0 && `).,;:!?'"<>`.includes(token[end - 1]!)) end--
const trailing = token.slice(end)
const candidate = token.slice(0, end)
try {
const url = new URL(candidate)
const kept = candidate === url.origin || candidate === `${url.origin}/`
return (kept ? url.origin : `${url.origin}/[redacted]`) + trailing
} catch {
return '[redacted URL]' + trailing
}
})
}
Comment thread
tempoxyz-bot marked this conversation as resolved.
Resolved

const rpcKeys = ['TEMPO_RPC_URL', 'TEMPO_RPC_USERNAME', 'TEMPO_RPC_PASSWORD'] as const

/**
Expand Down
13 changes: 7 additions & 6 deletions docs/earn/modules/access.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,13 @@ policy identity and does not block unrelated removals; registry failures still s
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 `<id>/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.<id>`, 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
whose verified profile and record match the selected chain and `tip403PolicyId`. A record naming
another genesis of the same chain is skipped only when its EarnShare has no code on the selected
chain. Each runs with its own `.env.<id>`, 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; credentials without a URL there are refused. A refusal names the stack that needs the target.
Any neighbour with a record that fails to load, and any neighbour with only an unpublished deployment that, bound to its profile through its pending intent hash, targets this chain and policy (or cannot be bound), stops the command and is named; failure messages keep only each URL's origin. 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.

Expand Down
Loading