From 57655f0edf3ce430f8d7bbc56bec086cbacae358 Mon Sep 17 00:00:00 2001 From: arandomogg Date: Sat, 29 Aug 2026 22:21:51 +0100 Subject: [PATCH] contrib: add experimental feature flag pattern for x402 signer policies Self-contained reference for issue #284 under contrib/examples. Demonstrates gating experimental signer policy behavior behind a named experimental* flag so early adopters can opt in without a breaking change for everyone else. The gated behavior: capability rules accept a "*" wildcard by default, and a wildcard left behind after copying an example silently widens what a session key will sign. Setting experimentalStrictWildcardCapabilities rejects any wildcard rule at construction, requiring fully explicit rules. Omitted or false reproduces current behavior exactly. Includes 20 tests covering rule validation, capability evaluation, and that flagged and unflagged construction diverge only on wildcard rules, plus a README documenting the pattern and the risks of enabling the flag. --- .../README.md | 65 ++++++ .../experimental-signer-policy-flag.test.ts | 168 +++++++++++++++ .../experimental-signer-policy-flag.ts | 203 ++++++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 contrib/examples/issue-284-experimental-signer-policy-flag/README.md create mode 100644 contrib/examples/issue-284-experimental-signer-policy-flag/experimental-signer-policy-flag.test.ts create mode 100644 contrib/examples/issue-284-experimental-signer-policy-flag/experimental-signer-policy-flag.ts 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(); +}