diff --git a/docs/src/content/docs/developer-guides/session-keys.mdx b/docs/src/content/docs/developer-guides/session-keys.mdx index 2a56ce30..6db6f54d 100644 --- a/docs/src/content/docs/developer-guides/session-keys.mdx +++ b/docs/src/content/docs/developer-guides/session-keys.mdx @@ -196,15 +196,30 @@ const synapse = Synapse.create({ }) ``` -`Synapse.create()` validates that the session key has all four FWSS permissions (`DefaultFwssPermissions`) and that none are expired. This means the session key's expirations must be populated before construction, either by passing `expirations` to `fromSecp256k1()`, or by calling `sessionKey.syncExpirations()` after login. +By default, `Synapse.create()` validates that the session key has all four FWSS permissions (`SessionKey.DefaultFwssPermissions`) and that none are expired. This means the session key's expirations must be populated before construction, either by passing `expirations` to `fromSecp256k1()`, or by calling `sessionKey.syncExpirations()` after login. -#### All-or-nothing in `@filoz/synapse-sdk` +The error thrown by `Synapse.create()` names the specific permissions that are missing or expired (distinguishing "not authorized" from "expired at <timestamp>") so you can decide whether to extend the session key's authorizations or narrow the required set. -`@filoz/synapse-sdk` is the high-level "golden path" entry point and deliberately takes an all-or-nothing stance on session key permissions: `Synapse.create()` will throw if any of `CreateDataSet`, `AddPieces`, `SchedulePieceRemovals`, or `TerminateService` is missing or expired. This keeps the API surface predictable — every operation the high-level SDK exposes will work once construction succeeds. +#### Narrowing the required scope -If you want a narrower scope (for example, only `CreateDataSet` + `AddPieces` for an upload-only client), drop down to [`@filoz/synapse-core`](/developer-guides/synapse-core/) directly. The core package's `SessionKey` and SP-client functions check permissions per operation, so you can authorize the minimum set you need and call those operations directly without going through `Synapse.create()`. +By default `@filoz/synapse-sdk` takes an all-or-nothing stance: every operation the high-level SDK exposes works once construction succeeds. If your app only exercises a subset of those operations — for example an upload-only client that needs `CreateDataSet` + `AddPieces` but never schedules removals or terminates services — pass `requiredPermissions` so that `Synapse.create()` validates only what you actually use: -The error thrown by `Synapse.create()` names the specific permissions that are missing or expired so you can decide whether to extend the session key's authorizations or switch to `@filoz/synapse-core` for that flow. +```ts +const synapse = Synapse.create({ + account: rootAccount, + chain: calibration, + transport: http(rpcUrl), + sessionKey: sessionKey, + requiredPermissions: [ + SessionKey.CreateDataSetPermission, + SessionKey.AddPiecesPermission, + ], +}) +``` + +`requiredPermissions` defaults to `SessionKey.DefaultFwssPermissions`, so existing callers see no behavior change. Note that `requiredPermissions` only gates construction — the SDK does **not** enforce per-operation checks. Calling an SDK method whose permission is not in `requiredPermissions` will still go through, and if the session key is not authorized for that operation it will revert on-chain. Use `requiredPermissions` to gate construction, and only call SDK methods you have authorized. + +If you want per-operation permission enforcement, drop down to [`@filoz/synapse-core`](/developer-guides/synapse-core/) directly. The core package's `SessionKey` and SP-client functions check permissions per operation, so you can authorize the minimum set you need and call those operations directly without going through `Synapse.create()`. ### Revoke the session key diff --git a/packages/synapse-sdk/src/synapse.ts b/packages/synapse-sdk/src/synapse.ts index 79129548..42088265 100644 --- a/packages/synapse-sdk/src/synapse.ts +++ b/packages/synapse-sdk/src/synapse.ts @@ -60,21 +60,23 @@ export class Synapse { if (options.sessionKey != null) { const sessionKey = options.sessionKey - const missing = SessionKey.DefaultFwssPermissions.filter( - (permission) => !sessionKey.hasPermission(permission) - ).map((permission) => { - const name = SessionKey.PermissionNames[permission] ?? permission - const expiry = sessionKey.expirations[permission] - if (expiry == null || expiry === 0n) { - return `${name} (not authorized)` - } - return `${name} (expired at ${new Date(Number(expiry) * 1000).toISOString()})` - }) + const requiredPermissions = options.requiredPermissions ?? SessionKey.DefaultFwssPermissions + const missing = requiredPermissions + .filter((permission) => !sessionKey.hasPermission(permission)) + .map((permission) => { + const name = SessionKey.PermissionNames[permission] ?? permission + const expiry = sessionKey.expirations[permission] + if (expiry == null || expiry === 0n) { + return `${name} (not authorized)` + } + return `${name} (expired at ${new Date(Number(expiry) * 1000).toISOString()})` + }) if (missing.length > 0) { throw new Error( `Session key is missing required FWSS permissions: ${missing.join(', ')}. ` + - 'Synapse.create requires every permission in SessionKey.DefaultFwssPermissions to be authorized and unexpired. ' + + 'Synapse.create requires every permission in requiredPermissions (defaults to SessionKey.DefaultFwssPermissions) to be authorized and unexpired. ' + 'Authorize the session key for all of them (SessionKey.login) and refresh local state (sessionKey.syncExpirations), ' + + 'pass a narrower requiredPermissions set, ' + 'or drop down to @filoz/synapse-core to operate with a custom permission scope. ' + 'See https://docs.filecoin.cloud/developer-guides/session-keys/ for details.' ) diff --git a/packages/synapse-sdk/src/test/session-keys.test.ts b/packages/synapse-sdk/src/test/session-keys.test.ts index 8f7fb883..b503a6f2 100644 --- a/packages/synapse-sdk/src/test/session-keys.test.ts +++ b/packages/synapse-sdk/src/test/session-keys.test.ts @@ -208,17 +208,18 @@ describe('Synapse', () => { }) describe('Synapse.create permission validation', () => { + const now = () => BigInt(Math.floor(Date.now() / 1000)) + it('should throw an informative error listing not-authorized and expired permissions', () => { - const now = BigInt(Math.floor(Date.now() / 1000)) const sessionKey = SessionKey.fromSecp256k1({ chain: calibration, privateKey: Mocks.PRIVATE_KEYS.key2, root: client.account, expirations: { - [SessionKey.CreateDataSetPermission]: now + 3600n, + [SessionKey.CreateDataSetPermission]: now() + 3600n, [SessionKey.AddPiecesPermission]: 0n, - [SessionKey.SchedulePieceRemovalsPermission]: now - 3600n, - [SessionKey.TerminateServicePermission]: now + 3600n, + [SessionKey.SchedulePieceRemovalsPermission]: now() - 3600n, + [SessionKey.TerminateServicePermission]: now() + 3600n, }, }) @@ -239,22 +240,90 @@ describe('Synapse', () => { it('should not throw when all FWSS permissions are valid', () => { server.use(Mocks.JSONRPC(Mocks.presets.basic)) - const now = BigInt(Math.floor(Date.now() / 1000)) const sessionKey = SessionKey.fromSecp256k1({ chain: calibration, privateKey: Mocks.PRIVATE_KEYS.key2, root: client.account, expirations: { - [SessionKey.CreateDataSetPermission]: now + 3600n, - [SessionKey.AddPiecesPermission]: now + 3600n, - [SessionKey.SchedulePieceRemovalsPermission]: now + 3600n, - [SessionKey.TerminateServicePermission]: now + 3600n, + [SessionKey.CreateDataSetPermission]: now() + 3600n, + [SessionKey.AddPiecesPermission]: now() + 3600n, + [SessionKey.SchedulePieceRemovalsPermission]: now() + 3600n, + [SessionKey.TerminateServicePermission]: now() + 3600n, }, }) const synapse = Synapse.create({ chain: calibration, account, source: null, sessionKey }) assert.exists(synapse) }) + + it('should succeed when only the narrower required scope is authorized', () => { + server.use(Mocks.JSONRPC(Mocks.presets.basic)) + const sessionKey = SessionKey.fromSecp256k1({ + chain: calibration, + privateKey: Mocks.PRIVATE_KEYS.key2, + root: client.account, + expirations: { + [SessionKey.CreateDataSetPermission]: now() + 3600n, + [SessionKey.AddPiecesPermission]: now() + 3600n, + [SessionKey.SchedulePieceRemovalsPermission]: 0n, + [SessionKey.TerminateServicePermission]: 0n, + }, + }) + + const synapse = Synapse.create({ + chain: calibration, + account, + source: null, + sessionKey, + requiredPermissions: [SessionKey.CreateDataSetPermission, SessionKey.AddPiecesPermission], + }) + assert.exists(synapse) + }) + + it('should still throw when a required permission is missing from the narrower set', () => { + const sessionKey = SessionKey.fromSecp256k1({ + chain: calibration, + privateKey: Mocks.PRIVATE_KEYS.key2, + root: client.account, + expirations: { + [SessionKey.CreateDataSetPermission]: now() + 3600n, + [SessionKey.AddPiecesPermission]: 0n, + [SessionKey.SchedulePieceRemovalsPermission]: now() + 3600n, + [SessionKey.TerminateServicePermission]: now() + 3600n, + }, + }) + + assert.throws( + () => + Synapse.create({ + chain: calibration, + account, + source: null, + sessionKey, + requiredPermissions: [SessionKey.CreateDataSetPermission, SessionKey.AddPiecesPermission], + }), + /Session key is missing required FWSS permissions/ + ) + }) + + it('should default to DefaultFwssPermissions when requiredPermissions is omitted', () => { + const sessionKey = SessionKey.fromSecp256k1({ + chain: calibration, + privateKey: Mocks.PRIVATE_KEYS.key2, + root: client.account, + expirations: { + [SessionKey.CreateDataSetPermission]: now() + 3600n, + [SessionKey.AddPiecesPermission]: now() + 3600n, + [SessionKey.SchedulePieceRemovalsPermission]: 0n, + [SessionKey.TerminateServicePermission]: 0n, + }, + }) + + assert.throws( + () => Synapse.create({ chain: calibration, account, source: null, sessionKey }), + /Session key is missing required FWSS permissions/ + ) + }) }) }) }) diff --git a/packages/synapse-sdk/src/types.ts b/packages/synapse-sdk/src/types.ts index 46b8c710..2eb0f252 100644 --- a/packages/synapse-sdk/src/types.ts +++ b/packages/synapse-sdk/src/types.ts @@ -7,7 +7,7 @@ import type { Chain } from '@filoz/synapse-core/chains' import type { PieceCID } from '@filoz/synapse-core/piece' -import type { SessionKey, SessionKeyAccount } from '@filoz/synapse-core/session-key' +import type { Permission, SessionKey, SessionKeyAccount } from '@filoz/synapse-core/session-key' import type { pullPiecesApiRequest } from '@filoz/synapse-core/sp' import type { PDPProvider } from '@filoz/synapse-core/sp-registry' import type { MetadataObject } from '@filoz/synapse-core/utils' @@ -112,6 +112,22 @@ export interface SynapseOptions { sessionKey?: SessionKey<'Secp256k1'> + /** + * The set of session key permissions `Synapse.create` validates as authorized and unexpired. + * + * Defaults to `SessionKey.DefaultFwssPermissions` (all four FWSS permissions: + * `CreateDataSet`, `AddPieces`, `SchedulePieceRemovals`, `TerminateService`), which matches + * the operations exposed by the high-level Synapse class. + * + * Pass a narrower array (e.g. `[CreateDataSetPermission, AddPiecesPermission]`) to keep + * least-privilege session keys on the `Synapse.create` happy path when the app only exercises + * a subset of the SDK surface. Operations whose permissions are not listed here will revert + * on-chain if attempted; the SDK does not enforce per-operation checks. + * + * Only meaningful together with `sessionKey`. + */ + requiredPermissions?: Permission[] + /** Whether to use CDN for retrievals (default: false) */ withCDN?: boolean