diff --git a/contrib/examples/issue-284-experimental-signer-policy-flag/README.md b/contrib/examples/issue-284-experimental-signer-policy-flag/README.md new file mode 100644 index 0000000..eb31f10 --- /dev/null +++ b/contrib/examples/issue-284-experimental-signer-policy-flag/README.md @@ -0,0 +1,65 @@ +# Experimental feature flag for x402 signer policies + +Self-contained reference for issue [#284](https://github.com/Vellar-Wallet/vellar-sdk/issues/284): a feature-flag pattern for gating experimental x402 signer policy behavior, so early adopters can opt in without a hard breaking change for everyone else. + +## The pattern + +An experimental signer policy change ships behind a named `experimental*` +boolean on the signer config: + +1. The new behavior lives behind a named `experimental*` flag. +2. Omitted / `false` reproduces today's behavior **exactly**. +3. `true` opts into the new (here, stricter) behavior. +4. Validation runs at **construction**, not sign time, so a bad config fails + loudly before it can ever sign. + +## The behavior being gated + +The real vellar-sdk signers (`src/x402-signer.ts`) accept a `capabilities` +rule set whose `resourceType` / `action` fields each allow a `"*"` wildcard. +A wildcard left behind after copy-pasting an example silently widens what a +session key will sign. Tightening that by default would break every existing +config that relies on wildcards — so it's gated: + +```ts +const signer = createMockSigner({ + address: walletCAddress, + capabilities: [{ resourceType: usdcSac, action: "transfer" }], + // Any rule using "*" now throws InvalidCapabilityRuleError at construction, + // instead of being silently accepted. + experimentalStrictWildcardCapabilities: true, +}); +``` + +| Config | Unflagged (default) | Flagged (`true`) | +| ------ | ------------------- | ---------------- | +| `[{ resourceType: usdc, action: "*" }]` | accepted, signs | **rejected at construction** | +| `[{ resourceType: usdc, action: "transfer" }]` | accepted, signs | accepted, signs (unchanged) | +| `capabilities` omitted entirely | permits everything | permits everything (unchanged) | + +## Risks of enabling the flag + +- **Experimental.** It may change shape or be removed in a future release + without a major-version bump, per this package's pre-1.0 status. +- **Breaking for the signer you enable it on.** If that config currently + relies on a wildcard rule, construction throws instead of succeeding. Only + enable it once your `capabilities` list is already fully explicit, or use it + as a CI check ahead of tightening a config for production. +- **No effect without wildcard rules.** A config with no `capabilities`, or + with fully explicit ones, behaves exactly as before — so enabling it there + buys nothing and costs nothing. +- **Client-side only.** This narrows what the SDK will attempt to sign in this + process. It is not a substitute for the on-chain `SignerLimits` / Policy + mechanism, which is the only check a compromised host process cannot bypass. + +## Run it + +```sh +npx tsx experimental-signer-policy-flag.ts +``` + +## Tests + +```sh +npx vitest run contrib/examples/issue-284-experimental-signer-policy-flag +``` diff --git a/contrib/examples/issue-284-experimental-signer-policy-flag/experimental-signer-policy-flag.test.ts b/contrib/examples/issue-284-experimental-signer-policy-flag/experimental-signer-policy-flag.test.ts new file mode 100644 index 0000000..885ae0d --- /dev/null +++ b/contrib/examples/issue-284-experimental-signer-policy-flag/experimental-signer-policy-flag.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + assertValidCapabilityRules, + CapabilityDeniedError, + createMockSigner, + evaluateCapability, + InvalidCapabilityRuleError, + type MockSignerConfig, +} from "./experimental-signer-policy-flag"; + +const WALLET = "CAFIATCEAZJTGQQKFL3N2YB6VMCUN2UYX4QD5A3FALDRU7UJJ6OWBKOW"; +const USDC = "CBIN4HTPJM2QLJ32DTRO6OCLIMM7TR7D74JDIPVQYLNYGL7SBWOXH5ND"; +const OTHER = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + +describe("assertValidCapabilityRules", () => { + it("accepts an empty rule set", () => { + expect(() => assertValidCapabilityRules([])).not.toThrow(); + }); + + it("rejects a resourceType that is not a contract id or wildcard", () => { + expect(() => + assertValidCapabilityRules([{ resourceType: "not-a-contract", action: "transfer" }]), + ).toThrow(InvalidCapabilityRuleError); + }); + + it("rejects an action containing invalid characters", () => { + expect(() => + assertValidCapabilityRules([{ resourceType: USDC, action: "trans-fer!" }]), + ).toThrow(InvalidCapabilityRuleError); + }); + + it("rejects an action longer than 32 characters", () => { + expect(() => + assertValidCapabilityRules([{ resourceType: USDC, action: "a".repeat(33) }]), + ).toThrow(InvalidCapabilityRuleError); + }); +}); + +describe("the experimental flag (strictWildcards)", () => { + it("accepts wildcard rules when the flag is not set (default, unchanged)", () => { + expect(() => assertValidCapabilityRules([{ resourceType: "*", action: "*" }])).not.toThrow(); + }); + + it("accepts wildcard rules when the flag is explicitly false", () => { + expect(() => + assertValidCapabilityRules([{ resourceType: USDC, action: "*" }], false), + ).not.toThrow(); + }); + + it("rejects a wildcard resourceType when the flag is true", () => { + expect(() => + assertValidCapabilityRules([{ resourceType: "*", action: "transfer" }], true), + ).toThrow(InvalidCapabilityRuleError); + }); + + it("rejects a wildcard action when the flag is true", () => { + expect(() => + assertValidCapabilityRules([{ resourceType: USDC, action: "*" }], true), + ).toThrow(InvalidCapabilityRuleError); + }); + + it("still accepts fully explicit rules when the flag is true", () => { + expect(() => + assertValidCapabilityRules([{ resourceType: USDC, action: "transfer" }], true), + ).not.toThrow(); + }); + + it("names the offending rule and the flag in the error message", () => { + try { + assertValidCapabilityRules([{ resourceType: USDC, action: "*" }], true); + expect.fail("expected a throw"); + } catch (err) { + expect((err as Error).message).toMatch(/experimentalStrictWildcardCapabilities/); + expect((err as Error).message).toMatch(/wildcard/); + } + }); +}); + +describe("evaluateCapability", () => { + it("permits everything when no rules are configured (opt-in scoping)", () => { + expect(evaluateCapability([], { resourceType: USDC, action: "transfer" })).toBe(true); + }); + + it("permits an exact resourceType + action match", () => { + const rules = [{ resourceType: USDC, action: "transfer" }]; + expect(evaluateCapability(rules, { resourceType: USDC, action: "transfer" })).toBe(true); + }); + + it("denies a different resourceType or action", () => { + const rules = [{ resourceType: USDC, action: "transfer" }]; + expect(evaluateCapability(rules, { resourceType: OTHER, action: "transfer" })).toBe(false); + expect(evaluateCapability(rules, { resourceType: USDC, action: "burn" })).toBe(false); + }); + + it("honours wildcards in either field", () => { + expect( + evaluateCapability([{ resourceType: USDC, action: "*" }], { + resourceType: USDC, + action: "burn", + }), + ).toBe(true); + expect( + evaluateCapability([{ resourceType: "*", action: "transfer" }], { + resourceType: OTHER, + action: "transfer", + }), + ).toBe(true); + }); +}); + +describe("createMockSigner: flagged vs unflagged behavior differs", () => { + const wildcardConfig: MockSignerConfig = { + address: WALLET, + capabilities: [{ resourceType: USDC, action: "*" }], + }; + + it("the same wildcard config is accepted unflagged and rejected flagged", () => { + // Default (flag unset): accepted, and signs. + const lenient = createMockSigner(wildcardConfig); + expect(lenient.signInvocation({ resourceType: USDC, action: "burn" })).toContain("signed:"); + + // Flagged: the identical config now throws at construction. + expect(() => + createMockSigner({ ...wildcardConfig, experimentalStrictWildcardCapabilities: true }), + ).toThrow(InvalidCapabilityRuleError); + }); + + it("explicitly setting the flag to false matches default (unflagged) behavior", () => { + expect(() => + createMockSigner({ ...wildcardConfig, experimentalStrictWildcardCapabilities: false }), + ).not.toThrow(); + }); + + it("does not affect a signer whose rules contain no wildcards", () => { + const signer = createMockSigner({ + address: WALLET, + capabilities: [{ resourceType: USDC, action: "transfer" }], + experimentalStrictWildcardCapabilities: true, + }); + expect(signer.signInvocation({ resourceType: USDC, action: "transfer" })).toBe( + `signed:transfer@${USDC}`, + ); + }); + + it("does not affect a signer with no capabilities configured at all", () => { + const signer = createMockSigner({ + address: WALLET, + experimentalStrictWildcardCapabilities: true, + }); + // No scoping configured means everything is permitted, flag or not. + expect(signer.signInvocation({ resourceType: OTHER, action: "anything" })).toContain("signed:"); + }); + + it("still enforces the capability check itself under the flag", () => { + const signer = createMockSigner({ + address: WALLET, + capabilities: [{ resourceType: USDC, action: "transfer" }], + experimentalStrictWildcardCapabilities: true, + }); + expect(() => signer.signInvocation({ resourceType: USDC, action: "burn" })).toThrow( + CapabilityDeniedError, + ); + }); + + it("exposes the signer address unchanged", () => { + expect(createMockSigner({ address: WALLET }).address).toBe(WALLET); + }); +}); diff --git a/contrib/examples/issue-284-experimental-signer-policy-flag/experimental-signer-policy-flag.ts b/contrib/examples/issue-284-experimental-signer-policy-flag/experimental-signer-policy-flag.ts new file mode 100644 index 0000000..7a81746 --- /dev/null +++ b/contrib/examples/issue-284-experimental-signer-policy-flag/experimental-signer-policy-flag.ts @@ -0,0 +1,203 @@ +// Self-contained reference for issue #284: a feature-flag pattern for gating +// experimental x402 signer policy behavior, so early adopters can opt in +// without a hard breaking change for everyone else. +// +// The real vellar-sdk signers (src/x402-signer.ts) accept a `capabilities` +// rule set whose `resourceType`/`action` fields each allow a `"*"` wildcard. +// A wildcard left in place after copy-pasting an example silently widens what +// a session key will sign. Tightening that by default would break every +// existing config that relies on wildcards — so the change is gated behind an +// experimental flag instead. This is a standalone, dependency-free +// demonstration of that flag pattern. +// +// The pattern itself is the point, and it generalizes to any experimental +// signer policy change: +// +// 1. The new behavior lives behind a named `experimental*` boolean. +// 2. Omitted / `false` reproduces today's behavior EXACTLY. +// 3. `true` opts into the stricter (or simply different) behavior. +// 4. Validation happens at construction, not at sign time, so a bad +// config fails loudly before it can sign anything. +// +// Run with: npx tsx experimental-signer-policy-flag.ts + +/** One resource+action a signer is permitted to authorize. `"*"` matches any + * value for that field. */ +export interface CapabilityRule { + /** The contract this rule applies to (a SEP-41 token), or `"*"` for any. */ + resourceType: string; + /** The Soroban function name this rule permits, or `"*"` for any. */ + action: string; +} + +/** Config for the mock signer, mirroring the shape of the real + * `SessionKeySignerConfig` but carrying only what this example needs. */ +export interface MockSignerConfig { + /** The smart-account C-address that pays. */ + address: string; + /** Client-side capability scoping. Omit for no scoping. */ + capabilities?: readonly CapabilityRule[]; + /** + * EXPERIMENTAL — opt in to stricter capability-rule validation: any rule + * using a `"*"` wildcard is rejected at construction, requiring every rule + * to be fully explicit. + * + * Default (`false`/omitted): unchanged — wildcard rules are accepted, same + * as every signer built before this flag existed. + */ + experimentalStrictWildcardCapabilities?: boolean; +} + +/** Thrown when a capability rule is malformed, or when a wildcard rule is + * used while the experimental strict flag is enabled. */ +export class InvalidCapabilityRuleError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidCapabilityRuleError"; + } +} + +/** Thrown by the signer when a requested invocation matches no rule. */ +export class CapabilityDeniedError extends Error { + constructor( + readonly request: { resourceType: string; action: string }, + readonly rules: readonly CapabilityRule[], + ) { + super( + `capability check denied ${request.action} on ${request.resourceType}: ` + + `no configured rule permits it (${rules.length} rule(s) configured).`, + ); + this.name = "CapabilityDeniedError"; + } +} + +const CONTRACT_ID = /^C[A-Z2-7]{55}$/; +// Soroban symbols: ASCII alphanumeric + underscore, <= 32 chars. +const SOROBAN_SYMBOL = /^[A-Za-z0-9_]{1,32}$/; + +/** + * Validate a capability rule set at construction time. + * + * `strictWildcards` is the experimental gate: when `true`, any rule using + * `"*"` for `resourceType` or `action` is additionally rejected. When + * `false` (the default), wildcard rules are perfectly valid — this is what + * keeps the flag non-breaking for existing consumers. + */ +export function assertValidCapabilityRules( + rules: readonly CapabilityRule[], + strictWildcards = false, +): void { + for (const rule of rules) { + if (rule.resourceType !== "*" && !CONTRACT_ID.test(rule.resourceType)) { + throw new InvalidCapabilityRuleError( + `capability rule resourceType must be a contract id (C…) or "*": got ${JSON.stringify(rule.resourceType)}`, + ); + } + if (rule.action !== "*" && !SOROBAN_SYMBOL.test(rule.action)) { + throw new InvalidCapabilityRuleError( + `capability rule action must be a Soroban function-name symbol or "*": got ${JSON.stringify(rule.action)}`, + ); + } + if (strictWildcards && (rule.resourceType === "*" || rule.action === "*")) { + throw new InvalidCapabilityRuleError( + `capability rule uses a wildcard ("*") but experimentalStrictWildcardCapabilities ` + + `is enabled, which requires every rule to name an explicit resourceType ` + + `and action: got ${JSON.stringify(rule)}`, + ); + } + } +} + +/** + * Does any rule permit `request`? An EMPTY rule set means "no scoping + * configured" and permits everything — the backward-compatible default for a + * signer that never opted into capability scoping at all. + */ +export function evaluateCapability( + rules: readonly CapabilityRule[], + request: { resourceType: string; action: string }, +): boolean { + if (rules.length === 0) return true; + return rules.some( + (rule) => + (rule.resourceType === "*" || rule.resourceType === request.resourceType) && + (rule.action === "*" || rule.action === request.action), + ); +} + +export interface MockSigner { + readonly address: string; + /** Mock "sign": asserts the invocation is permitted, then returns a + * placeholder signature. The real signer would build the auth-entry + * signature map here. */ + signInvocation(request: { resourceType: string; action: string }): string; +} + +/** + * Build a mock signer, applying the experimental flag at CONSTRUCTION time. + * + * This is the crux of the pattern: the flag changes whether construction + * succeeds, not whether a later signature is produced. A config that would + * be rejected under the flag fails immediately and visibly, rather than + * behaving subtly differently at sign time. + */ +export function createMockSigner(config: MockSignerConfig): MockSigner { + const capabilities = config.capabilities ?? []; + assertValidCapabilityRules( + capabilities, + config.experimentalStrictWildcardCapabilities ?? false, + ); + + return { + address: config.address, + signInvocation(request) { + if (!evaluateCapability(capabilities, request)) { + throw new CapabilityDeniedError(request, capabilities); + } + return `signed:${request.action}@${request.resourceType}`; + }, + }; +} + +function main() { + const WALLET = "CAFIATCEAZJTGQQKFL3N2YB6VMCUN2UYX4QD5A3FALDRU7UJJ6OWBKOW"; + const USDC = "CBIN4HTPJM2QLJ32DTRO6OCLIMM7TR7D74JDIPVQYLNYGL7SBWOXH5ND"; + + // A config that leans on a wildcard — the kind left behind after copying + // an example. + const wildcardConfig: MockSignerConfig = { + address: WALLET, + capabilities: [{ resourceType: USDC, action: "*" }], + }; + + // 1. UNFLAGGED (today's behavior): the wildcard is accepted. + const lenient = createMockSigner(wildcardConfig); + console.log("unflagged, wildcard rule :", lenient.signInvocation({ resourceType: USDC, action: "burn" })); + + // 2. FLAGGED: the very same config is now rejected at construction. + try { + createMockSigner({ ...wildcardConfig, experimentalStrictWildcardCapabilities: true }); + console.log("flagged, wildcard rule : UNEXPECTED — should have thrown"); + } catch (err) { + console.log("flagged, wildcard rule : rejected —", (err as Error).name); + } + + // 3. FLAGGED with fully explicit rules: unaffected, signs as normal. + const strict = createMockSigner({ + address: WALLET, + capabilities: [{ resourceType: USDC, action: "transfer" }], + experimentalStrictWildcardCapabilities: true, + }); + console.log("flagged, explicit rules :", strict.signInvocation({ resourceType: USDC, action: "transfer" })); + + // 4. The capability check itself still applies regardless of the flag. + try { + strict.signInvocation({ resourceType: USDC, action: "burn" }); + } catch (err) { + console.log("flagged, out-of-scope action: denied —", (err as Error).name); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +}