Skip to content
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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` |

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
Expand Down
2 changes: 2 additions & 0 deletions src/add/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -181,6 +182,7 @@ export async function runAdd(options: AddOptions): Promise<AddResult | AddDryRun
config.dataSetMetadata = dataSetMetadata
}
if (withCDN) config.withCDN = true
config.requiredPermissions = [CreateDataSetPermission, AddPiecesPermission]

const synapse = await initializeSynapse(config, logger)
const networkSlug = getNetworkSlug(synapse.chain)
Expand Down
49 changes: 49 additions & 0 deletions src/core/session/console-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Console URL helpers: the canonical builder for Filecoin Cloud console
* deep links. Session-key pairing/login work builds on these same helpers.
*/

/** Console deployments by chain id; calibration lives under a path prefix. */
export const DEFAULT_CONSOLE_URLS: Record<number, string> = {
314: 'https://pay.filecoin.cloud',
314159: 'https://pay.filecoin.cloud/calibration',
}

/**
* 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): string | undefined {
return 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<number, string> = {
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. 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.
*
* 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,
sessionAddress: string,
scopeIds: string[],
chainId?: number
): string {
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.toLowerCase()}&scopes=${scopeIds.join(',')}${networkParam}`
}
106 changes: 76 additions & 30 deletions src/core/synapse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,8 @@ 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 pc from 'picocolors'
import type { Logger } from 'pino'
import {
type Account,
Expand All @@ -32,6 +26,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'
Expand Down Expand Up @@ -70,6 +65,14 @@ interface BaseSynapseConfig {
withCDN?: boolean
/** Default metadata to apply when creating datasets */
dataSetMetadata?: Record<string, string>
/**
* 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[]
}

/**
Expand Down Expand Up @@ -130,14 +133,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<string, string> = {
[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.
Expand All @@ -153,28 +148,75 @@ 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 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(',')

// 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 lines = missing.map((p) => {
const name = PERMISSION_NAMES[p] ?? p
const scopeDetails = missing.map((p) => {
const name = PermissionNames[p] ?? p
const expiry = key.expirations[p] ?? 0n
if (expiry > 0n && expiry < now) {
if (expiry > 0n && expiry <= now) {
return ` • ${name}: expired at ${new Date(Number(expiry) * 1000).toISOString()}`
}
return ` • ${name}: never authorized`
return ` • ${name}: never granted`
})

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 = 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
// 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).')
}
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'))
}

/**
Expand Down Expand Up @@ -231,7 +273,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)
Expand Down Expand Up @@ -280,6 +322,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
Expand Down
3 changes: 2 additions & 1 deletion src/data-set/run.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions src/filecoin-pinning-server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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],
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/import/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -251,6 +252,7 @@ export async function runCarImport(options: ImportOptions): Promise<ImportResult
config.dataSetMetadata = dataSetMetadata
}
if (withCDN) config.withCDN = true
config.requiredPermissions = [CreateDataSetPermission, AddPiecesPermission]

const synapse = await initializeSynapse(config, logger)
const networkSlug = getNetworkSlug(synapse.chain)
Expand Down
2 changes: 2 additions & 0 deletions src/rm/remove-all-pieces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* - Return aggregated results (or throw on failure)
*/
import { confirm, isCancel } from '@clack/prompts'
import { SchedulePieceRemovalsPermission } from '@filoz/synapse-core/session-key'
import pc from 'picocolors'
import pino from 'pino'
import { setIncompleteExitCode } from '../common/cli-errors.js'
Expand Down Expand Up @@ -62,6 +63,7 @@ export async function runRmAllPieces(options: RmAllPiecesOptions): Promise<RmAll
spinner.start('Initializing Synapse SDK...')

const authConfig = parseCLIAuth(options)
authConfig.requiredPermissions = [SchedulePieceRemovalsPermission]
const synapse = await initializeSynapse(authConfig, logger)
const network = synapse.chain.name

Expand Down
3 changes: 3 additions & 0 deletions src/rm/remove-piece.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* - Wire up progress events to spinner output
* - Return transaction hash and confirmation status (or throw on failure)
*/

import { SchedulePieceRemovalsPermission } from '@filoz/synapse-core/session-key'
import pc from 'picocolors'
import pino from 'pino'
import { setIncompleteExitCode } from '../common/cli-errors.js'
Expand Down Expand Up @@ -59,6 +61,7 @@ export async function runRmPiece(options: RmPieceOptions): Promise<RmPieceResult
spinner.start('Initializing Synapse SDK...')

const authConfig = parseCLIAuth(options)
authConfig.requiredPermissions = [SchedulePieceRemovalsPermission]
const synapse = await initializeSynapse(authConfig, logger)
const network = synapse.chain.name

Expand Down
7 changes: 7 additions & 0 deletions src/test/mocks/synapse-core-session-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export const DefaultFwssPermissions = [
SchedulePieceRemovalsPermission,
]

export const PermissionNames: Record<string, string> = {
[CreateDataSetPermission]: 'CreateDataSet',
[TerminateServicePermission]: 'TerminateService',
[AddPiecesPermission]: 'AddPieces',
[SchedulePieceRemovalsPermission]: 'SchedulePieceRemovals',
}

const mockExpirations = Object.fromEntries(DefaultFwssPermissions.map((p) => [p, 0n]))

export const fromSecp256k1: Mock = vi.fn(() => ({
Expand Down
Loading
Loading