From 2f62ed396daf169f68f871af2a3349febf9288b8 Mon Sep 17 00:00:00 2001 From: jennijuju Date: Sat, 22 Aug 2026 05:46:31 -0400 Subject: [PATCH 1/8] feat(session): per-command scope gating with console-first remediation Session-key mode required all four FWSS permissions on every command, so a least-privilege key (e.g. upload-only) could not run the CLI at all. Commands now declare the permissions they need; the preflight checks only those and passes the same set to Synapse.create (whose own gate otherwise defaults to all four). Per-command sets: add/import = CreateDataSet+AddPieces; rm = SchedulePieceRemovals; data-set terminate = TerminateService; pinning server = CreateDataSet+AddPieces+SchedulePieceRemovals; reads = none. A missing scope now fails with a console-first remediation: the target network, then a Filecoin Pay console deep link (?authorize=&scopes=) to approve with the owner wallet, then the owner-only CLI commands (session authorize/create --scopes). Distinguishes 'not authorized at all' (wrong network / never granted / revoked, with a --network hint) from 'missing this scope'. Console URL resolves via CONSOLE_URL env or a mainnet default (TEMPORARY helper to dedupe with the console-pairing branch). --- README.md | 16 ++- src/add/add.ts | 2 + src/core/session/console-url.ts | 34 ++++++ src/core/synapse/index.ts | 96 +++++++++++------ src/data-set/run.ts | 3 +- src/filecoin-pinning-server.ts | 7 ++ src/import/import.ts | 2 + src/rm/remove-all-pieces.ts | 2 + src/rm/remove-piece.ts | 3 + src/test/mocks/synapse-core-session-key.ts | 7 ++ src/test/unit/synapse-service.test.ts | 117 +++++++++++++++++++++ src/utils/cli-auth.ts | 6 +- 12 files changed, 258 insertions(+), 37 deletions(-) create mode 100644 src/core/session/console-url.ts diff --git a/README.md b/README.md index d61a53e8..16002003 100644 --- a/README.md +++ b/README.md @@ -261,12 +261,26 @@ filecoin-pin add myfile.txt * `-v`, `--verbose`: Verbose output * `--private-key`: Ethereum-style (`0x`) private key (wallet and signer), funded with USDFC * `--wallet-address`: Session key mode: owner wallet address -* `--session-key`: Session key mode: the session key's **private key** (printed as `SESSION_KEY` by `filecoin-pin session create` / `session generate`), not the session address +* `--session-key`: Session key mode: the session key's **private key** (printed as `SESSION_KEY` by `filecoin-pin session create` / `session generate`), not the session address. Each command checks only the permissions it needs; see [Session-Key Permissions](#session-key-permissions) below. * `--network`: Filecoin network to use: `mainnet`, `calibration`, or `devnet` (default: `mainnet`). Mutually exclusive with `--rpc-url`. * `--rpc-url`: Filecoin RPC endpoint. Filecoin Pin probes its `eth_chainId` to derive the chain. Mutually exclusive with `--network`. Other arguments are possible for individual commands, use `--help` to find out more. +### Session-Key Permissions + +In session-key mode, each command checks only the on-chain permissions it needs — a delegate does not need the full FWSS permission set to run a scoped subset of commands. + +| Command | Required scopes | +| --- | --- | +| Read commands (`payments status`, `data-set ls`, `provider ls`, `data-set show`, `data-set piece-status`, …) | None | +| `add`, `import` | `CreateDataSet`, `AddPieces` | +| `rm` (`--piece` or `--all`) | `SchedulePieceRemovals` | +| `data-set terminate` | `TerminateService` | +| Pinning server (`filecoin-pinning-server`) | `CreateDataSet`, `AddPieces`, `SchedulePieceRemovals` | + +If the session key is missing a required scope, the command fails up front with a console link to approve the missing scope with the owner wallet, plus the equivalent `filecoin-pin session authorize` / `filecoin-pin session create` commands for the account owner to run. + ### Environment Variables ```bash diff --git a/src/add/add.ts b/src/add/add.ts index b04ea532..401e4a53 100644 --- a/src/add/add.ts +++ b/src/add/add.ts @@ -8,6 +8,7 @@ import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { Readable } from 'node:stream' +import { AddPiecesPermission, CreateDataSetPermission } from '@filoz/synapse-core/session-key' import pc from 'picocolors' import pino from 'pino' import { CliFatal, isCliFatal } from '../common/cli-errors.js' @@ -181,6 +182,7 @@ export async function runAdd(options: AddOptions): Promise = {} + +/** + * Pick the console base URL: an explicit override wins, then `CONSOLE_URL`, + * then the known deployment for the chain. Returns `undefined` when none of + * those resolve — the caller falls back to a console-without-a-link message. + */ +export function resolveConsoleUrl(chainId: number, override?: string): string | undefined { + return override ?? process.env.CONSOLE_URL ?? DEFAULT_CONSOLE_URLS[chainId] +} + +/** + * Build the console deep link that pre-fills the session address and the + * scopes it needs on the session-keys authorization page. + */ +export function buildAuthorizeUrl(consoleUrl: string, sessionAddress: string, scopeIds: string[]): string { + const base = consoleUrl.endsWith('/') ? consoleUrl.slice(0, -1) : consoleUrl + return `${base}/console/session-keys?authorize=${sessionAddress}&scopes=${scopeIds.join(',')}` +} diff --git a/src/core/synapse/index.ts b/src/core/synapse/index.ts index 921c7a1b..d4ad0b95 100644 --- a/src/core/synapse/index.ts +++ b/src/core/synapse/index.ts @@ -13,14 +13,7 @@ import { type Chain, calibration, mainnet, Synapse, type SynapseOptions } from ' export { calibration, mainnet, type Chain } import type { SessionKey } from '@filoz/synapse-core/session-key' -import { - AddPiecesPermission, - CreateDataSetPermission, - DefaultFwssPermissions, - fromSecp256k1, - SchedulePieceRemovalsPermission, - TerminateServicePermission, -} from '@filoz/synapse-core/session-key' +import { fromSecp256k1, type Permission, PermissionNames } from '@filoz/synapse-core/session-key' import type { Logger } from 'pino' import { type Account, @@ -32,6 +25,7 @@ import { type WebSocketTransport, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' +import { buildAuthorizeUrl, resolveConsoleUrl } from '../session/console-url.js' import { APPLICATION_SOURCE } from './constants.js' import { createTransport } from './create-transport.js' import { resolveChainFromRpc } from './resolve-chain-from-rpc.js' @@ -70,6 +64,12 @@ interface BaseSynapseConfig { withCDN?: boolean /** Default metadata to apply when creating datasets */ dataSetMetadata?: Record + /** + * Session-key mode only: on-chain permissions this invocation needs, checked + * up front so a missing grant fails with a remediation message instead of a + * tx revert. Defaults to none — commands that only read require no permissions. + */ + requiredPermissions?: Permission[] } /** @@ -130,14 +130,6 @@ function isSessionKeyConfig(config: SynapseSetupConfig): config is SessionKeyCon function isReadOnlyConfig(config: SynapseSetupConfig): config is ReadOnlyConfig { return 'readOnly' in config && (config as ReadOnlyConfig).readOnly === true && 'walletAddress' in config } - -const PERMISSION_NAMES: Record = { - [CreateDataSetPermission]: 'CreateDataSet', - [TerminateServicePermission]: 'TerminateService', - [AddPiecesPermission]: 'AddPieces', - [SchedulePieceRemovalsPermission]: 'SchedulePieceRemovals', -} - /** * Reject malformed session key material before it reaches the SDK, whose own * error ("invalid private key, expected hex or 32 bytes") never names the flag. @@ -153,28 +145,60 @@ export function assertSessionKeyPrivateKey(value: string): asserts value is Hex ) } -function checkSessionKeyPermissions(key: SessionKey<'Secp256k1'>, ownerAddress: string): void { - const missing = DefaultFwssPermissions.filter((p) => !key.hasPermission(p)) +/** + * Preflight a session key against the permissions an operation needs. + * + * Two failure shapes, both console-first (spec: problem -> console + * recommended -> owner CLI). Never tells a delegate to run a root-key + * command as their own action. + * + * - Not authorized at all: every on-chain expiration is 0 (never granted, + * or fully expired/revoked — on-chain state can't tell those apart). + * - Missing the required scope: some grant is live, but not the one this + * operation needs. + */ +function checkSessionKeyPermissions( + key: SessionKey<'Secp256k1'>, + ownerAddress: string, + required: Permission[], + chainId: number, + networkName: string +): void { + const missing = required.filter((p) => !key.hasPermission(p)) if (missing.length === 0) return - const now = BigInt(Math.floor(Date.now() / 1000)) - const lines = missing.map((p) => { - const name = PERMISSION_NAMES[p] ?? p - const expiry = key.expirations[p] ?? 0n - if (expiry > 0n && expiry < now) { - return ` • ${name}: expired at ${new Date(Number(expiry) * 1000).toISOString()}` - } - return ` • ${name}: never authorized` + const allExpirations = Object.values(key.expirations) + const neverAuthorized = allExpirations.length > 0 && allExpirations.every((expiry) => expiry === 0n) + + // Scope ids are the camelCase forms of the FWSS EIP-712 operation names + // (PermissionNames is PascalCase: CreateDataSet -> createDataSet). Derive + // them here rather than importing the CLI-layer session/scopes.ts into core. + const scopeIds = missing.map((p) => { + const name = PermissionNames[p] + if (name == null || name.length === 0) return p + return name.charAt(0).toLowerCase() + name.slice(1) }) + const scopeLabels = missing.map((p) => PermissionNames[p] ?? p).join(', ') + const scopesArg = scopeIds.join(',') - const footnotes = missing.map((p) => ` ${PERMISSION_NAMES[p] ?? p}: ${p}`) + const problem = neverAuthorized + ? `Session key ${key.address} isn't authorized for account ${ownerAddress} on ${networkName} — never authorized, expired/revoked, or the key is for a different network (check --network).` + : `Session key ${key.address} lacks ${scopeLabels} for this operation on ${networkName}.` - throw new Error( - `Session key ${key.address} is missing ${missing.length} required permission(s):\n` + - lines.join('\n') + - `\nAuthorize this session key from owner wallet ${ownerAddress}.\nPermission hashes:\n` + - footnotes.join('\n') - ) + const consoleUrl = resolveConsoleUrl(chainId) + const lines = [problem, ''] + if (consoleUrl != null) { + lines.push('Recommended — approve in the browser with the owner wallet:') + lines.push(` ${buildAuthorizeUrl(consoleUrl, key.address, scopeIds)}`) + } else { + lines.push('Authorize in the Filecoin Pay console (set CONSOLE_URL for a direct link).') + } + lines.push('') + lines.push('The account owner can also use the CLI:') + lines.push(` filecoin-pin session authorize ${key.address} --scopes ${scopesArg} (add scope to this key)`) + lines.push(` filecoin-pin session create --scopes ${scopesArg} (or mint a new scoped key)`) + + throw new Error(lines.join('\n')) } /** @@ -231,7 +255,7 @@ export async function initializeSynapse(config: SynapseSetupConfig, logger?: Log throw new Error(`Invalid --session-key / SESSION_KEY: ${reason}`, { cause: error }) } await sessionKey.syncExpirations() - checkSessionKeyPermissions(sessionKey, walletAddress) + checkSessionKeyPermissions(sessionKey, walletAddress, config.requiredPermissions ?? [], chain.id, chain.name) logger?.info({ event: 'synapse.init', mode: 'session-key' }, 'Initializing Synapse (session key)') } else if (isPrivateKeyConfig(config)) { account = privateKeyToAccount(config.privateKey) @@ -280,6 +304,10 @@ export async function initializeSynapse(config: SynapseSetupConfig, logger?: Log } if (sessionKey) { synapseOptions.sessionKey = sessionKey + // Match the SDK's own permission gate to this command's needs; without it + // Synapse.create defaults to requiring all FWSS permissions and re-rejects a + // subset key that our preflight already accepted. + synapseOptions.requiredPermissions = config.requiredPermissions ?? [] } if (config.withCDN) { synapseOptions.withCDN = config.withCDN diff --git a/src/data-set/run.ts b/src/data-set/run.ts index b494fd7a..20553aa5 100644 --- a/src/data-set/run.ts +++ b/src/data-set/run.ts @@ -1,4 +1,5 @@ import { confirm, isCancel } from '@clack/prompts' +import { TerminateServicePermission } from '@filoz/synapse-core/session-key' import type { EnhancedDataSetInfo, Synapse } from '@filoz/synapse-sdk' import pc from 'picocolors' import { WaitForTransactionReceiptTimeoutError } from 'viem' @@ -223,7 +224,7 @@ export async function runTerminateDataSetCommand(dataSetId: number, options: Dat spinner.start('Connecting to Synapse...') try { - const synapse = await getCliSynapse(options) + const synapse = await getCliSynapse(options, [TerminateServicePermission]) const network = synapse.chain.name const address = getClientAddress(synapse) diff --git a/src/filecoin-pinning-server.ts b/src/filecoin-pinning-server.ts index 0dc36dac..bbb3f0a5 100644 --- a/src/filecoin-pinning-server.ts +++ b/src/filecoin-pinning-server.ts @@ -1,3 +1,8 @@ +import { + AddPiecesPermission, + CreateDataSetPermission, + SchedulePieceRemovalsPermission, +} from '@filoz/synapse-core/session-key' import fastify, { type FastifyError, type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify' import { CID } from 'multiformats/cid' import type { Logger } from 'pino' @@ -105,6 +110,8 @@ function buildSynapseConfig(config: Config): SynapseSetupConfig { ...base, walletAddress: config.walletAddress, sessionKey: config.sessionKey, + // The pinning API creates datasets, adds pieces, and deletes pins. + requiredPermissions: [CreateDataSetPermission, AddPiecesPermission, SchedulePieceRemovalsPermission], } } diff --git a/src/import/import.ts b/src/import/import.ts index 55b42577..96d6e595 100644 --- a/src/import/import.ts +++ b/src/import/import.ts @@ -8,6 +8,7 @@ import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { Readable } from 'node:stream' +import { AddPiecesPermission, CreateDataSetPermission } from '@filoz/synapse-core/session-key' import { CarReader } from '@ipld/car' import { CID } from 'multiformats/cid' import pc from 'picocolors' @@ -251,6 +252,7 @@ export async function runCarImport(options: ImportOptions): Promise = { + [CreateDataSetPermission]: 'CreateDataSet', + [TerminateServicePermission]: 'TerminateService', + [AddPiecesPermission]: 'AddPieces', + [SchedulePieceRemovalsPermission]: 'SchedulePieceRemovals', +} + const mockExpirations = Object.fromEntries(DefaultFwssPermissions.map((p) => [p, 0n])) export const fromSecp256k1: Mock = vi.fn(() => ({ diff --git a/src/test/unit/synapse-service.test.ts b/src/test/unit/synapse-service.test.ts index c9821b9c..b93a5cd3 100644 --- a/src/test/unit/synapse-service.test.ts +++ b/src/test/unit/synapse-service.test.ts @@ -1,3 +1,11 @@ +// Value import of the mocked module (vi.mock below replaces it at runtime). +import { + AddPiecesPermission, + CreateDataSetPermission, + fromSecp256k1, + SchedulePieceRemovalsPermission, + TerminateServicePermission, +} from '@filoz/synapse-core/session-key' import { CID } from 'multiformats/cid' import type { Logger } from 'pino' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -98,6 +106,115 @@ describe('synapse-service', () => { ) }) + // Queue a one-shot session key whose only meaningful behaviour is hasPermission + // and expirations. initializeSynapse touches just syncExpirations/hasPermission/ + // expirations/address, so the partial object is cast to the concrete (unexported) + // class the real fromSecp256k1 returns. + const mockSessionKeyOnce = (hasPermission: (p: string) => boolean, expirations: Record) => { + vi.mocked(fromSecp256k1).mockImplementationOnce((() => ({ + syncExpirations: vi.fn().mockResolvedValue(undefined), + expirations, + address: '0x0000000000000000000000000000000000000001', + hasPermission: vi.fn(hasPermission), + })) as unknown as typeof fromSecp256k1) + } + + it('should not require any permission by default (read-only commands)', async () => { + mockSessionKeyOnce(() => false, {}) // key granted nothing; no requiredPermissions means nothing to check + + const config: SynapseSetupConfig = { + walletAddress: '0x0000000000000000000000000000000000000002', + sessionKey: '0x0000000000000000000000000000000000000000000000000000000000000001', + rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', + } + + await expect(initializeSynapse(config, logger)).resolves.toBeDefined() + }) + + it('should reject only the missing scope, with a console-first remediation message', async () => { + // Grant everything except AddPieces — a live grant exists, so this is case 3 + // (missing scope), not case 2 (never authorized at all). + mockSessionKeyOnce((p) => p !== AddPiecesPermission, { + [CreateDataSetPermission]: 9999999999n, + [AddPiecesPermission]: 0n, + [SchedulePieceRemovalsPermission]: 9999999999n, + [TerminateServicePermission]: 9999999999n, + }) + + const previousConsoleUrl = process.env.CONSOLE_URL + process.env.CONSOLE_URL = 'https://pay.example.test' + try { + const config: SynapseSetupConfig = { + walletAddress: '0x0000000000000000000000000000000000000002', + sessionKey: '0x0000000000000000000000000000000000000000000000000000000000000001', + rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', + requiredPermissions: [AddPiecesPermission], + } + + const error = await initializeSynapse(config, logger).then( + () => null, + (e: unknown) => e as Error + ) + expect(error).not.toBeNull() + expect(error?.message).toContain('lacks AddPieces for this operation on ') + expect(error?.message).not.toContain('TerminateService') + expect(error?.message).toContain( + 'https://pay.example.test/console/session-keys?authorize=0x0000000000000000000000000000000000000001&scopes=addPieces' + ) + expect(error?.message).toContain( + 'filecoin-pin session authorize 0x0000000000000000000000000000000000000001 --scopes addPieces' + ) + expect(error?.message).toContain('filecoin-pin session create --scopes addPieces') + } finally { + if (previousConsoleUrl == null) { + delete process.env.CONSOLE_URL + } else { + process.env.CONSOLE_URL = previousConsoleUrl + } + } + }) + + it("should use the 'never authorized, or expired/revoked' wording when the key holds no live grant", async () => { + mockSessionKeyOnce(() => false, { + [CreateDataSetPermission]: 0n, + [AddPiecesPermission]: 0n, + [SchedulePieceRemovalsPermission]: 0n, + [TerminateServicePermission]: 0n, + }) + + const config: SynapseSetupConfig = { + walletAddress: '0x0000000000000000000000000000000000000002', + sessionKey: '0x0000000000000000000000000000000000000000000000000000000000000001', + rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', + requiredPermissions: [AddPiecesPermission], + } + + const error = await initializeSynapse(config, logger).then( + () => null, + (e: unknown) => e as Error + ) + expect(error).not.toBeNull() + expect(error?.message).toContain("isn't authorized for account 0x0000000000000000000000000000000000000002 on ") + expect(error?.message).toContain('the key is for a different network (check --network)') + }) + + it('should pass when the key holds exactly the required permissions', async () => { + const granted: Record = { [AddPiecesPermission]: true, [CreateDataSetPermission]: true } + mockSessionKeyOnce((p) => granted[p] === true, { + [CreateDataSetPermission]: 9999999999n, + [AddPiecesPermission]: 9999999999n, + }) + + const config: SynapseSetupConfig = { + walletAddress: '0x0000000000000000000000000000000000000002', + sessionKey: '0x0000000000000000000000000000000000000000000000000000000000000001', + rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', + requiredPermissions: [AddPiecesPermission, CreateDataSetPermission], + } + + await expect(initializeSynapse(config, logger)).resolves.toBeDefined() + }) + it('should throw when no authentication is provided', async () => { // AccountConfig with null account satisfies the type but triggers the no-auth branch const config = { rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1' } as any diff --git a/src/utils/cli-auth.ts b/src/utils/cli-auth.ts index 320792ad..3b85da38 100644 --- a/src/utils/cli-auth.ts +++ b/src/utils/cli-auth.ts @@ -5,6 +5,7 @@ * and preparing them for use with the Synapse SDK. */ +import type { Permission } from '@filoz/synapse-core/session-key' import type { Chain, Synapse } from '@filoz/synapse-sdk' import { getRpcUrl, NETWORK_CHAINS, resolveDevnetConfig } from '../common/get-rpc-url.js' import type { SynapseSetupConfig } from '../core/synapse/index.js' @@ -248,8 +249,11 @@ export function getCLILogger() { return createLogger({ logLevel: process.env.LOG_LEVEL }) } -export async function getCliSynapse(options: CLIAuthOptions): Promise { +export async function getCliSynapse(options: CLIAuthOptions, requiredPermissions?: Permission[]): Promise { const authConfig = parseCLIAuth(options) + if (requiredPermissions != null) { + authConfig.requiredPermissions = requiredPermissions + } const logger = getCLILogger() return initializeSynapse(authConfig, logger) } From 3266b67ee656f8e8ca02449aed51cf7093674a26 Mon Sep 17 00:00:00 2001 From: jennijuju Date: Tue, 25 Aug 2026 20:58:24 -0400 Subject: [PATCH 2/8] feat(session): default console URL to pay.filecoin.cloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both networks resolve to the production console so gating errors carry a working ?authorize=&scopes= deep link out of the box; CONSOLE_URL and the explicit override still win for local/preview consoles. Must not ship before the console's session-keys page deploys — opened as a pair with the console PR. --- src/core/session/console-url.ts | 16 ++++++---- src/test/unit/console-url.test.ts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 src/test/unit/console-url.test.ts diff --git a/src/core/session/console-url.ts b/src/core/session/console-url.ts index 01eb911d..a1322fe2 100644 --- a/src/core/session/console-url.ts +++ b/src/core/session/console-url.ts @@ -6,14 +6,18 @@ */ /** - * Known console deployments by chain id. + * Known console deployments by chain id. One deployment serves both networks + * (the console switches network in-app), so mainnet and calibration share a + * base URL. `CONSOLE_URL` still overrides for local/preview consoles. * - * TODO: add pay.filecoin.cloud for 314/314159 once the console's - * session-keys page (and its ?authorize=&scopes= pairing params) ships to - * production — today it only exists on an unreleased branch, so a default - * link would 404. Until then the link only renders when CONSOLE_URL is set. + * NOTE: links 404 until the console's session-keys page (with its + * ?authorize=&scopes= pairing params) is deployed — this PR is opened + * together with that console PR and must not ship ahead of it. */ -export const DEFAULT_CONSOLE_URLS: Record = {} +export const DEFAULT_CONSOLE_URLS: Record = { + 314: 'https://pay.filecoin.cloud', + 314159: 'https://pay.filecoin.cloud', +} /** * Pick the console base URL: an explicit override wins, then `CONSOLE_URL`, diff --git a/src/test/unit/console-url.test.ts b/src/test/unit/console-url.test.ts new file mode 100644 index 00000000..2ca5598a --- /dev/null +++ b/src/test/unit/console-url.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { buildAuthorizeUrl, resolveConsoleUrl } from '../../core/session/console-url.js' + +const previousConsoleUrl = process.env.CONSOLE_URL + +afterEach(() => { + if (previousConsoleUrl == null) { + delete process.env.CONSOLE_URL + } else { + process.env.CONSOLE_URL = previousConsoleUrl + } +}) + +describe('resolveConsoleUrl', () => { + it('defaults to the production console on both networks', () => { + delete process.env.CONSOLE_URL + expect(resolveConsoleUrl(314)).toBe('https://pay.filecoin.cloud') + expect(resolveConsoleUrl(314159)).toBe('https://pay.filecoin.cloud') + }) + + it('returns undefined for unknown chains without an override', () => { + delete process.env.CONSOLE_URL + expect(resolveConsoleUrl(1)).toBeUndefined() + }) + + it('prefers CONSOLE_URL over the default, and the explicit override over both', () => { + process.env.CONSOLE_URL = 'http://localhost:3005' + expect(resolveConsoleUrl(314)).toBe('http://localhost:3005') + expect(resolveConsoleUrl(314, 'http://preview.test')).toBe('http://preview.test') + }) +}) + +describe('buildAuthorizeUrl', () => { + it('builds the session-keys deep link with pairing params', () => { + expect( + buildAuthorizeUrl('https://pay.filecoin.cloud', '0xAbC0000000000000000000000000000000000001', [ + 'createDataSet', + 'addPieces', + ]) + ).toBe( + 'https://pay.filecoin.cloud/console/session-keys?authorize=0xAbC0000000000000000000000000000000000001&scopes=createDataSet,addPieces' + ) + }) + + it('tolerates a trailing slash on the base URL', () => { + expect(buildAuthorizeUrl('http://localhost:3005/', '0xA', ['addPieces'])).toBe( + 'http://localhost:3005/console/session-keys?authorize=0xA&scopes=addPieces' + ) + }) +}) From 5198a43b163b1cf99418b62d641bbe3c1e810233 Mon Sep 17 00:00:00 2001 From: jennijuju Date: Tue, 25 Aug 2026 22:50:37 -0400 Subject: [PATCH 3/8] feat(session): carry network in console authorize links A calibration remediation link approved by a mainnet-connected wallet silently granted the scopes on mainnet (hit in testing). The console refuses to prefill on a chain mismatch when the link names its network. --- src/core/session/console-url.ts | 23 ++++++++++++++++++++--- src/core/synapse/index.ts | 2 +- src/test/unit/console-url.test.ts | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/core/session/console-url.ts b/src/core/session/console-url.ts index a1322fe2..91c1fc38 100644 --- a/src/core/session/console-url.ts +++ b/src/core/session/console-url.ts @@ -28,11 +28,28 @@ export function resolveConsoleUrl(chainId: number, override?: string): string | return override ?? process.env.CONSOLE_URL ?? DEFAULT_CONSOLE_URLS[chainId] } +/** Console network slug by chain id; the console validates and guards on it. */ +const CONSOLE_NETWORK_SLUG: Record = { + 314: 'mainnet', + 314159: 'calibration', +} + /** * Build the console deep link that pre-fills the session address and the - * scopes it needs on the session-keys authorization page. + * scopes it needs on the session-keys authorization page. Carries the + * network the failure happened on so the console can refuse to prefill + * when the connected wallet is on a different chain — without it, a + * calibration remediation link approved by a mainnet-connected wallet + * silently grants the scopes on mainnet. */ -export function buildAuthorizeUrl(consoleUrl: string, sessionAddress: string, scopeIds: string[]): string { +export function buildAuthorizeUrl( + consoleUrl: string, + sessionAddress: string, + scopeIds: string[], + chainId?: number +): string { const base = consoleUrl.endsWith('/') ? consoleUrl.slice(0, -1) : consoleUrl - return `${base}/console/session-keys?authorize=${sessionAddress}&scopes=${scopeIds.join(',')}` + const network = chainId != null ? CONSOLE_NETWORK_SLUG[chainId] : undefined + const networkParam = network ? `&network=${network}` : '' + return `${base}/console/session-keys?authorize=${sessionAddress}&scopes=${scopeIds.join(',')}${networkParam}` } diff --git a/src/core/synapse/index.ts b/src/core/synapse/index.ts index d4ad0b95..09e9e904 100644 --- a/src/core/synapse/index.ts +++ b/src/core/synapse/index.ts @@ -189,7 +189,7 @@ function checkSessionKeyPermissions( const lines = [problem, ''] if (consoleUrl != null) { lines.push('Recommended — approve in the browser with the owner wallet:') - lines.push(` ${buildAuthorizeUrl(consoleUrl, key.address, scopeIds)}`) + lines.push(` ${buildAuthorizeUrl(consoleUrl, key.address, scopeIds, chainId)}`) } else { lines.push('Authorize in the Filecoin Pay console (set CONSOLE_URL for a direct link).') } diff --git a/src/test/unit/console-url.test.ts b/src/test/unit/console-url.test.ts index 2ca5598a..fa7e450b 100644 --- a/src/test/unit/console-url.test.ts +++ b/src/test/unit/console-url.test.ts @@ -48,3 +48,17 @@ describe('buildAuthorizeUrl', () => { ) }) }) + +describe('buildAuthorizeUrl network param', () => { + it('carries the network slug for known chain ids', () => { + expect(buildAuthorizeUrl('https://pay.filecoin.cloud', '0xA', ['addPieces'], 314159)).toBe( + 'https://pay.filecoin.cloud/console/session-keys?authorize=0xA&scopes=addPieces&network=calibration' + ) + expect(buildAuthorizeUrl('https://pay.filecoin.cloud', '0xA', ['addPieces'], 314)).toMatch(/&network=mainnet$/) + }) + + it('omits the param for unknown or missing chain ids', () => { + expect(buildAuthorizeUrl('https://pay.filecoin.cloud', '0xA', ['addPieces'], 1)).not.toMatch(/network=/) + expect(buildAuthorizeUrl('https://pay.filecoin.cloud', '0xA', ['addPieces'])).not.toMatch(/network=/) + }) +}) From e042881ec901ed5ecc3bc3d7dd0c41826b45d065 Mon Sep 17 00:00:00 2001 From: jennijuju Date: Tue, 25 Aug 2026 23:13:38 -0400 Subject: [PATCH 4/8] style(session): render console deep link in hyperlink colors --- src/core/synapse/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/synapse/index.ts b/src/core/synapse/index.ts index 09e9e904..8640434a 100644 --- a/src/core/synapse/index.ts +++ b/src/core/synapse/index.ts @@ -14,6 +14,7 @@ export { calibration, mainnet, type Chain } import type { SessionKey } from '@filoz/synapse-core/session-key' import { fromSecp256k1, type Permission, PermissionNames } from '@filoz/synapse-core/session-key' +import pc from 'picocolors' import type { Logger } from 'pino' import { type Account, @@ -189,7 +190,9 @@ function checkSessionKeyPermissions( const lines = [problem, ''] if (consoleUrl != null) { lines.push('Recommended — approve in the browser with the owner wallet:') - lines.push(` ${buildAuthorizeUrl(consoleUrl, key.address, scopeIds, chainId)}`) + // pc.cyan+underline: conventional terminal hyperlink styling so the link + // stands out of the error wall; picocolors self-disables when not a TTY. + lines.push(` ${pc.cyan(pc.underline(buildAuthorizeUrl(consoleUrl, key.address, scopeIds, chainId)))}`) } else { lines.push('Authorize in the Filecoin Pay console (set CONSOLE_URL for a direct link).') } From e26717e658fcf7bdec7bdf21b07bb9fdf74880dd Mon Sep 17 00:00:00 2001 From: jennijuju Date: Wed, 26 Aug 2026 00:48:16 -0400 Subject: [PATCH 5/8] chore(session): drop unused override param from resolveConsoleUrl No production caller ever passed it (only resolveConsoleUrl(chainId)), and CONSOLE_URL already covers the override use case. --- src/core/session/console-url.ts | 10 +++++----- src/test/unit/console-url.test.ts | 5 ++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/core/session/console-url.ts b/src/core/session/console-url.ts index 91c1fc38..3a60a520 100644 --- a/src/core/session/console-url.ts +++ b/src/core/session/console-url.ts @@ -20,12 +20,12 @@ export const DEFAULT_CONSOLE_URLS: Record = { } /** - * Pick the console base URL: an explicit override wins, then `CONSOLE_URL`, - * then the known deployment for the chain. Returns `undefined` when none of - * those resolve — the caller falls back to a console-without-a-link message. + * Pick the console base URL: `CONSOLE_URL` wins, then the known deployment + * for the chain. Returns `undefined` when neither resolves — the caller + * falls back to a console-without-a-link message. */ -export function resolveConsoleUrl(chainId: number, override?: string): string | undefined { - return override ?? process.env.CONSOLE_URL ?? DEFAULT_CONSOLE_URLS[chainId] +export function resolveConsoleUrl(chainId: number): string | undefined { + return process.env.CONSOLE_URL ?? DEFAULT_CONSOLE_URLS[chainId] } /** Console network slug by chain id; the console validates and guards on it. */ diff --git a/src/test/unit/console-url.test.ts b/src/test/unit/console-url.test.ts index fa7e450b..be0991f1 100644 --- a/src/test/unit/console-url.test.ts +++ b/src/test/unit/console-url.test.ts @@ -18,15 +18,14 @@ describe('resolveConsoleUrl', () => { expect(resolveConsoleUrl(314159)).toBe('https://pay.filecoin.cloud') }) - it('returns undefined for unknown chains without an override', () => { + it('returns undefined for unknown chains', () => { delete process.env.CONSOLE_URL expect(resolveConsoleUrl(1)).toBeUndefined() }) - it('prefers CONSOLE_URL over the default, and the explicit override over both', () => { + it('prefers CONSOLE_URL over the default', () => { process.env.CONSOLE_URL = 'http://localhost:3005' expect(resolveConsoleUrl(314)).toBe('http://localhost:3005') - expect(resolveConsoleUrl(314, 'http://preview.test')).toBe('http://preview.test') }) }) From 59c659f0ae3891ba8127212b69b827ece7773d2b Mon Sep 17 00:00:00 2001 From: jennijuju Date: Thu, 27 Aug 2026 21:36:14 -0400 Subject: [PATCH 6/8] fix(session): lowercase authorize address, restore per-scope expiry detail - buildAuthorizeUrl lowercases the address: the console validates with viem strict isAddress, which silently rejects wrong-checksum mixed-case; lowercase always passes - preflight failure restores the per-scope 'expired at ' vs 'never granted' detail the pre-gating wording carried - README: replace FWSS jargon in the permissions intro; scope table uses canonical camelCase ids matching --scopes on-the-wire casing - tests: expired-scope regression; scope-id derivation lockstep cross-check against PermissionNames --- README.md | 10 +++++----- src/core/session/console-url.ts | 6 +++++- src/core/synapse/index.ts | 15 ++++++++++++++- src/test/unit/console-url.test.ts | 10 ++++++---- src/test/unit/session-scopes.test.ts | 18 +++++++++++++++++- src/test/unit/synapse-service.test.ts | 27 +++++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 16002003..55b3902a 100644 --- a/README.md +++ b/README.md @@ -269,15 +269,15 @@ Other arguments are possible for individual commands, use `--help` to find out m ### Session-Key Permissions -In session-key mode, each command checks only the on-chain permissions it needs — a delegate does not need the full FWSS permission set to run a scoped subset of commands. +In session-key mode, each command checks only the on-chain permissions it needs — a delegate does not need every storage-service permission to run a scoped subset of commands. | Command | Required scopes | | --- | --- | | Read commands (`payments status`, `data-set ls`, `provider ls`, `data-set show`, `data-set piece-status`, …) | None | -| `add`, `import` | `CreateDataSet`, `AddPieces` | -| `rm` (`--piece` or `--all`) | `SchedulePieceRemovals` | -| `data-set terminate` | `TerminateService` | -| Pinning server (`filecoin-pinning-server`) | `CreateDataSet`, `AddPieces`, `SchedulePieceRemovals` | +| `add`, `import` | `createDataSet`, `addPieces` | +| `rm` (`--piece` or `--all`) | `schedulePieceRemovals` | +| `data-set terminate` | `terminateService` | +| Pinning server (`filecoin-pinning-server`) | `createDataSet`, `addPieces`, `schedulePieceRemovals` | If the session key is missing a required scope, the command fails up front with a console link to approve the missing scope with the owner wallet, plus the equivalent `filecoin-pin session authorize` / `filecoin-pin session create` commands for the account owner to run. diff --git a/src/core/session/console-url.ts b/src/core/session/console-url.ts index 3a60a520..05a9542a 100644 --- a/src/core/session/console-url.ts +++ b/src/core/session/console-url.ts @@ -41,6 +41,10 @@ const CONSOLE_NETWORK_SLUG: Record = { * when the connected wallet is on a different chain — without it, a * calibration remediation link approved by a mainnet-connected wallet * silently grants the scopes on mainnet. + * + * The address is lowercased: the console validates it with viem's strict + * `isAddress`, which accepts all-lowercase or a correct EIP-55 checksum but + * silently rejects mixed-case with a wrong checksum. Lowercase always passes. */ export function buildAuthorizeUrl( consoleUrl: string, @@ -51,5 +55,5 @@ export function buildAuthorizeUrl( const base = consoleUrl.endsWith('/') ? consoleUrl.slice(0, -1) : consoleUrl const network = chainId != null ? CONSOLE_NETWORK_SLUG[chainId] : undefined const networkParam = network ? `&network=${network}` : '' - return `${base}/console/session-keys?authorize=${sessionAddress}&scopes=${scopeIds.join(',')}${networkParam}` + return `${base}/console/session-keys?authorize=${sessionAddress.toLowerCase()}&scopes=${scopeIds.join(',')}${networkParam}` } diff --git a/src/core/synapse/index.ts b/src/core/synapse/index.ts index 8640434a..8da4576a 100644 --- a/src/core/synapse/index.ts +++ b/src/core/synapse/index.ts @@ -182,12 +182,25 @@ function checkSessionKeyPermissions( const scopeLabels = missing.map((p) => PermissionNames[p] ?? p).join(', ') const scopesArg = scopeIds.join(',') + // Per-scope detail preserves the expired-at vs never-granted distinction + // the on-chain expirations carry — an expired grant points at renewal, + // a never-granted scope points at a fresh authorization. + const now = BigInt(Math.floor(Date.now() / 1000)) + const scopeDetails = missing.map((p) => { + const name = PermissionNames[p] ?? p + const expiry = key.expirations[p] ?? 0n + if (expiry > 0n && expiry <= now) { + return ` • ${name}: expired at ${new Date(Number(expiry) * 1000).toISOString()}` + } + return ` • ${name}: never granted` + }) + const problem = neverAuthorized ? `Session key ${key.address} isn't authorized for account ${ownerAddress} on ${networkName} — never authorized, expired/revoked, or the key is for a different network (check --network).` : `Session key ${key.address} lacks ${scopeLabels} for this operation on ${networkName}.` const consoleUrl = resolveConsoleUrl(chainId) - const lines = [problem, ''] + const lines = neverAuthorized ? [problem, ''] : [problem, ...scopeDetails, ''] if (consoleUrl != null) { lines.push('Recommended — approve in the browser with the owner wallet:') // pc.cyan+underline: conventional terminal hyperlink styling so the link diff --git a/src/test/unit/console-url.test.ts b/src/test/unit/console-url.test.ts index be0991f1..15baa30e 100644 --- a/src/test/unit/console-url.test.ts +++ b/src/test/unit/console-url.test.ts @@ -30,20 +30,22 @@ describe('resolveConsoleUrl', () => { }) describe('buildAuthorizeUrl', () => { - it('builds the session-keys deep link with pairing params', () => { + it('builds the session-keys deep link with pairing params, lowercasing the address', () => { + // Lowercase is the contract: the console's strict isAddress silently + // rejects mixed-case with a wrong EIP-55 checksum; lowercase always passes. expect( buildAuthorizeUrl('https://pay.filecoin.cloud', '0xAbC0000000000000000000000000000000000001', [ 'createDataSet', 'addPieces', ]) ).toBe( - 'https://pay.filecoin.cloud/console/session-keys?authorize=0xAbC0000000000000000000000000000000000001&scopes=createDataSet,addPieces' + 'https://pay.filecoin.cloud/console/session-keys?authorize=0xabc0000000000000000000000000000000000001&scopes=createDataSet,addPieces' ) }) it('tolerates a trailing slash on the base URL', () => { expect(buildAuthorizeUrl('http://localhost:3005/', '0xA', ['addPieces'])).toBe( - 'http://localhost:3005/console/session-keys?authorize=0xA&scopes=addPieces' + 'http://localhost:3005/console/session-keys?authorize=0xa&scopes=addPieces' ) }) }) @@ -51,7 +53,7 @@ describe('buildAuthorizeUrl', () => { describe('buildAuthorizeUrl network param', () => { it('carries the network slug for known chain ids', () => { expect(buildAuthorizeUrl('https://pay.filecoin.cloud', '0xA', ['addPieces'], 314159)).toBe( - 'https://pay.filecoin.cloud/console/session-keys?authorize=0xA&scopes=addPieces&network=calibration' + 'https://pay.filecoin.cloud/console/session-keys?authorize=0xa&scopes=addPieces&network=calibration' ) expect(buildAuthorizeUrl('https://pay.filecoin.cloud', '0xA', ['addPieces'], 314)).toMatch(/&network=mainnet$/) }) diff --git a/src/test/unit/session-scopes.test.ts b/src/test/unit/session-scopes.test.ts index 9464b052..18caf42c 100644 --- a/src/test/unit/session-scopes.test.ts +++ b/src/test/unit/session-scopes.test.ts @@ -1,11 +1,12 @@ import { AddPiecesPermission, CreateDataSetPermission, + PermissionNames, SchedulePieceRemovalsPermission, } from '@filoz/synapse-core/session-key' import { describe, expect, it } from 'vitest' import { TerminateServicePermission } from '../../core/session/index.js' -import { describeScopes, parseScopes, SCOPE_IDS } from '../../session/scopes.js' +import { describeScopes, parseScopes, SCOPE_IDS, SCOPE_PERMISSIONS } from '../../session/scopes.js' describe('parseScopes', () => { it('parses a single scope to its permission typehash', () => { @@ -71,3 +72,18 @@ describe('describeScopes', () => { expect(describeScopes(['addPieces', 'createDataSet'])).toBe('addPieces, createDataSet') }) }) + +describe('scope-id derivation lockstep', () => { + // core/synapse/index.ts derives scope ids by camelCasing the SDK's + // PermissionNames instead of importing this CLI-layer map into core. + // That derivation is only safe while PascalCase->camelCase reproduces + // SCOPE_PERMISSIONS exactly — this cross-check makes a future rename + // fail loudly instead of silently diverging. + it('camelCased PermissionNames reproduce every canonical scope id', () => { + for (const [id, permission] of Object.entries(SCOPE_PERMISSIONS)) { + const name = PermissionNames[permission] + if (name == null) throw new Error(`PermissionNames missing entry for scope "${id}"`) + expect(name.charAt(0).toLowerCase() + name.slice(1)).toBe(id) + } + }) +}) diff --git a/src/test/unit/synapse-service.test.ts b/src/test/unit/synapse-service.test.ts index b93a5cd3..100f11bc 100644 --- a/src/test/unit/synapse-service.test.ts +++ b/src/test/unit/synapse-service.test.ts @@ -174,6 +174,33 @@ describe('synapse-service', () => { } }) + it('should say a previously granted scope expired, with its timestamp, rather than the generic wording', async () => { + // AddPieces WAS granted and lapsed (nonzero past expiry); another grant is + // still live, so this is the missing-scope shape, not never-authorized. + mockSessionKeyOnce((p) => p !== AddPiecesPermission, { + [CreateDataSetPermission]: 9999999999n, + [AddPiecesPermission]: 1000n, // 1970-01-01T00:16:40.000Z — unambiguously past + [SchedulePieceRemovalsPermission]: 9999999999n, + [TerminateServicePermission]: 9999999999n, + }) + + const config: SynapseSetupConfig = { + walletAddress: '0x0000000000000000000000000000000000000002', + sessionKey: '0x0000000000000000000000000000000000000000000000000000000000000001', + rpcUrl: 'wss://wss.calibration.node.glif.io/apigw/lotus/rpc/v1', + requiredPermissions: [AddPiecesPermission], + } + + const error = await initializeSynapse(config, logger).then( + () => null, + (e: unknown) => e as Error + ) + expect(error).not.toBeNull() + expect(error?.message).toContain('lacks AddPieces for this operation on ') + expect(error?.message).toContain('AddPieces: expired at 1970-01-01T00:16:40.000Z') + expect(error?.message).not.toContain('never granted') + }) + it("should use the 'never authorized, or expired/revoked' wording when the key holds no live grant", async () => { mockSessionKeyOnce(() => false, { [CreateDataSetPermission]: 0n, From d194afd4a83a423c7aff143111c2e2fea62ba2f9 Mon Sep 17 00:00:00 2001 From: jennijuju Date: Thu, 27 Aug 2026 22:12:02 -0400 Subject: [PATCH 7/8] fix(session): point calibration console links at /calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed console serves calibration under a path prefix, not the bare host — bare-host remediation links 404 on calibration. Also trims the helper's comments: the stale TEMPORARY/dedupe note is gone (this file is the canonical console-URL builder; the pairing/login work builds on it) and the merge-order NOTE moves to the PR description. --- src/core/session/console-url.ts | 18 ++++-------------- src/test/unit/console-url.test.ts | 2 +- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/core/session/console-url.ts b/src/core/session/console-url.ts index 05a9542a..16ddec54 100644 --- a/src/core/session/console-url.ts +++ b/src/core/session/console-url.ts @@ -1,22 +1,12 @@ /** - * Console URL helper for session-key preflight remediation messages. - * - * TEMPORARY: dedupe with the session console-pairing helpers - * (resolveConsoleUrl/buildAuthorizeUrl) when that work lands on master. + * Console URL helpers: the canonical builder for Filecoin Cloud console + * deep links. Session-key pairing/login work builds on these same helpers. */ -/** - * Known console deployments by chain id. One deployment serves both networks - * (the console switches network in-app), so mainnet and calibration share a - * base URL. `CONSOLE_URL` still overrides for local/preview consoles. - * - * NOTE: links 404 until the console's session-keys page (with its - * ?authorize=&scopes= pairing params) is deployed — this PR is opened - * together with that console PR and must not ship ahead of it. - */ +/** Console deployments by chain id; calibration lives under a path prefix. */ export const DEFAULT_CONSOLE_URLS: Record = { 314: 'https://pay.filecoin.cloud', - 314159: 'https://pay.filecoin.cloud', + 314159: 'https://pay.filecoin.cloud/calibration', } /** diff --git a/src/test/unit/console-url.test.ts b/src/test/unit/console-url.test.ts index 15baa30e..a499f5fb 100644 --- a/src/test/unit/console-url.test.ts +++ b/src/test/unit/console-url.test.ts @@ -15,7 +15,7 @@ describe('resolveConsoleUrl', () => { it('defaults to the production console on both networks', () => { delete process.env.CONSOLE_URL expect(resolveConsoleUrl(314)).toBe('https://pay.filecoin.cloud') - expect(resolveConsoleUrl(314159)).toBe('https://pay.filecoin.cloud') + expect(resolveConsoleUrl(314159)).toBe('https://pay.filecoin.cloud/calibration') }) it('returns undefined for unknown chains', () => { From fdef94aff1ef56ef8b8fe5dd27361819428cf532 Mon Sep 17 00:00:00 2001 From: jennijuju Date: Thu, 27 Aug 2026 22:12:02 -0400 Subject: [PATCH 8/8] docs(synapse): plain-words requiredPermissions doc Review found the preflight comment unreadable; rewritten to say when the check runs, what happens on a missing grant, and that read-only commands leave it unset. --- src/core/synapse/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/synapse/index.ts b/src/core/synapse/index.ts index 8da4576a..b1a64e78 100644 --- a/src/core/synapse/index.ts +++ b/src/core/synapse/index.ts @@ -66,9 +66,11 @@ interface BaseSynapseConfig { /** Default metadata to apply when creating datasets */ dataSetMetadata?: Record /** - * Session-key mode only: on-chain permissions this invocation needs, checked - * up front so a missing grant fails with a remediation message instead of a - * tx revert. Defaults to none — commands that only read require no permissions. + * Session-key mode only: the permissions this command needs. Before doing + * any work, we check the session key's on-chain grants cover these. If one + * is missing, the command stops immediately with instructions for getting + * it granted (console link), rather than failing later mid-transaction. + * Leave unset for read-only commands — they need no permissions. */ requiredPermissions?: Permission[] }